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    fn warmup_period(&self) -> usize {
149        // SMA emits its first value at input `sma_period`; the deviation ring
150        // then needs `dev_period − 1` more inputs to fill, so first TII lands
151        // at `sma_period + dev_period − 1`.
152        self.sma_period + self.dev_period - 1
153    }
154
155    fn is_ready(&self) -> bool {
156        self.last.is_some()
157    }
158
159    fn name(&self) -> &'static str {
160        "TII"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168    use approx::assert_relative_eq;
169
170    #[test]
171    fn rejects_zero_period() {
172        assert!(matches!(Tii::new(0, 10), Err(Error::PeriodZero)));
173        assert!(matches!(Tii::new(10, 0), Err(Error::PeriodZero)));
174    }
175
176    #[test]
177    fn accessors_and_metadata() {
178        let mut t = Tii::new(60, 30).unwrap();
179        assert_eq!(t.periods(), (60, 30));
180        assert_eq!(t.warmup_period(), 89);
181        assert_eq!(t.name(), "TII");
182        assert!(t.value().is_none());
183        let prices: Vec<f64> = (1..=100).map(|i| 100.0 + f64::from(i)).collect();
184        for &p in &prices {
185            t.update(p);
186        }
187        assert!(t.value().is_some());
188    }
189
190    #[test]
191    fn first_emission_at_warmup_period() {
192        let prices: Vec<f64> = (1..=30)
193            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
194            .collect();
195        let mut t = Tii::new(5, 4).unwrap();
196        let out = t.batch(&prices);
197        let warmup = 5 + 4 - 1; // 8
198        for v in out.iter().take(warmup - 1) {
199            assert!(v.is_none());
200        }
201        assert!(out[warmup - 1].is_some());
202    }
203
204    #[test]
205    fn pure_uptrend_saturates_at_100() {
206        // Strictly increasing series: the SMA always lags, so every close
207        // sits above the SMA → every deviation positive → TII = 100.
208        let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i)).collect();
209        let mut t = Tii::new(10, 5).unwrap();
210        let last = t.batch(&prices).into_iter().flatten().last().unwrap();
211        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
212    }
213
214    #[test]
215    fn pure_downtrend_falls_to_zero() {
216        let prices: Vec<f64> = (1..=80).rev().map(|i| 100.0 + f64::from(i)).collect();
217        let mut t = Tii::new(10, 5).unwrap();
218        let last = t.batch(&prices).into_iter().flatten().last().unwrap();
219        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
220    }
221
222    #[test]
223    fn constant_series_yields_neutral_50() {
224        // Every deviation is zero; the `denom == 0` guard returns the
225        // neutral mid-point.
226        let mut t = Tii::new(5, 4).unwrap();
227        let last = t
228            .batch(&[10.0_f64; 30])
229            .into_iter()
230            .flatten()
231            .last()
232            .unwrap();
233        assert_eq!(last, 50.0);
234    }
235
236    #[test]
237    fn output_bounded_in_unit_interval() {
238        let prices: Vec<f64> = (0..200)
239            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0 + (f64::from(i) * 0.07).cos() * 3.0)
240            .collect();
241        let mut t = Tii::new(20, 10).unwrap();
242        for v in t.batch(&prices).into_iter().flatten() {
243            assert!((0.0..=100.0).contains(&v));
244        }
245    }
246
247    #[test]
248    fn batch_equals_streaming() {
249        let prices: Vec<f64> = (0..120)
250            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
251            .collect();
252        let mut a = Tii::new(20, 10).unwrap();
253        let mut b = Tii::new(20, 10).unwrap();
254        assert_eq!(
255            a.batch(&prices),
256            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
257        );
258    }
259
260    #[test]
261    fn reset_clears_state() {
262        let mut t = Tii::new(5, 4).unwrap();
263        t.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
264        assert!(t.is_ready());
265        t.reset();
266        assert!(!t.is_ready());
267        assert_eq!(t.update(1.0), None);
268    }
269}