wickra-core 0.2.7

Core streaming-first technical indicators engine for the Wickra library
Documentation
//! 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<f64>,
    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<Self> {
        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<f64> {
        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<f64> {
        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)));
    }

    /// Cover the const accessor `period` (70-72) and the Indicator-impl
    /// `warmup_period` (115-117) + `name` (123-125). Existing tests
    /// inspect SMA output but never query the metadata.
    #[test]
    fn accessors_and_metadata() {
        let sma = Sma::new(20).unwrap();
        assert_eq!(sma.period(), 20);
        assert_eq!(sma.warmup_period(), 20);
        assert_eq!(sma.name(), "SMA");
    }

    #[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<f64> = (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::<f64>() / 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<f64> = 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::<f64>() / 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}"
        );
    }
}