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    period: usize,
31}
32
33impl Trix {
34    /// # Errors
35    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
36    pub fn new(period: usize) -> Result<Self> {
37        Ok(Self {
38            ema1: Ema::new(period)?,
39            ema2: Ema::new(period)?,
40            ema3: Ema::new(period)?,
41            prev_tr: None,
42            period,
43        })
44    }
45
46    /// Configured period.
47    pub const fn period(&self) -> usize {
48        self.period
49    }
50}
51
52impl Indicator for Trix {
53    type Input = f64;
54    type Output = f64;
55
56    fn update(&mut self, input: f64) -> Option<f64> {
57        let e1 = self.ema1.update(input)?;
58        let e2 = self.ema2.update(e1)?;
59        let e3 = self.ema3.update(e2)?;
60        match self.prev_tr {
61            Some(prev) if prev != 0.0 => {
62                let trix = 100.0 * (e3 - prev) / prev;
63                self.prev_tr = Some(e3);
64                Some(trix)
65            }
66            Some(_) => {
67                self.prev_tr = Some(e3);
68                Some(0.0)
69            }
70            None => {
71                self.prev_tr = Some(e3);
72                None
73            }
74        }
75    }
76
77    fn reset(&mut self) {
78        self.ema1.reset();
79        self.ema2.reset();
80        self.ema3.reset();
81        self.prev_tr = None;
82    }
83
84    fn warmup_period(&self) -> usize {
85        // Triple EMA seeds at 3*period-2; plus one extra for the rate of change.
86        3 * self.period - 1
87    }
88
89    fn is_ready(&self) -> bool {
90        self.prev_tr.is_some() && self.ema3.is_ready()
91    }
92
93    fn name(&self) -> &'static str {
94        "TRIX"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102    use approx::assert_relative_eq;
103
104    #[test]
105    fn constant_series_yields_zero_trix() {
106        let mut trix = Trix::new(5).unwrap();
107        let out = trix.batch(&[100.0_f64; 80]);
108        let last = out.iter().rev().flatten().next().unwrap();
109        assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
110    }
111
112    #[test]
113    fn rising_series_eventually_positive_trix() {
114        let prices: Vec<f64> = (1..=200).map(f64::from).collect();
115        let mut trix = Trix::new(5).unwrap();
116        let last = trix.batch(&prices).into_iter().flatten().last().unwrap();
117        assert!(last > 0.0);
118    }
119
120    #[test]
121    fn batch_equals_streaming() {
122        let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 1.3).collect();
123        let mut a = Trix::new(7).unwrap();
124        let mut b = Trix::new(7).unwrap();
125        assert_eq!(
126            a.batch(&prices),
127            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
128        );
129    }
130
131    #[test]
132    fn reset_clears_state() {
133        let mut trix = Trix::new(5).unwrap();
134        trix.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
135        assert!(trix.is_ready());
136        trix.reset();
137        assert!(!trix.is_ready());
138    }
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(Trix::new(0).is_err());
143    }
144
145    /// Cover the const accessor `period` (47-49) and the Indicator-impl
146    /// `warmup_period` (84-87) + `name` (93-95). Existing tests never
147    /// inspect these metadata methods.
148    #[test]
149    fn accessors_and_metadata() {
150        let trix = Trix::new(5).unwrap();
151        assert_eq!(trix.period(), 5);
152        // Triple EMA seeds at 3*5-2 = 13; +1 for the rate-of-change pair = 14.
153        assert_eq!(trix.warmup_period(), 14);
154        assert_eq!(trix.name(), "TRIX");
155    }
156
157    /// Cover the `Some(_)` match arm at lines 66-68 — the degenerate path
158    /// where the previous triple-EMA value is exactly 0.0 (which would
159    /// otherwise divide by zero on the percent-rate formula). A series of
160    /// all-zero inputs collapses every EMA stage to 0.0, so once the
161    /// indicator warms up `prev_tr` is `Some(0.0)` and every subsequent
162    /// emission must take the fallback branch and return 0.0.
163    #[test]
164    fn zero_input_series_yields_zero_trix() {
165        let mut trix = Trix::new(3).unwrap();
166        let out = trix.batch(&[0.0_f64; 20]);
167        let last = out.into_iter().flatten().last().expect("emits");
168        assert_eq!(last, 0.0);
169    }
170}