Skip to main content

wickra_core/indicators/
trima.rs

1//! Triangular Moving Average.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::Sma;
7
8/// Triangular Moving Average — a simple moving average applied twice, which
9/// triangular-weights the window so the middle bars carry the most weight and
10/// the edges the least.
11///
12/// For period `n` the two stacked SMAs use lengths `n1` and `n2`:
13/// an odd `n` uses `n1 = n2 = (n + 1) / 2`; an even `n` uses `n1 = n / 2` and
14/// `n2 = n / 2 + 1`. Either way the first output lands after exactly `n`
15/// inputs.
16///
17/// # Example
18///
19/// ```
20/// use wickra_core::{Indicator, Trima};
21///
22/// let mut indicator = Trima::new(5).unwrap();
23/// let mut last = None;
24/// for i in 0..80 {
25///     last = indicator.update(100.0 + f64::from(i));
26/// }
27/// assert!(last.is_some());
28/// ```
29#[derive(Debug, Clone)]
30pub struct Trima {
31    period: usize,
32    inner: Sma,
33    outer: Sma,
34}
35
36impl Trima {
37    /// Construct a new TRIMA with the given period.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`Error::PeriodZero`] if `period == 0`.
42    pub fn new(period: usize) -> Result<Self> {
43        if period == 0 {
44            return Err(Error::PeriodZero);
45        }
46        if period > crate::error::MAX_PERIOD {
47            return Err(Error::InvalidPeriod {
48                message: crate::error::PERIOD_ABOVE_MAX,
49            });
50        }
51        let (n1, n2) = if period % 2 == 1 {
52            (period.div_ceil(2), period.div_ceil(2))
53        } else {
54            (period / 2, period / 2 + 1)
55        };
56        Ok(Self {
57            period,
58            inner: Sma::new(n1)?,
59            outer: Sma::new(n2)?,
60        })
61    }
62
63    /// Configured period.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67
68    /// Current value if available.
69    pub fn value(&self) -> Option<f64> {
70        self.outer.value()
71    }
72}
73
74impl Indicator for Trima {
75    type Input = f64;
76    type Output = f64;
77
78    #[inline]
79    fn update(&mut self, input: f64) -> Option<f64> {
80        if !input.is_finite() {
81            // Non-finite input is ignored; do not double-feed the inner SMA's
82            // stale value into the outer SMA.
83            return None;
84        }
85        // Genuine stacking: the outer SMA consumes the inner SMA's output.
86        match self.inner.update(input) {
87            Some(v) => self.outer.update(v),
88            None => None,
89        }
90    }
91
92    fn reset(&mut self) {
93        self.inner.reset();
94        self.outer.reset();
95    }
96
97    #[inline]
98    fn warmup_period(&self) -> usize {
99        self.period
100    }
101
102    #[inline]
103    fn is_ready(&self) -> bool {
104        self.outer.is_ready()
105    }
106
107    #[inline]
108    fn name(&self) -> &'static str {
109        "TRIMA"
110    }
111
112    /// SIMD kernel: the inner and outer SMAs as the rolling-sum prefix-scan
113    /// kernel. Agrees with the exact batch to within a few units in the last
114    /// place; warmup `NaN`s and length are identical. TRIMA only remembers its
115    /// last `inner + outer − 1` inputs, so afterwards its state is rebuilt
116    /// exactly by replaying them.
117    fn batch_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
118        assert_eq!(
119            inputs.len(),
120            out.len(),
121            "batch output length must equal input length"
122        );
123        let (inner, outer) = (self.inner.period(), self.outer.period());
124        let n = inputs.len();
125        let span = inner + outer - 1;
126        if !(self.inner.is_fresh() && self.outer.is_fresh())
127            || n < span
128            || !crate::fast::in_range(inputs)
129        {
130            self.batch_nan_into(inputs, out);
131            return;
132        }
133        crate::fast::with_scratch(n, |tmp| {
134            wickra_simd::dispatch(crate::fast::TrimaFast {
135                x: inputs,
136                inner,
137                outer,
138                tmp,
139                out,
140                _borrow: std::marker::PhantomData,
141            });
142        });
143        crate::fast::replay_tail(self, &inputs[n - span..]);
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::traits::BatchExt;
151    use approx::assert_relative_eq;
152
153    #[test]
154    fn new_rejects_zero_period() {
155        assert!(matches!(Trima::new(0), Err(Error::PeriodZero)));
156    }
157
158    /// Cover the const accessors `period` / `value` (59-66) and the
159    /// Indicator-impl `name` body (99-101). Existing tests inspect
160    /// TRIMA output but never query the metadata.
161    #[test]
162    fn accessors_and_metadata() {
163        let mut t = Trima::new(5).unwrap();
164        assert_eq!(t.period(), 5);
165        assert_eq!(t.name(), "TRIMA");
166        assert_eq!(t.value(), None);
167        for i in 1..=t.warmup_period() {
168            t.update(f64::from(u32::try_from(i).unwrap()));
169        }
170        assert!(t.value().is_some());
171    }
172
173    #[test]
174    fn odd_period_reference_values() {
175        // TRIMA(5) is SMA(3) of SMA(3).
176        // SMA(3) of 1..=7 -> [_,_,2,3,4,5,6]; SMA(3) of that -> [_,_,_,_,3,4,5].
177        let mut trima = Trima::new(5).unwrap();
178        let out = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
179        assert_eq!(out[0], None);
180        assert_eq!(out[3], None);
181        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
182        assert_relative_eq!(out[5].unwrap(), 4.0, epsilon = 1e-12);
183        assert_relative_eq!(out[6].unwrap(), 5.0, epsilon = 1e-12);
184    }
185
186    #[test]
187    fn first_emission_at_warmup_period() {
188        // Even period: TRIMA(6) -> SMA(3) of SMA(4); first value at input 6.
189        let mut trima = Trima::new(6).unwrap();
190        let out = trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
191        assert_eq!(trima.warmup_period(), 6);
192        for v in out.iter().take(5) {
193            assert!(v.is_none());
194        }
195        assert!(out[5].is_some());
196    }
197
198    #[test]
199    fn constant_series_yields_the_constant() {
200        let mut trima = Trima::new(7).unwrap();
201        let out = trima.batch(&[42.0; 20]);
202        for x in out.iter().skip(6) {
203            assert_relative_eq!(x.unwrap(), 42.0, epsilon = 1e-12);
204        }
205    }
206
207    #[test]
208    fn ignores_non_finite_input() {
209        let mut trima = Trima::new(5).unwrap();
210        let ready = trima.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
211        let last = ready[4];
212        assert!(last.is_some());
213        assert_eq!(trima.update(f64::NAN), None);
214    }
215
216    #[test]
217    fn reset_clears_state() {
218        let mut trima = Trima::new(5).unwrap();
219        trima.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
220        assert!(trima.is_ready());
221        trima.reset();
222        assert!(!trima.is_ready());
223        assert_eq!(trima.update(1.0), None);
224    }
225
226    #[test]
227    fn batch_equals_streaming() {
228        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
229        let batch = Trima::new(8).unwrap().batch(&prices);
230        let mut b = Trima::new(8).unwrap();
231        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
232        assert_eq!(batch, streamed);
233    }
234}