Skip to main content

wickra_core/indicators/
smma.rs

1//! Smoothed Moving Average (Wilder's RMA).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Smoothed Moving Average — Wilder's running moving average, also known as
9/// RMA.
10///
11/// Seeded with the simple average of the first `period` inputs, then advanced
12/// by `SMMA_t = (SMMA_{t-1} * (period - 1) + price_t) / period`. This is an
13/// exponential average with a slow `1 / period` smoothing factor and is the
14/// average underlying Wilder's RSI and ATR. The first output lands after
15/// exactly `period` inputs.
16///
17/// # Example
18///
19/// ```
20/// use wickra_core::{Indicator, Smma};
21///
22/// let mut indicator = Smma::new(3).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 Smma {
31    period: usize,
32    /// Inputs collected while seeding (before the first value is produced).
33    seed: VecDeque<f64>,
34    seed_sum: f64,
35    current: Option<f64>,
36}
37
38impl Smma {
39    /// Construct a new SMMA with the given period.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`Error::PeriodZero`] if `period == 0`.
44    pub fn new(period: usize) -> Result<Self> {
45        if period == 0 {
46            return Err(Error::PeriodZero);
47        }
48        if period > crate::error::MAX_PERIOD {
49            return Err(Error::InvalidPeriod {
50                message: crate::error::PERIOD_ABOVE_MAX,
51            });
52        }
53        Ok(Self {
54            period,
55            seed: VecDeque::with_capacity(period),
56            seed_sum: 0.0,
57            current: None,
58        })
59    }
60
61    /// Configured period.
62    pub const fn period(&self) -> usize {
63        self.period
64    }
65
66    /// Current value if available.
67    pub const fn value(&self) -> Option<f64> {
68        self.current
69    }
70}
71
72impl Indicator for Smma {
73    type Input = f64;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, input: f64) -> Option<f64> {
78        if !input.is_finite() {
79            // Non-finite input is ignored, leaving state untouched.
80            return None;
81        }
82        if let Some(prev) = self.current {
83            let period = self.period as f64;
84            self.current = Some((prev * (period - 1.0) + input) / period);
85        } else {
86            self.seed.push_back(input);
87            self.seed_sum += input;
88            if self.seed.len() == self.period {
89                self.current = Some(self.seed_sum / self.period as f64);
90            }
91        }
92        self.current
93    }
94
95    fn reset(&mut self) {
96        self.seed.clear();
97        self.seed_sum = 0.0;
98        self.current = None;
99    }
100
101    #[inline]
102    fn warmup_period(&self) -> usize {
103        self.period
104    }
105
106    #[inline]
107    fn is_ready(&self) -> bool {
108        self.current.is_some()
109    }
110
111    #[inline]
112    fn name(&self) -> &'static str {
113        "SMMA"
114    }
115
116    /// SIMD kernel: the same seed mean, then Wilder's smoothing
117    /// `y = ((period − 1) / period) · y + x / period` as a linear-recurrence
118    /// scan. Agrees with the exact batch to within a few units in the last
119    /// place (the division per step becomes two multiplies, and the scan
120    /// reassociates the recurrence); warmup `NaN`s and length are identical.
121    /// Afterwards the SMMA continues streaming from the kernel's last value.
122    fn batch_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
123        assert_eq!(
124            inputs.len(),
125            out.len(),
126            "batch output length must equal input length"
127        );
128        let p = self.period;
129        if !self.seed.is_empty()
130            || self.current.is_some()
131            || inputs.len() < p
132            || !crate::fast::in_range(inputs)
133        {
134            self.batch_nan_into(inputs, out);
135            return;
136        }
137        let seed_sum = inputs[..p].iter().fold(0.0, |acc, &x| acc + x);
138        let last = wickra_simd::dispatch(crate::fast::SmmaFast {
139            x: inputs,
140            period: p,
141            seed_sum,
142            out,
143            _borrow: std::marker::PhantomData,
144        });
145        self.seed.extend(&inputs[..p]);
146        self.seed_sum = seed_sum;
147        self.current = Some(last);
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155    use approx::assert_relative_eq;
156
157    #[test]
158    fn new_rejects_zero_period() {
159        assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
160    }
161
162    /// Cover the const accessors `period` / `value` and the Indicator-impl
163    /// `warmup_period` / `name` methods. Existing tests only exercise the
164    /// numeric output of `update` / `batch` / `reset`, never query the
165    /// metadata surface.
166    #[test]
167    fn accessors_and_metadata() {
168        let mut smma = Smma::new(7).unwrap();
169        assert_eq!(smma.period(), 7);
170        assert_eq!(smma.warmup_period(), 7);
171        assert_eq!(smma.name(), "SMMA");
172        // value() must report both the pre-warmup None and post-warmup Some branches.
173        assert_eq!(smma.value(), None);
174        for i in 1..=7 {
175            smma.update(f64::from(i));
176        }
177        assert!(smma.value().is_some());
178    }
179
180    #[test]
181    fn warmup_then_recurrence() {
182        // SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
183        let mut smma = Smma::new(3).unwrap();
184        assert_eq!(smma.update(1.0), None);
185        assert_eq!(smma.update(2.0), None);
186        assert_eq!(smma.update(3.0), Some(2.0));
187        assert_relative_eq!(
188            smma.update(4.0).unwrap(),
189            (2.0 * 2.0 + 4.0) / 3.0,
190            epsilon = 1e-12
191        );
192        assert_relative_eq!(
193            smma.update(5.0).unwrap(),
194            ((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0,
195            epsilon = 1e-12
196        );
197    }
198
199    #[test]
200    fn period_one_is_pass_through() {
201        let mut smma = Smma::new(1).unwrap();
202        assert_eq!(smma.update(5.0), Some(5.0));
203        assert_eq!(smma.update(10.0), Some(10.0));
204    }
205
206    #[test]
207    fn constant_series_yields_the_constant() {
208        let mut smma = Smma::new(5).unwrap();
209        let out = smma.batch(&[7.0; 20]);
210        for x in out.iter().skip(4) {
211            assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12);
212        }
213    }
214
215    #[test]
216    fn ignores_non_finite_input() {
217        let mut smma = Smma::new(3).unwrap();
218        smma.batch(&[1.0, 2.0, 3.0]);
219        assert_eq!(smma.update(f64::NAN), None);
220        assert_eq!(smma.update(f64::INFINITY), None);
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut smma = Smma::new(3).unwrap();
226        smma.batch(&[1.0, 2.0, 3.0, 4.0]);
227        assert!(smma.is_ready());
228        smma.reset();
229        assert!(!smma.is_ready());
230        assert_eq!(smma.update(10.0), None);
231    }
232
233    #[test]
234    fn batch_equals_streaming() {
235        let prices: Vec<f64> = (1..=30).map(f64::from).collect();
236        let batch = Smma::new(7).unwrap().batch(&prices);
237        let mut b = Smma::new(7).unwrap();
238        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
239        assert_eq!(batch, streamed);
240    }
241}