//! Simple Moving Average. use std::collections::VecDeque; use crate::error::{Error, Result}; use crate::traits::Indicator; /// Simple Moving Average over a fixed window. /// /// Maintains a rolling sum so each update is O(1). Output equals /// `sum(last `period` prices) / period` once the window is full; `None` before. /// /// On long-running streams a single-subtract incremental sum can accumulate /// rounding error (catastrophic cancellation when values of very different /// magnitudes are alternately added and removed). To keep drift bounded, the /// running sum is reseeded from the live window every `16 · period` updates — /// O(1) amortised cost (`O(period)` work amortised over `O(period)` updates), /// zero observable behaviour change on inputs that did not drift to begin /// with, and a strict cap on accumulated rounding for streams that did. /// /// # Example /// /// ``` /// use wickra_core::{Indicator, Sma}; /// /// let mut indicator = Sma::new(3).unwrap(); /// let mut last = None; /// for i in 0..80 { /// last = indicator.update(100.0 + f64::from(i)); /// } /// assert!(last.is_some()); /// ``` #[derive(Debug, Clone)] pub struct Sma { period: usize, window: VecDeque, sum: f64, /// Number of finite updates since the running `sum` was last reseeded from /// the live window. Caps accumulated floating-point drift on long streams. /// See [`RECOMPUTE_EVERY`] below. updates_since_recompute: usize, } /// How often (in finite updates) the incremental sum is reseeded from the live /// window. The multiplier `16` is the smallest power of two that keeps the /// amortised cost flat under any `period` while still bounding any drift to /// roughly `16 · period · ULP · max(|x|)` — sub-picodollar on real-world price /// scales. const RECOMPUTE_EVERY: usize = 16; impl Sma { /// Construct a new SMA with the given window length. /// /// # Errors /// /// Returns [`Error::PeriodZero`] if `period == 0`. pub fn new(period: usize) -> Result { if period == 0 { return Err(Error::PeriodZero); } Ok(Self { period, window: VecDeque::with_capacity(period), sum: 0.0, updates_since_recompute: 0, }) } /// Configured window length. pub const fn period(&self) -> usize { self.period } /// Current value if available. pub fn value(&self) -> Option { if self.window.len() == self.period { Some(self.sum / self.period as f64) } else { None } } } impl Indicator for Sma { type Input = f64; type Output = f64; fn update(&mut self, input: f64) -> Option { if !input.is_finite() { return self.value(); } if self.window.len() == self.period { // Slide: drop the oldest, then add the new. Each step is a single // f64 add/subtract — O(1) but introduces ~1 ULP of rounding noise. // The periodic reseed below caps the accumulated drift. let old = self.window.pop_front().expect("window non-empty"); self.sum -= old; } self.window.push_back(input); self.sum += input; self.updates_since_recompute += 1; if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period { self.sum = self.window.iter().copied().sum(); self.updates_since_recompute = 0; } self.value() } fn reset(&mut self) { self.window.clear(); self.sum = 0.0; self.updates_since_recompute = 0; } fn warmup_period(&self) -> usize { self.period } fn is_ready(&self) -> bool { self.window.len() == self.period } fn name(&self) -> &'static str { "SMA" } } #[cfg(test)] mod tests { use super::*; use crate::traits::BatchExt; use approx::assert_relative_eq; #[test] fn new_rejects_zero_period() { assert!(matches!(Sma::new(0), Err(Error::PeriodZero))); } #[test] fn warmup_returns_none() { let mut sma = Sma::new(3).unwrap(); assert_eq!(sma.update(1.0), None); assert_eq!(sma.update(2.0), None); assert_eq!(sma.update(3.0), Some(2.0)); } #[test] fn rolls_window_after_full() { let mut sma = Sma::new(3).unwrap(); let out: Vec<_> = [1.0, 2.0, 3.0, 4.0, 5.0] .iter() .map(|p| sma.update(*p)) .collect(); assert_eq!(out, vec![None, None, Some(2.0), Some(3.0), Some(4.0)]); } #[test] fn period_one_is_pass_through() { let mut sma = Sma::new(1).unwrap(); assert_eq!(sma.update(5.0), Some(5.0)); assert_eq!(sma.update(10.0), Some(10.0)); } #[test] fn ignores_non_finite_input_but_keeps_state() { let mut sma = Sma::new(3).unwrap(); sma.update(1.0); sma.update(2.0); sma.update(3.0); assert_eq!(sma.update(f64::NAN), Some(2.0)); assert_eq!(sma.update(f64::INFINITY), Some(2.0)); // Non-finite inputs were not pushed; window still holds 1,2,3. assert_eq!(sma.update(6.0), Some((2.0 + 3.0 + 6.0) / 3.0)); } #[test] fn reset_clears_state() { let mut sma = Sma::new(3).unwrap(); sma.batch(&[1.0, 2.0, 3.0]); assert!(sma.is_ready()); sma.reset(); assert!(!sma.is_ready()); assert_eq!(sma.update(10.0), None); } #[test] fn batch_equals_streaming() { let prices: Vec = (1..=20).map(f64::from).collect(); let mut a = Sma::new(5).unwrap(); let batch = a.batch(&prices); let mut b = Sma::new(5).unwrap(); let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); assert_eq!(batch, streamed); } #[test] fn known_reference_values() { // SMA(3) of [2, 4, 6, 8, 10] -> [_, _, 4, 6, 8] let mut sma = Sma::new(3).unwrap(); let out = sma.batch(&[2.0, 4.0, 6.0, 8.0, 10.0]); assert_eq!(out[2], Some(4.0)); assert_eq!(out[3], Some(6.0)); assert_eq!(out[4], Some(8.0)); } #[test] fn constant_series_yields_constant_sma() { let mut sma = Sma::new(5).unwrap(); let v = sma.batch(&[7.0; 10]); for x in v.iter().skip(4) { assert_relative_eq!(x.unwrap(), 7.0, epsilon = 1e-12); } } proptest::proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(64))] #[test] fn sma_matches_naive_definition( period in 1usize..20, prices in proptest::collection::vec(-1000.0_f64..1000.0, 0..200), ) { let mut sma = Sma::new(period).unwrap(); let stream: Vec<_> = prices.iter().map(|p| sma.update(*p)).collect(); for (i, got) in stream.iter().enumerate() { if i + 1 < period { proptest::prop_assert!(got.is_none()); } else { let window = &prices[i + 1 - period..=i]; let expected = window.iter().sum::() / period as f64; let actual = got.expect("ready"); proptest::prop_assert!( (actual - expected).abs() < 1e-9, "i={i} actual={actual} expected={expected}" ); } } } } /// Long-running stability check. Runs more updates than `RECOMPUTE_EVERY * /// period` so the periodic reseed must fire several times, then asserts /// that the reported SMA still equals a fresh from-scratch mean over the /// live window to within tight floating-point tolerance. Inputs swing /// between two magnitudes (`1e9` and `1.0`) — a pattern designed to /// expose catastrophic cancellation in a naive single-subtract sum. #[test] fn long_stream_drift_stays_bounded() { let period = 20; let mut sma = Sma::new(period).unwrap(); let mut window: VecDeque = VecDeque::with_capacity(period); // `RECOMPUTE_EVERY * period * 5` updates → recompute fires 5+ times. let n_updates = 16 * period * 5; for i in 0..n_updates { let v = if i % 2 == 0 { 1e9 } else { 1.0 }; sma.update(v); if window.len() == period { window.pop_front(); } window.push_back(v); } let from_scratch: f64 = window.iter().sum::() / period as f64; let got = sma.value().expect("warmed up"); assert!( (got - from_scratch).abs() < 1e-6, "SMA drift exceeds 1e-6 over {n_updates} updates: got={got}, scratch={from_scratch}" ); } }