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        Ok(Self {
49            period,
50            seed: VecDeque::with_capacity(period),
51            seed_sum: 0.0,
52            current: None,
53        })
54    }
55
56    /// Configured period.
57    pub const fn period(&self) -> usize {
58        self.period
59    }
60
61    /// Current value if available.
62    pub const fn value(&self) -> Option<f64> {
63        self.current
64    }
65}
66
67impl Indicator for Smma {
68    type Input = f64;
69    type Output = f64;
70
71    fn update(&mut self, input: f64) -> Option<f64> {
72        if !input.is_finite() {
73            // Non-finite input is ignored, leaving state untouched.
74            return self.current;
75        }
76        if let Some(prev) = self.current {
77            let period = self.period as f64;
78            self.current = Some((prev * (period - 1.0) + input) / period);
79        } else {
80            self.seed.push_back(input);
81            self.seed_sum += input;
82            if self.seed.len() == self.period {
83                self.current = Some(self.seed_sum / self.period as f64);
84            }
85        }
86        self.current
87    }
88
89    fn reset(&mut self) {
90        self.seed.clear();
91        self.seed_sum = 0.0;
92        self.current = None;
93    }
94
95    fn warmup_period(&self) -> usize {
96        self.period
97    }
98
99    fn is_ready(&self) -> bool {
100        self.current.is_some()
101    }
102
103    fn name(&self) -> &'static str {
104        "SMMA"
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::traits::BatchExt;
112    use approx::assert_relative_eq;
113
114    #[test]
115    fn new_rejects_zero_period() {
116        assert!(matches!(Smma::new(0), Err(Error::PeriodZero)));
117    }
118
119    /// Cover the const accessors `period` / `value` and the Indicator-impl
120    /// `warmup_period` / `name` methods. Existing tests only exercise the
121    /// numeric output of `update` / `batch` / `reset`, never query the
122    /// metadata surface.
123    #[test]
124    fn accessors_and_metadata() {
125        let mut smma = Smma::new(7).unwrap();
126        assert_eq!(smma.period(), 7);
127        assert_eq!(smma.warmup_period(), 7);
128        assert_eq!(smma.name(), "SMMA");
129        // value() must report both the pre-warmup None and post-warmup Some branches.
130        assert_eq!(smma.value(), None);
131        for i in 1..=7 {
132            smma.update(f64::from(i));
133        }
134        assert!(smma.value().is_some());
135    }
136
137    #[test]
138    fn warmup_then_recurrence() {
139        // SMMA(3): seed = SMA(1,2,3) = 2.0; then (prev*2 + x) / 3.
140        let mut smma = Smma::new(3).unwrap();
141        assert_eq!(smma.update(1.0), None);
142        assert_eq!(smma.update(2.0), None);
143        assert_eq!(smma.update(3.0), Some(2.0));
144        assert_relative_eq!(
145            smma.update(4.0).unwrap(),
146            (2.0 * 2.0 + 4.0) / 3.0,
147            epsilon = 1e-12
148        );
149        assert_relative_eq!(
150            smma.update(5.0).unwrap(),
151            ((2.0 * 2.0 + 4.0) / 3.0 * 2.0 + 5.0) / 3.0,
152            epsilon = 1e-12
153        );
154    }
155
156    #[test]
157    fn period_one_is_pass_through() {
158        let mut smma = Smma::new(1).unwrap();
159        assert_eq!(smma.update(5.0), Some(5.0));
160        assert_eq!(smma.update(10.0), Some(10.0));
161    }
162
163    #[test]
164    fn constant_series_yields_the_constant() {
165        let mut smma = Smma::new(5).unwrap();
166        let out = smma.batch(&[7.0; 20]);
167        for x in out.iter().skip(4) {
168            assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12);
169        }
170    }
171
172    #[test]
173    fn ignores_non_finite_input() {
174        let mut smma = Smma::new(3).unwrap();
175        smma.batch(&[1.0, 2.0, 3.0]);
176        assert_eq!(smma.update(f64::NAN), Some(2.0));
177        assert_eq!(smma.update(f64::INFINITY), Some(2.0));
178    }
179
180    #[test]
181    fn reset_clears_state() {
182        let mut smma = Smma::new(3).unwrap();
183        smma.batch(&[1.0, 2.0, 3.0, 4.0]);
184        assert!(smma.is_ready());
185        smma.reset();
186        assert!(!smma.is_ready());
187        assert_eq!(smma.update(10.0), None);
188    }
189
190    #[test]
191    fn batch_equals_streaming() {
192        let prices: Vec<f64> = (1..=30).map(f64::from).collect();
193        let batch = Smma::new(7).unwrap().batch(&prices);
194        let mut b = Smma::new(7).unwrap();
195        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
196        assert_eq!(batch, streamed);
197    }
198}