`Sma` and `BollingerBands` both maintained their running `sum` (and `sum_sq` for Bollinger) with a single-subtract incremental update. That is correct in exact arithmetic, but in f64 the sequence `sum -= old; sum += new` on long streams with alternating large/small magnitudes can accumulate catastrophic-cancellation error. Bollinger's existing `.max(0.0)` clamp on the computed variance was a band-aid for the same root cause — the drift had already driven the running variance below zero. The fix: every `16 · period` finite updates, reseed `sum` (and `sum_sq` for Bollinger) from the live window. Amortised cost stays at O(1) — `O(period)` work amortised over `O(period)` updates — and the reseed strategy is named after the constant `RECOMPUTE_EVERY` so the intention is clear at the call site. Behaviour is unchanged on inputs that did not drift to begin with (every existing test still passes, including `batch_equals_streaming` and the SMA proptest). Two new stress tests (`long_stream_drift_stays_bounded` in each module) feed a magnitude-alternating stream for `5 · RECOMPUTE_EVERY · period` updates and assert the reported value tracks a fresh from-scratch computation over the live window to within tight tolerance — these would have failed without the reseed on Bollinger's `sum_sq`. The misleading `sma.rs` comment that claimed drift was already bounded by recomputing the sum after each pop is rewritten to describe the actual reseed strategy (audit finding L2-Rust).
269 lines
8.7 KiB
Rust
269 lines
8.7 KiB
Rust
//! 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)));
|
|
}
|
|
|
|
#[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.is_multiple_of(2) { 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}"
|
|
);
|
|
}
|
|
}
|