Skip to main content

wickra_core/indicators/
tii.rs

1//! Trend Intensity Index (TII).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sma::Sma;
7use crate::traits::Indicator;
8
9/// M.H. Pee's Trend Intensity Index — a `[0, 100]` oscillator that measures
10/// what fraction of the recent SMA deviations are positive.
11///
12/// First, compute an `SMA(close, sma_period)` (canonical `sma_period = 60`).
13/// On each bar `t` that the SMA is defined, compute the deviation
14/// `dev_t = close_t − SMA_t`. Then, over the most recent `dev_period`
15/// deviations (canonical `dev_period = 30`, i.e. `sma_period / 2`), sum the
16/// positive and negative magnitudes separately:
17///
18/// ```text
19/// SD_pos = Σ_{i ∈ window, dev_i > 0}  dev_i
20/// SD_neg = Σ_{i ∈ window, dev_i < 0}  |dev_i|
21/// TII    = 100 · SD_pos / (SD_pos + SD_neg)
22/// ```
23///
24/// `TII` is bounded in `[0, 100]`: high readings (`> 80`) signal a sustained
25/// uptrend (most recent closes above the SMA), low readings (`< 20`) a
26/// sustained downtrend. A perfectly flat window produces `50` (every deviation
27/// is zero, so the indicator falls back to its neutral mid-point).
28///
29/// The first output is emitted once both the SMA is ready (`sma_period`
30/// inputs) and the deviation ring is full (`dev_period − 1` more inputs):
31/// warmup = `sma_period + dev_period − 1`.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Indicator, Tii};
37///
38/// let mut indicator = Tii::new(20, 10).unwrap();
39/// let mut last = None;
40/// for i in 0..60 {
41///     last = indicator.update(100.0 + f64::from(i));
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct Tii {
47    sma_period: usize,
48    dev_period: usize,
49    sma: Sma,
50    /// Rolling window of the most recent `dev_period` deviations.
51    window: VecDeque<f64>,
52    sum_pos: f64,
53    sum_neg: f64,
54    last: Option<f64>,
55}
56
57impl Tii {
58    /// Construct a new TII with the SMA period and the deviation window length.
59    ///
60    /// The canonical Pee parameters are `(sma_period = 60, dev_period = 30)`;
61    /// expose them as the Python defaults.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::PeriodZero`] if either period is `0`.
66    pub fn new(sma_period: usize, dev_period: usize) -> Result<Self> {
67        if sma_period == 0 || dev_period == 0 {
68            return Err(Error::PeriodZero);
69        }
70        Ok(Self {
71            sma_period,
72            dev_period,
73            sma: Sma::new(sma_period)?,
74            window: VecDeque::with_capacity(dev_period),
75            sum_pos: 0.0,
76            sum_neg: 0.0,
77            last: None,
78        })
79    }
80
81    /// Configured `(sma_period, dev_period)`.
82    pub const fn periods(&self) -> (usize, usize) {
83        (self.sma_period, self.dev_period)
84    }
85
86    /// Current value if available.
87    pub const fn value(&self) -> Option<f64> {
88        self.last
89    }
90}
91
92impl Indicator for Tii {
93    type Input = f64;
94    type Output = f64;
95
96    fn update(&mut self, input: f64) -> Option<f64> {
97        let sma_value = self.sma.update(input)?;
98        let dev = input - sma_value;
99
100        if self.window.len() == self.dev_period {
101            let old = self.window.pop_front().expect("ring is non-empty");
102            if old > 0.0 {
103                self.sum_pos -= old;
104            } else if old < 0.0 {
105                self.sum_neg -= -old;
106            }
107        }
108        self.window.push_back(dev);
109        if dev > 0.0 {
110            self.sum_pos += dev;
111        } else if dev < 0.0 {
112            self.sum_neg += -dev;
113        }
114
115        if self.window.len() < self.dev_period {
116            return None;
117        }
118
119        let denom = self.sum_pos + self.sum_neg;
120        let tii = if denom <= 0.0 {
121            // A perfectly flat window — every deviation is zero. By
122            // convention we return the neutral mid-point, matching
123            // pandas-ta's implementation. The `<=` also catches the rare
124            // case where rolling-subtraction rounding leaves the
125            // accumulator slightly negative; the indicator is then
126            // mathematically undefined and we again fall back to the
127            // neutral mid-point.
128            50.0
129        } else {
130            // Clamp to [0, 100]: by construction the ratio lives in this
131            // interval, but the rolling sum_pos / sum_neg subtractions
132            // accumulate floating-point error and can produce a result
133            // a few ULP outside the bound on long histories.
134            (100.0 * self.sum_pos / denom).clamp(0.0, 100.0)
135        };
136        self.last = Some(tii);
137        Some(tii)
138    }
139
140    fn reset(&mut self) {
141        self.sma.reset();
142        self.window.clear();
143        self.sum_pos = 0.0;
144        self.sum_neg = 0.0;
145        self.last = None;
146    }
147
148    #[inline]
149    fn warmup_period(&self) -> usize {
150        // SMA emits its first value at input `sma_period`; the deviation ring
151        // then needs `dev_period − 1` more inputs to fill, so first TII lands
152        // at `sma_period + dev_period − 1`.
153        self.sma_period + self.dev_period - 1
154    }
155
156    #[inline]
157    fn is_ready(&self) -> bool {
158        self.last.is_some()
159    }
160
161    #[inline]
162    fn name(&self) -> &'static str {
163        "TII"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    #[test]
174    fn rejects_zero_period() {
175        assert!(matches!(Tii::new(0, 10), Err(Error::PeriodZero)));
176        assert!(matches!(Tii::new(10, 0), Err(Error::PeriodZero)));
177    }
178
179    #[test]
180    fn accessors_and_metadata() {
181        let mut t = Tii::new(60, 30).unwrap();
182        assert_eq!(t.periods(), (60, 30));
183        assert_eq!(t.warmup_period(), 89);
184        assert_eq!(t.name(), "TII");
185        assert!(t.value().is_none());
186        let prices: Vec<f64> = (1..=100).map(|i| 100.0 + f64::from(i)).collect();
187        for &p in &prices {
188            t.update(p);
189        }
190        assert!(t.value().is_some());
191    }
192
193    #[test]
194    fn first_emission_at_warmup_period() {
195        let prices: Vec<f64> = (1..=30)
196            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
197            .collect();
198        let mut t = Tii::new(5, 4).unwrap();
199        let out = t.batch(&prices);
200        let warmup = 5 + 4 - 1; // 8
201        for v in out.iter().take(warmup - 1) {
202            assert!(v.is_none());
203        }
204        assert!(out[warmup - 1].is_some());
205    }
206
207    #[test]
208    fn pure_uptrend_saturates_at_100() {
209        // Strictly increasing series: the SMA always lags, so every close
210        // sits above the SMA → every deviation positive → TII = 100.
211        let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
212        let mut t = Tii::new(10, 5).unwrap();
213        let last = t.batch(&prices).into_iter().flatten().last().unwrap();
214        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
215    }
216
217    #[test]
218    fn pure_downtrend_falls_to_zero() {
219        let prices: Vec<f64> = (1..=80).rev().map(|i| 100.0 + f64::from(i)).collect();
220        let mut t = Tii::new(10, 5).unwrap();
221        let last = t.batch(&prices).into_iter().flatten().last().unwrap();
222        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
223    }
224
225    #[test]
226    fn constant_series_yields_neutral_50() {
227        // Every deviation is zero; the `denom == 0` guard returns the
228        // neutral mid-point.
229        let mut t = Tii::new(5, 4).unwrap();
230        let last = t
231            .batch(&[10.0_f64; 30])
232            .into_iter()
233            .flatten()
234            .last()
235            .unwrap();
236        assert_eq!(last, 50.0);
237    }
238
239    #[test]
240    fn output_bounded_in_unit_interval() {
241        let prices: Vec<f64> = (0..200)
242            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0 + (f64::from(i) * 0.07).cos() * 3.0)
243            .collect();
244        let mut t = Tii::new(20, 10).unwrap();
245        for v in t.batch(&prices).into_iter().flatten() {
246            assert!((0.0..=100.0).contains(&v));
247        }
248    }
249
250    #[test]
251    fn batch_equals_streaming() {
252        let prices: Vec<f64> = (0..120)
253            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
254            .collect();
255        let mut a = Tii::new(20, 10).unwrap();
256        let mut b = Tii::new(20, 10).unwrap();
257        assert_eq!(
258            a.batch(&prices),
259            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
260        );
261    }
262
263    #[test]
264    fn reset_clears_state() {
265        let mut t = Tii::new(5, 4).unwrap();
266        t.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
267        assert!(t.is_ready());
268        t.reset();
269        assert!(!t.is_ready());
270        assert_eq!(t.update(1.0), None);
271    }
272}