wickra_core/indicators/
trend_label.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
41pub struct TrendLabel {
42 period: usize,
43 window: VecDeque<f64>,
44}
45
46impl TrendLabel {
47 pub fn new(period: usize) -> Result<Self> {
53 if period < 2 {
54 return Err(Error::InvalidPeriod {
55 message: "trend label needs period >= 2",
56 });
57 }
58 if period > crate::error::MAX_PERIOD {
59 return Err(Error::InvalidPeriod {
60 message: crate::error::PERIOD_ABOVE_MAX,
61 });
62 }
63 Ok(Self {
64 period,
65 window: VecDeque::with_capacity(period),
66 })
67 }
68
69 pub const fn period(&self) -> usize {
71 self.period
72 }
73}
74
75impl Indicator for TrendLabel {
76 type Input = f64;
77 type Output = f64;
78
79 #[inline]
80 fn update(&mut self, value: f64) -> Option<f64> {
81 if !value.is_finite() {
82 return None;
83 }
84 if self.window.len() == self.period {
85 self.window.pop_front();
86 }
87 self.window.push_back(value);
88 if self.window.len() < self.period {
89 return None;
90 }
91 let count = self.period as f64;
92 let mean_t = (count - 1.0) / 2.0;
93 let mean_x = self.window.iter().sum::<f64>() / count;
94 let mut numerator = 0.0;
97 for (t, &x) in self.window.iter().enumerate() {
98 numerator += (t as f64 - mean_t) * (x - mean_x);
99 }
100 let label = if numerator > 0.0 {
101 1.0
102 } else if numerator < 0.0 {
103 -1.0
104 } else {
105 0.0
106 };
107 Some(label)
108 }
109
110 fn reset(&mut self) {
111 self.window.clear();
112 }
113
114 #[inline]
115 fn warmup_period(&self) -> usize {
116 self.period
117 }
118
119 #[inline]
120 fn is_ready(&self) -> bool {
121 self.window.len() == self.period
122 }
123
124 #[inline]
125 fn name(&self) -> &'static str {
126 "TrendLabel"
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::traits::BatchExt;
134
135 #[test]
136 fn rejects_period_below_two() {
137 assert!(matches!(
138 TrendLabel::new(1),
139 Err(Error::InvalidPeriod { .. })
140 ));
141 assert!(TrendLabel::new(2).is_ok());
142 }
143
144 #[test]
145 fn accessors_and_metadata() {
146 let tl = TrendLabel::new(10).unwrap();
147 assert_eq!(tl.period(), 10);
148 assert_eq!(tl.warmup_period(), 10);
149 assert_eq!(tl.name(), "TrendLabel");
150 assert!(!tl.is_ready());
151 }
152
153 #[test]
154 fn rising_series_is_plus_one() {
155 let mut tl = TrendLabel::new(10).unwrap();
156 let prices: Vec<f64> = (0..20).map(f64::from).collect();
157 assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(1.0));
158 }
159
160 #[test]
161 fn falling_series_is_minus_one() {
162 let mut tl = TrendLabel::new(10).unwrap();
163 let prices: Vec<f64> = (0..20).map(|i| 100.0 - f64::from(i)).collect();
164 assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(-1.0));
165 }
166
167 #[test]
168 fn flat_series_is_zero() {
169 let mut tl = TrendLabel::new(8).unwrap();
170 for v in tl.batch(&[42.0; 16]).into_iter().flatten() {
171 assert_eq!(v, 0.0);
172 }
173 }
174
175 #[test]
176 fn scale_invariant_sign() {
177 let prices: Vec<f64> = (0..30)
179 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
180 .collect();
181 let small = TrendLabel::new(12).unwrap().batch(&prices);
182 let scaled: Vec<f64> = prices.iter().map(|p| p * 1000.0).collect();
183 let large = TrendLabel::new(12).unwrap().batch(&scaled);
184 assert_eq!(small, large);
185 }
186
187 #[test]
188 fn output_is_ternary() {
189 let mut tl = TrendLabel::new(14).unwrap();
190 let prices: Vec<f64> = (0..200)
191 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
192 .collect();
193 for v in tl.batch(&prices).into_iter().flatten() {
194 assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
195 }
196 }
197
198 #[test]
199 fn reset_clears_state() {
200 let mut tl = TrendLabel::new(5).unwrap();
201 tl.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
202 assert!(tl.is_ready());
203 tl.reset();
204 assert!(!tl.is_ready());
205 assert_eq!(tl.update(1.0), None);
206 }
207
208 #[test]
209 fn batch_equals_streaming() {
210 let prices: Vec<f64> = (0..60)
211 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
212 .collect();
213 let batch = TrendLabel::new(14).unwrap().batch(&prices);
214 let mut b = TrendLabel::new(14).unwrap();
215 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
216 assert_eq!(batch, streamed);
217 }
218}