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    #[inline]
85    fn update(&mut self, input: f64) -> Option<f64> {
86        if !input.is_finite() {
87            // Non-finite input is ignored; state is left untouched.
88            return None;
89        }
90        let Some(prev) = self.prev_price else {
91            self.prev_price = Some(input);
92            return None;
93        };
94        self.prev_price = Some(input);
95
96        let momentum = input - prev;
97        let ds_mom = self
98            .ema_long_mom
99            .update(momentum)
100            .and_then(|v| self.ema_short_mom.update(v));
101        let ds_abs = self
102            .ema_long_abs
103            .update(momentum.abs())
104            .and_then(|v| self.ema_short_abs.update(v));
105
106        match (ds_mom, ds_abs) {
107            (Some(m), Some(a)) => {
108                let tsi = if a == 0.0 {
109                    // Flat double-smoothed range: there is no momentum at all.
110                    0.0
111                } else {
112                    100.0 * m / a
113                };
114                self.current = Some(tsi);
115                Some(tsi)
116            }
117            _ => None,
118        }
119    }
120
121    fn reset(&mut self) {
122        self.prev_price = None;
123        self.ema_long_mom.reset();
124        self.ema_short_mom.reset();
125        self.ema_long_abs.reset();
126        self.ema_short_abs.reset();
127        self.current = None;
128    }
129
130    #[inline]
131    fn warmup_period(&self) -> usize {
132        self.long + self.short
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.current.is_some()
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "TSI"
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::traits::BatchExt;
150    use approx::assert_relative_eq;
151
152    #[test]
153    fn new_rejects_zero_period() {
154        assert!(matches!(Tsi::new(0, 13), Err(Error::PeriodZero)));
155        assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
156    }
157
158    /// Cover the const accessors `periods` / `value` (70-77) and the
159    /// Indicator-impl `name` body (137-139). Existing tests inspect
160    /// TSI output but never query the metadata.
161    #[test]
162    fn accessors_and_metadata() {
163        let mut tsi = Tsi::new(25, 13).unwrap();
164        assert_eq!(tsi.periods(), (25, 13));
165        assert_eq!(tsi.name(), "TSI");
166        assert_eq!(tsi.value(), None);
167        for i in 1..=tsi.warmup_period() {
168            tsi.update(100.0 + f64::from(u32::try_from(i).unwrap()));
169        }
170        assert!(tsi.value().is_some());
171    }
172
173    #[test]
174    fn first_emission_at_warmup_period() {
175        let mut tsi = Tsi::new(5, 3).unwrap();
176        assert_eq!(tsi.warmup_period(), 8);
177        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
178        for v in out.iter().take(7) {
179            assert!(v.is_none());
180        }
181        assert!(out[7].is_some());
182    }
183
184    #[test]
185    fn pure_uptrend_saturates_at_plus_100() {
186        // Every momentum is +1, so |momentum| == momentum and the ratio is 1.
187        let mut tsi = Tsi::new(5, 3).unwrap();
188        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
189        for v in out.iter().skip(8).flatten() {
190            assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
191        }
192    }
193
194    #[test]
195    fn pure_downtrend_saturates_at_minus_100() {
196        let mut tsi = Tsi::new(5, 3).unwrap();
197        let out = tsi.batch(&(1..=40).rev().map(f64::from).collect::<Vec<_>>());
198        for v in out.iter().skip(8).flatten() {
199            assert_relative_eq!(*v, -100.0, epsilon = 1e-9);
200        }
201    }
202
203    #[test]
204    fn constant_series_yields_zero() {
205        let mut tsi = Tsi::new(5, 3).unwrap();
206        let out = tsi.batch(&[50.0; 40]);
207        for v in out.iter().skip(8).flatten() {
208            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
209        }
210    }
211
212    #[test]
213    fn ignores_non_finite_input() {
214        let mut tsi = Tsi::new(5, 3).unwrap();
215        let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
216        let last = *out.last().unwrap();
217        assert!(last.is_some());
218        assert_eq!(tsi.update(f64::NAN), None);
219        assert_eq!(tsi.update(f64::INFINITY), None);
220    }
221
222    #[test]
223    fn reset_clears_state() {
224        let mut tsi = Tsi::new(5, 3).unwrap();
225        tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
226        assert!(tsi.is_ready());
227        tsi.reset();
228        assert!(!tsi.is_ready());
229        assert_eq!(tsi.update(1.0), None);
230    }
231
232    #[test]
233    fn batch_equals_streaming() {
234        let prices: Vec<f64> = (1..=80)
235            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 9.0)
236            .collect();
237        let batch = Tsi::new(13, 7).unwrap().batch(&prices);
238        let mut b = Tsi::new(13, 7).unwrap();
239        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
240        assert_eq!(batch, streamed);
241    }
242}