Skip to main content

wickra_core/indicators/
trend_label.rs

1//! Trend Label — the sign of the rolling least-squares slope.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Trend Label — a discrete `{−1, 0, +1}` classification of the local trend from
9/// the sign of the ordinary-least-squares slope over the last `period` values.
10///
11/// ```text
12/// slope = Σ (tᵢ − t̄)(xᵢ − x̄) / Σ (tᵢ − t̄)²      (regress price on bar index)
13/// label = +1 if slope > 0,  −1 if slope < 0,  0 if slope == 0
14/// ```
15///
16/// The sign of the regression slope is *scale-invariant* — it does not depend on
17/// the nominal price level — which makes it a clean, comparable trend state
18/// across instruments. `+1` marks a rising regression line, `−1` a falling one,
19/// and `0` a perfectly flat window. It is the discrete companion to
20/// [`LinRegSlope`](crate::LinRegSlope) (which returns the continuous slope): use
21/// the label when a feature pipeline wants a categorical trend direction and
22/// keys any magnitude / dead-band tuning on the raw slope itself.
23///
24/// Each `update` is `O(period)`: the slope numerator is recomputed from the
25/// window. The denominator `Σ(tᵢ − t̄)²` is strictly positive for `period ≥ 2`,
26/// so the sign is always well-defined.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, TrendLabel};
32///
33/// let mut indicator = TrendLabel::new(10).unwrap();
34/// let mut last = None;
35/// for i in 0..20 {
36///     last = indicator.update(100.0 + f64::from(i)); // strictly rising
37/// }
38/// assert_eq!(last, Some(1.0));
39/// ```
40#[derive(Debug, Clone)]
41pub struct TrendLabel {
42    period: usize,
43    window: VecDeque<f64>,
44}
45
46impl TrendLabel {
47    /// Construct a new Trend Label classifier.
48    ///
49    /// # Errors
50    /// Returns [`Error::InvalidPeriod`] if `period < 2` — a slope needs at least
51    /// two points.
52    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    /// Configured period.
70    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        // Slope numerator: Σ (t − t̄)(x − x̄). The denominator Σ(t − t̄)² > 0 for
95        // period >= 2, so the slope sign equals the numerator sign.
96        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        // Multiplying the whole series by a constant cannot change the trend sign.
178        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}