Skip to main content

wickra_core/indicators/
trix.rs

1//! TRIX: triple-smoothed EMA percent rate of change.
2
3use crate::error::Result;
4use crate::indicators::ema::Ema;
5use crate::traits::Indicator;
6
7/// TRIX: the 1-period percent rate of change of a triple-smoothed EMA.
8///
9/// `TRIX = 100 * (TR_t - TR_{t-1}) / TR_{t-1}` where
10/// `TR_t = EMA(EMA(EMA(price)))`.
11///
12/// # Example
13///
14/// ```
15/// use wickra_core::{Indicator, Trix};
16///
17/// let mut indicator = Trix::new(3).unwrap();
18/// let mut last = None;
19/// for i in 0..80 {
20///     last = indicator.update(100.0 + f64::from(i));
21/// }
22/// assert!(last.is_some());
23/// ```
24#[derive(Debug, Clone)]
25pub struct Trix {
26    ema1: Ema,
27    ema2: Ema,
28    ema3: Ema,
29    prev_tr: Option<f64>,
30    /// Whether a value has been emitted since the last reset. `prev_tr` cannot
31    /// stand in for this: the bar that first fills it is the rate-of-change
32    /// baseline and returns `None`, so keying readiness off it would report
33    /// ready one input early.
34    has_emitted: bool,
35    period: usize,
36}
37
38impl Trix {
39    /// # Errors
40    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
41    pub fn new(period: usize) -> Result<Self> {
42        Ok(Self {
43            ema1: Ema::new(period)?,
44            ema2: Ema::new(period)?,
45            ema3: Ema::new(period)?,
46            prev_tr: None,
47            has_emitted: false,
48            period,
49        })
50    }
51
52    /// Configured period.
53    pub const fn period(&self) -> usize {
54        self.period
55    }
56}
57
58impl Indicator for Trix {
59    type Input = f64;
60    type Output = f64;
61
62    #[inline]
63    fn update(&mut self, input: f64) -> Option<f64> {
64        let e1 = self.ema1.update(input)?;
65        let e2 = self.ema2.update(e1)?;
66        let e3 = self.ema3.update(e2)?;
67        match self.prev_tr {
68            Some(prev) if prev != 0.0 => {
69                let trix = 100.0 * (e3 - prev) / prev;
70                self.prev_tr = Some(e3);
71                self.has_emitted = true;
72                Some(trix)
73            }
74            Some(_) => {
75                self.prev_tr = Some(e3);
76                self.has_emitted = true;
77                Some(0.0)
78            }
79            None => {
80                self.prev_tr = Some(e3);
81                None
82            }
83        }
84    }
85
86    fn reset(&mut self) {
87        self.ema1.reset();
88        self.ema2.reset();
89        self.ema3.reset();
90        self.prev_tr = None;
91        self.has_emitted = false;
92    }
93
94    #[inline]
95    fn warmup_period(&self) -> usize {
96        // Triple EMA seeds at 3*period-2; plus one extra for the rate of change.
97        3 * self.period - 1
98    }
99
100    #[inline]
101    fn is_ready(&self) -> bool {
102        self.has_emitted
103    }
104
105    #[inline]
106    fn name(&self) -> &'static str {
107        "TRIX"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115    use approx::assert_relative_eq;
116
117    #[test]
118    fn constant_series_yields_zero_trix() {
119        let mut trix = Trix::new(5).unwrap();
120        let out = trix.batch(&[100.0_f64; 80]);
121        let last = out.iter().rev().flatten().next().unwrap();
122        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
123    }
124
125    #[test]
126    fn rising_series_eventually_positive_trix() {
127        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
128        let mut trix = Trix::new(5).unwrap();
129        let last = trix.batch(&prices).into_iter().flatten().last().unwrap();
130        assert!(last > 0.0);
131    }
132
133    #[test]
134    fn batch_equals_streaming() {
135        let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 1.3).collect();
136        let mut a = Trix::new(7).unwrap();
137        let mut b = Trix::new(7).unwrap();
138        assert_eq!(
139            a.batch(&prices),
140            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
141        );
142    }
143
144    #[test]
145    fn reset_clears_state() {
146        let mut trix = Trix::new(5).unwrap();
147        trix.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
148        assert!(trix.is_ready());
149        trix.reset();
150        assert!(!trix.is_ready());
151    }
152
153    #[test]
154    fn rejects_zero_period() {
155        assert!(Trix::new(0).is_err());
156    }
157
158    /// `is_ready()` used to key off `prev_tr`, which the rate-of-change baseline
159    /// bar fills while still returning `None` — so readiness flipped one input
160    /// before the first value. Pin readiness to the emission itself, and the
161    /// declared warmup to the index of that emission.
162    #[test]
163    fn readiness_flips_exactly_when_the_first_value_lands() {
164        let prices: Vec<f64> = (1..=80).map(|i| 100.0 + f64::from(i) * 0.7).collect();
165        let mut trix = Trix::new(3).unwrap();
166        let mut first = None;
167        for (i, price) in prices.iter().enumerate() {
168            let out = trix.update(*price);
169            assert_eq!(out.is_some(), trix.is_ready());
170            if out.is_some() && first.is_none() {
171                first = Some(i);
172            }
173        }
174        assert_eq!(first.unwrap() + 1, trix.warmup_period());
175    }
176
177    /// Cover the const accessor `period` (47-49) and the Indicator-impl
178    /// `warmup_period` (84-87) + `name` (93-95). Existing tests never
179    /// inspect these metadata methods.
180    #[test]
181    fn accessors_and_metadata() {
182        let trix = Trix::new(5).unwrap();
183        assert_eq!(trix.period(), 5);
184        // Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14.
185        assert_eq!(trix.warmup_period(), 14);
186        assert_eq!(trix.name(), "TRIX");
187    }
188
189    /// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path
190    /// where the previous triple-EMA value is exactly 0.0 (which would
191    /// otherwise divide by zero on the percent-rate formula). A series of
192    /// all-zero inputs collapses every EMA stage to 0.0, so once the
193    /// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent
194    /// emission must take the fallback branch and return 0.0.
195    #[test]
196    fn zero_input_series_yields_zero_trix() {
197        let mut trix = Trix::new(3).unwrap();
198        let out = trix.batch(&[0.0_f64; 20]);
199        let last = out.into_iter().flatten().last().expect("emits");
200        assert_eq!(last, 0.0);
201    }
202}