Skip to main content

wickra_embed_core/indicators/
sma.rs

1//! Simple Moving Average — const-generic, no-alloc, byte-parity with
2//! `wickra_core::Sma`.
3
4use crate::ring::Ring;
5use crate::traits::Indicator;
6
7/// How often (in finite updates) the incremental sum is reseeded from the live
8/// window, as a multiple of `N`. Must equal `wickra-core`'s constant: the drift
9/// bound and, more importantly, the exact reseed *cadence* are part of the
10/// byte-parity contract.
11const RECOMPUTE_EVERY: usize = 16;
12
13/// Simple Moving Average over a fixed window of `N` prices.
14///
15/// Keeps a rolling sum so each update is O(1); the sum is reseeded from the live
16/// window every `16 · N` finite updates to bound floating-point drift, exactly
17/// as `wickra_core::Sma` does. Output is `sum / N` once the window is full,
18/// `None` before.
19///
20/// # Example
21///
22/// ```
23/// use wickra_embed_core::{Indicator, Sma};
24///
25/// let mut sma = Sma::<3>::new();
26/// assert_eq!(sma.update(1.0), None);
27/// assert_eq!(sma.update(2.0), None);
28/// assert_eq!(sma.update(3.0), Some(2.0));
29/// assert_eq!(sma.update(4.0), Some(3.0));
30/// ```
31#[derive(Debug, Clone)]
32pub struct Sma<const N: usize> {
33    buf: Ring<N>,
34    sum: f64,
35    updates_since_recompute: usize,
36}
37
38impl<const N: usize> Sma<N> {
39    /// Construct a new SMA over a window of `N`.
40    #[must_use]
41    pub const fn new() -> Self {
42        Self {
43            buf: Ring::new(),
44            sum: 0.0,
45            updates_since_recompute: 0,
46        }
47    }
48
49    /// Current value if the window is full.
50    #[must_use]
51    pub fn value(&self) -> Option<f64> {
52        if self.buf.is_full() {
53            Some(self.sum / N as f64)
54        } else {
55            None
56        }
57    }
58}
59
60impl<const N: usize> Default for Sma<N> {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl<const N: usize> Indicator for Sma<N> {
67    type Input = f64;
68
69    fn update(&mut self, input: f64) -> Option<f64> {
70        if !input.is_finite() {
71            return self.value();
72        }
73        // Subtract-then-add in the same order as wickra-core: the evicted oldest
74        // value leaves the sum before the new one enters it.
75        match self.buf.push(input) {
76            Some(old) => {
77                self.sum -= old;
78                self.sum += input;
79            }
80            None => {
81                self.sum += input;
82            }
83        }
84        self.updates_since_recompute += 1;
85        if self.updates_since_recompute >= RECOMPUTE_EVERY * N {
86            self.sum = self.buf.chrono_sum();
87            self.updates_since_recompute = 0;
88        }
89        self.value()
90    }
91
92    fn reset(&mut self) {
93        *self = Self::new();
94    }
95
96    fn warmup_period(&self) -> usize {
97        N
98    }
99
100    fn is_ready(&self) -> bool {
101        self.buf.is_full()
102    }
103
104    fn name(&self) -> &'static str {
105        "sma"
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::Sma;
112    use crate::traits::Indicator;
113
114    #[test]
115    fn warmup_then_mean() {
116        let mut sma = Sma::<4>::new();
117        assert!(!sma.is_ready());
118        for x in [10.0, 20.0, 30.0] {
119            assert_eq!(sma.update(x), None);
120        }
121        assert_eq!(sma.update(40.0), Some(25.0));
122        assert!(sma.is_ready());
123        assert_eq!(sma.warmup_period(), 4);
124    }
125
126    #[test]
127    fn reset_clears_state() {
128        let mut sma = Sma::<3>::new();
129        for x in [1.0, 2.0, 3.0, 4.0] {
130            sma.update(x);
131        }
132        assert!(sma.is_ready());
133        sma.reset();
134        assert!(!sma.is_ready());
135        assert_eq!(sma.value(), None);
136    }
137
138    #[test]
139    fn non_finite_is_ignored() {
140        let mut sma = Sma::<2>::new();
141        sma.update(2.0);
142        let ready = sma.update(4.0);
143        assert_eq!(ready, Some(3.0));
144        // NaN leaves the value unchanged.
145        assert_eq!(sma.update(f64::NAN), Some(3.0));
146        assert_eq!(sma.update(f64::INFINITY), Some(3.0));
147    }
148
149    /// Byte-for-byte parity with the std `wickra_core::Sma` over a long,
150    /// drift-exercising series (crosses several reseed boundaries).
151    #[test]
152    fn byte_parity_with_wickra_core() {
153        let mut ours = Sma::<20>::new();
154        let mut theirs = wickra_core::Sma::new(20).unwrap();
155        for i in 0..2000 {
156            let x = 100.0 + 25.0 * (f64::from(i) * 0.05).sin() + 0.01 * f64::from(i);
157            let a = ours.update(x);
158            let b = <wickra_core::Sma as wickra_core::Indicator>::update(&mut theirs, x);
159            assert_eq!(
160                a.map(f64::to_bits),
161                b.map(f64::to_bits),
162                "divergence at index {i}"
163            );
164        }
165    }
166}