Skip to main content

wickra_core/indicators/
trend_strength_index.rs

1//! Trend Strength Index — the signed coefficient of determination of a linear
2//! regression of price against time.
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9/// Trend Strength Index: fits an ordinary-least-squares line to the last
10/// `period` prices against their bar index and reports the coefficient of
11/// determination `r^2`, signed by the slope of the fit.
12///
13/// ```text
14/// regress y = close on x = 0..period-1
15/// r^2  = (n·Σxy − Σx·Σy)^2 / [ (n·Σx² − (Σx)²)(n·Σy² − (Σy)²) ]
16/// TSI  = sign(slope) · r^2          (slope sign = sign of n·Σxy − Σx·Σy)
17/// ```
18///
19/// `r^2` in `[0, 1]` measures how well a straight line explains the price over
20/// the window — how *trendy* the segment is, regardless of direction. Carrying
21/// the slope sign turns it into a directional reading in `[-1, 1]`: values near
22/// `+1` are a strong, clean uptrend; near `-1` a strong downtrend; near `0` a
23/// flat or noisy market with no linear structure. A window of constant prices
24/// (zero variance in `y`) has no defined trend and returns `0`.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, TrendStrengthIndex};
30///
31/// let mut indicator = TrendStrengthIndex::new(20).unwrap();
32/// let mut last = None;
33/// for i in 0..40 {
34///     last = indicator.update(100.0 + f64::from(i));
35/// }
36/// // A clean ramp is a perfect uptrend -> r^2 = 1.
37/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
38/// ```
39#[derive(Debug, Clone)]
40pub struct TrendStrengthIndex {
41    period: usize,
42    buf: VecDeque<f64>,
43}
44
45impl TrendStrengthIndex {
46    /// Construct a Trend Strength Index over the given window.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if `period == 0`, or [`Error::InvalidPeriod`]
51    /// if `period == 1` (a regression needs at least two points).
52    pub fn new(period: usize) -> Result<Self> {
53        if period == 0 {
54            return Err(Error::PeriodZero);
55        }
56        if period > crate::error::MAX_PERIOD {
57            return Err(Error::InvalidPeriod {
58                message: crate::error::PERIOD_ABOVE_MAX,
59            });
60        }
61        if period == 1 {
62            return Err(Error::InvalidPeriod {
63                message: "period must be >= 2 for a regression",
64            });
65        }
66        Ok(Self {
67            period,
68            buf: VecDeque::with_capacity(period),
69        })
70    }
71
72    /// Configured window length.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76}
77
78impl Indicator for TrendStrengthIndex {
79    type Input = f64;
80    type Output = f64;
81
82    #[inline]
83    fn update(&mut self, price: f64) -> Option<f64> {
84        if !price.is_finite() {
85            return None;
86        }
87        self.buf.push_back(price);
88        if self.buf.len() > self.period {
89            self.buf.pop_front();
90        }
91        if self.buf.len() < self.period {
92            return None;
93        }
94
95        let count = self.period as f64;
96        // The correlation is invariant when a constant is subtracted from the
97        // price, so the power sums are built on deviations from the window
98        // mean. On raw prices `count·Σy² − (Σy)²` is a difference of two
99        // numbers of order `level²` whose result is of order `deviation²`: at a
100        // price level of 1e8 it collapsed far enough for the guard below to
101        // fire, reporting no trend at all whatever the data did.
102        let mean_y = self.buf.iter().sum::<f64>() / count;
103        let mut sum_x = 0.0;
104        let mut sum_xx = 0.0;
105        let mut sum_y = 0.0;
106        let mut sum_yy = 0.0;
107        let mut sum_xy = 0.0;
108        for (idx, &price) in self.buf.iter().enumerate() {
109            let x = idx as f64;
110            let y = price - mean_y;
111            sum_x += x;
112            sum_xx += x * x;
113            sum_y += y;
114            sum_yy += y * y;
115            sum_xy += x * y;
116        }
117
118        let cov = count.mul_add(sum_xy, -(sum_x * sum_y));
119        let var_x = count.mul_add(sum_xx, -(sum_x * sum_x));
120        let var_y = count.mul_add(sum_yy, -(sum_y * sum_y));
121        if var_y <= 0.0 {
122            return Some(0.0);
123        }
124        let r2 = (cov * cov) / (var_x * var_y);
125        Some(if cov >= 0.0 { r2 } else { -r2 })
126    }
127
128    fn reset(&mut self) {
129        self.buf.clear();
130    }
131
132    #[inline]
133    fn warmup_period(&self) -> usize {
134        self.period
135    }
136
137    #[inline]
138    fn is_ready(&self) -> bool {
139        self.buf.len() >= self.period
140    }
141
142    #[inline]
143    fn name(&self) -> &'static str {
144        "TrendStrengthIndex"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn rejects_invalid_period() {
156        assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero)));
157        assert!(matches!(
158            TrendStrengthIndex::new(1),
159            Err(Error::InvalidPeriod { .. })
160        ));
161    }
162
163    #[test]
164    fn accessors_and_metadata() {
165        let tsi = TrendStrengthIndex::new(20).unwrap();
166        assert_eq!(tsi.period(), 20);
167        assert_eq!(tsi.warmup_period(), 20);
168        assert_eq!(tsi.name(), "TrendStrengthIndex");
169        assert!(!tsi.is_ready());
170    }
171
172    #[test]
173    fn warmup_emits_at_period() {
174        let mut tsi = TrendStrengthIndex::new(4).unwrap();
175        let inputs: Vec<f64> = (0..6).map(f64::from).collect();
176        let out = tsi.batch(&inputs);
177        assert!(out[2].is_none());
178        assert!(out[3].is_some());
179    }
180
181    #[test]
182    fn perfect_uptrend_is_plus_one() {
183        let mut tsi = TrendStrengthIndex::new(10).unwrap();
184        let inputs: Vec<f64> = (0..10).map(f64::from).collect();
185        let last = tsi.batch(&inputs).last().unwrap().unwrap();
186        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
187    }
188
189    #[test]
190    fn perfect_downtrend_is_minus_one() {
191        let mut tsi = TrendStrengthIndex::new(10).unwrap();
192        let inputs: Vec<f64> = (0..10).map(|i| 100.0 - f64::from(i)).collect();
193        let last = tsi.batch(&inputs).last().unwrap().unwrap();
194        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
195    }
196
197    #[test]
198    fn flat_market_returns_zero() {
199        let mut tsi = TrendStrengthIndex::new(8).unwrap();
200        let inputs = [42.0; 12];
201        let last = tsi.batch(&inputs).last().unwrap().unwrap();
202        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
203    }
204
205    #[test]
206    fn noisy_trend_is_between() {
207        // An upward drift with noise: positive but not a perfect fit.
208        let mut tsi = TrendStrengthIndex::new(12).unwrap();
209        let inputs: Vec<f64> = (0..12)
210            .map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 })
211            .collect();
212        let last = tsi.batch(&inputs).last().unwrap().unwrap();
213        assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)");
214    }
215
216    #[test]
217    fn reset_clears_state() {
218        let mut tsi = TrendStrengthIndex::new(10).unwrap();
219        let inputs: Vec<f64> = (0..10).map(f64::from).collect();
220        tsi.batch(&inputs);
221        assert!(tsi.is_ready());
222        tsi.reset();
223        assert!(!tsi.is_ready());
224    }
225
226    #[test]
227    fn batch_equals_streaming() {
228        let inputs: Vec<f64> = (0..80)
229            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
230            .collect();
231        let mut a = TrendStrengthIndex::new(15).unwrap();
232        let mut b = TrendStrengthIndex::new(15).unwrap();
233        assert_eq!(
234            a.batch(&inputs),
235            inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236        );
237    }
238
239    /// The power sums ran on raw prices, so `count·Σy² − (Σy)²` was a
240    /// difference of two numbers of order `level²` producing one of order
241    /// `deviation²`. At a price level of 1e8 with a one-unit wobble it
242    /// collapsed past zero, the `var_y <= 0.0` guard fired, and the indicator
243    /// reported no trend at all -- for a series that is a clean sine. Measured
244    /// against a centred reference the error was exactly 1 (the output was 0);
245    /// it is now 2.4e-14.
246    #[test]
247    fn trend_at_a_high_price_level_is_still_detected() {
248        const P: usize = 20;
249        let data: Vec<f64> = (0..400)
250            .map(|i| {
251                let t = f64::from(i);
252                1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
253            })
254            .collect();
255
256        let mut ind = TrendStrengthIndex::new(P).unwrap();
257        let mean_x = (P as f64 - 1.0) / 2.0;
258        let mut compared = 0_usize;
259        let mut saw_strong_trend = false;
260        for (i, &v) in data.iter().enumerate() {
261            let Some(got) = ind.update(v) else { continue };
262            let window = &data[i + 1 - P..=i];
263            let mean_y = window.iter().sum::<f64>() / P as f64;
264            let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0);
265            for (j, &y) in window.iter().enumerate() {
266                let dx = j as f64 - mean_x;
267                let dy = y - mean_y;
268                sxy += dx * dy;
269                sxx += dx * dx;
270                syy += dy * dy;
271            }
272            let r = sxy / (sxx * syy).sqrt();
273            let want = if sxy >= 0.0 { r * r } else { -(r * r) };
274            if want.abs() > 0.5 {
275                saw_strong_trend = true;
276            }
277            compared += 1;
278            assert_relative_eq!(got, want, max_relative = 1e-9);
279        }
280        assert_eq!(compared, data.len() - ind.warmup_period() + 1);
281        // Without this the assertion would pass on an indicator stuck at zero.
282        assert!(saw_strong_trend);
283    }
284}