Skip to main content

wickra_core/indicators/
tsi.rs

1//! True Strength Index.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::Ema;
7
8/// True Strength Index — William Blau's double-smoothed momentum oscillator.
9///
10/// The 1-bar momentum `price_t − price_{t−1}` and its absolute value are each
11/// smoothed twice — first with an EMA of length `long`, then with an EMA of
12/// length `short` — and the indicator reports their ratio scaled to a
13/// percentage:
14///
15/// ```text
16/// TSI = 100 · EMA_short(EMA_long(momentum)) / EMA_short(EMA_long(|momentum|))
17/// ```
18///
19/// The double smoothing strips most of the noise while the ratio normalises
20/// the result into a roughly `[−100, 100]` oscillator centred on zero:
21/// positive means net upward pressure, negative net downward.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, Tsi};
27///
28/// let mut indicator = Tsi::new(25, 13).unwrap();
29/// let mut last = None;
30/// for i in 0..80 {
31///     last = indicator.update(100.0 + f64::from(i));
32/// }
33/// assert_eq!(last, Some(100.0)); // pure uptrend saturates at +100
34/// ```
35#[derive(Debug, Clone)]
36pub struct Tsi {
37    long: usize,
38    short: usize,
39    prev_price: Option<f64>,
40    ema_long_mom: Ema,
41    ema_short_mom: Ema,
42    ema_long_abs: Ema,
43    ema_short_abs: Ema,
44    current: Option<f64>,
45}
46
47impl Tsi {
48    /// Construct a new TSI with the `long` and `short` smoothing periods.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`Error::PeriodZero`] if either period is `0`.
53    pub fn new(long: usize, short: usize) -> Result<Self> {
54        if long == 0 || short == 0 {
55            return Err(Error::PeriodZero);
56        }
57        Ok(Self {
58            long,
59            short,
60            prev_price: None,
61            ema_long_mom: Ema::new(long)?,
62            ema_short_mom: Ema::new(short)?,
63            ema_long_abs: Ema::new(long)?,
64            ema_short_abs: Ema::new(short)?,
65            current: None,
66        })
67    }
68
69    /// The `(long, short)` smoothing periods.
70    pub const fn periods(&self) -> (usize, usize) {
71        (self.long, self.short)
72    }
73
74    /// Current value if available.
75    pub const fn value(&self) -> Option<f64> {
76        self.current
77    }
78}
79
80impl Indicator for Tsi {
81    type Input = f64;
82    type Output = f64;
83
84    fn update(&mut self, input: f64) -> Option<f64> {
85        if !input.is_finite() {
86            // Non-finite input is ignored; state is left untouched.
87            return self.current;
88        }
89        let Some(prev) = self.prev_price else {
90            self.prev_price = Some(input);
91            return None;
92        };
93        self.prev_price = Some(input);
94
95        let momentum = input - prev;
96        let ds_mom = self
97            .ema_long_mom
98            .update(momentum)
99            .and_then(|v| self.ema_short_mom.update(v));
100        let ds_abs = self
101            .ema_long_abs
102            .update(momentum.abs())
103            .and_then(|v| self.ema_short_abs.update(v));
104
105        match (ds_mom, ds_abs) {
106            (Some(m), Some(a)) => {
107                let tsi = if a == 0.0 {
108                    // Flat double-smoothed range: there is no momentum at all.
109                    0.0
110                } else {
111                    100.0 * m / a
112                };
113                self.current = Some(tsi);
114                Some(tsi)
115            }
116            _ => None,
117        }
118    }
119
120    fn reset(&mut self) {
121        self.prev_price = None;
122        self.ema_long_mom.reset();
123        self.ema_short_mom.reset();
124        self.ema_long_abs.reset();
125        self.ema_short_abs.reset();
126        self.current = None;
127    }
128
129    fn warmup_period(&self) -> usize {
130        self.long + self.short
131    }
132
133    fn is_ready(&self) -> bool {
134        self.current.is_some()
135    }
136
137    fn name(&self) -> &'static str {
138        "TSI"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn new_rejects_zero_period() {
150        assert!(matches!(Tsi::new(0, 13), Err(Error::PeriodZero)));
151        assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
152    }
153
154    /// Cover the const accessors `periods` / `value` (70-77) and the
155    /// Indicator-impl `name` body (137-139). Existing tests inspect
156    /// TSI output but never query the metadata.
157    #[test]
158    fn accessors_and_metadata() {
159        let mut tsi = Tsi::new(25, 13).unwrap();
160        assert_eq!(tsi.periods(), (25, 13));
161        assert_eq!(tsi.name(), "TSI");
162        assert_eq!(tsi.value(), None);
163        for i in 1..=tsi.warmup_period() {
164            tsi.update(100.0 + f64::from(u32::try_from(i).unwrap()));
165        }
166        assert!(tsi.value().is_some());
167    }
168
169    #[test]
170    fn first_emission_at_warmup_period() {
171        let mut tsi = Tsi::new(5, 3).unwrap();
172        assert_eq!(tsi.warmup_period(), 8);
173        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
174        for v in out.iter().take(7) {
175            assert!(v.is_none());
176        }
177        assert!(out[7].is_some());
178    }
179
180    #[test]
181    fn pure_uptrend_saturates_at_plus_100() {
182        // Every momentum is +1, so |momentum| == momentum and the ratio is 1.
183        let mut tsi = Tsi::new(5, 3).unwrap();
184        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
185        for v in out.iter().skip(8).flatten() {
186            assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
187        }
188    }
189
190    #[test]
191    fn pure_downtrend_saturates_at_minus_100() {
192        let mut tsi = Tsi::new(5, 3).unwrap();
193        let out = tsi.batch(&(1..=40).rev().map(f64::from).collect::<Vec<_>>());
194        for v in out.iter().skip(8).flatten() {
195            assert_relative_eq!(*v, -100.0, epsilon = 1e-9);
196        }
197    }
198
199    #[test]
200    fn constant_series_yields_zero() {
201        let mut tsi = Tsi::new(5, 3).unwrap();
202        let out = tsi.batch(&[50.0; 40]);
203        for v in out.iter().skip(8).flatten() {
204            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
205        }
206    }
207
208    #[test]
209    fn ignores_non_finite_input() {
210        let mut tsi = Tsi::new(5, 3).unwrap();
211        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
212        let last = *out.last().unwrap();
213        assert!(last.is_some());
214        assert_eq!(tsi.update(f64::NAN), last);
215        assert_eq!(tsi.update(f64::INFINITY), last);
216    }
217
218    #[test]
219    fn reset_clears_state() {
220        let mut tsi = Tsi::new(5, 3).unwrap();
221        tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
222        assert!(tsi.is_ready());
223        tsi.reset();
224        assert!(!tsi.is_ready());
225        assert_eq!(tsi.update(1.0), None);
226    }
227
228    #[test]
229    fn batch_equals_streaming() {
230        let prices: Vec<f64> = (1..=80)
231            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 9.0)
232            .collect();
233        let batch = Tsi::new(13, 7).unwrap().batch(&prices);
234        let mut b = Tsi::new(13, 7).unwrap();
235        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
236        assert_eq!(batch, streamed);
237    }
238}