Skip to main content

wickra_core/indicators/
std_dev.rs

1//! Rolling population standard deviation.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling population standard deviation over the last `period` values.
9///
10/// ```text
11/// mean     = (1/n) · Σ price
12/// variance = (1/n) · Σ price² − mean²
13/// StdDev   = √variance
14/// ```
15///
16/// This is the **population** standard deviation (divisor `n`, not `n − 1`) —
17/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands).
18/// It is maintained as an O(1) rolling state machine: a running sum and a
19/// running sum-of-squares, updated by one add and one subtract per bar. Tiny
20/// negative variances from floating-point cancellation are clamped to zero
21/// before the square root.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, StdDev};
27///
28/// let mut indicator = StdDev::new(20).unwrap();
29/// let mut last = None;
30/// for i in 0..80 {
31///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct StdDev {
37    period: usize,
38    window: VecDeque<f64>,
39    sum: f64,
40    sum_sq: f64,
41    last: Option<f64>,
42}
43
44impl StdDev {
45    /// Construct a new rolling standard deviation with the given period.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`Error::PeriodZero`] if `period == 0`.
50    pub fn new(period: usize) -> Result<Self> {
51        if period == 0 {
52            return Err(Error::PeriodZero);
53        }
54        Ok(Self {
55            period,
56            window: VecDeque::with_capacity(period),
57            sum: 0.0,
58            sum_sq: 0.0,
59            last: None,
60        })
61    }
62
63    /// Configured period.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67
68    /// Current value if available.
69    pub const fn value(&self) -> Option<f64> {
70        self.last
71    }
72}
73
74impl Indicator for StdDev {
75    type Input = f64;
76    type Output = f64;
77
78    fn update(&mut self, input: f64) -> Option<f64> {
79        if !input.is_finite() {
80            // Non-finite input is ignored; the window is left untouched.
81            return self.last;
82        }
83        if self.window.len() == self.period {
84            let old = self.window.pop_front().expect("window is non-empty");
85            self.sum -= old;
86            self.sum_sq -= old * old;
87        }
88        self.window.push_back(input);
89        self.sum += input;
90        self.sum_sq += input * input;
91        if self.window.len() < self.period {
92            return None;
93        }
94        let n = self.period as f64;
95        let mean = self.sum / n;
96        // Clamp floating-point cancellation noise: variance is never negative.
97        let variance = (self.sum_sq / n - mean * mean).max(0.0);
98        let sd = variance.sqrt();
99        self.last = Some(sd);
100        Some(sd)
101    }
102
103    fn reset(&mut self) {
104        self.window.clear();
105        self.sum = 0.0;
106        self.sum_sq = 0.0;
107        self.last = None;
108    }
109
110    fn warmup_period(&self) -> usize {
111        self.period
112    }
113
114    fn is_ready(&self) -> bool {
115        self.last.is_some()
116    }
117
118    fn name(&self) -> &'static str {
119        "StdDev"
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::traits::BatchExt;
127    use approx::assert_relative_eq;
128
129    #[test]
130    fn new_rejects_zero_period() {
131        assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
132    }
133
134    /// Cover the const accessors `period` / `value` and the Indicator-impl
135    /// `warmup_period` / `name` methods (lines 64-71, 110-112, 118-120).
136    /// Existing tests only inspect numeric outputs of `update` / `batch`.
137    #[test]
138    fn accessors_and_metadata() {
139        let mut sd = StdDev::new(14).unwrap();
140        assert_eq!(sd.period(), 14);
141        assert_eq!(sd.warmup_period(), 14);
142        assert_eq!(sd.name(), "StdDev");
143        assert_eq!(sd.value(), None);
144        for i in 1..=14 {
145            sd.update(f64::from(i));
146        }
147        assert!(sd.value().is_some());
148    }
149
150    #[test]
151    fn reference_value() {
152        // StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
153        let mut sd = StdDev::new(3).unwrap();
154        let out = sd.batch(&[2.0, 4.0, 6.0]);
155        assert_eq!(out[0], None);
156        assert_eq!(out[1], None);
157        assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12);
158    }
159
160    #[test]
161    fn constant_series_yields_zero() {
162        let mut sd = StdDev::new(5).unwrap();
163        let out = sd.batch(&[42.0; 20]);
164        for v in out.iter().skip(4).flatten() {
165            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
166        }
167    }
168
169    #[test]
170    fn matches_naive_definition() {
171        let prices: Vec<f64> = (1..=60)
172            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
173            .collect();
174        let period = 10;
175        let got = StdDev::new(period).unwrap().batch(&prices);
176        for (i, g) in got.iter().enumerate() {
177            if let Some(value) = g {
178                let window = &prices[i + 1 - period..=i];
179                let mean = window.iter().sum::<f64>() / period as f64;
180                let var = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
181                assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9);
182            }
183        }
184    }
185
186    #[test]
187    fn ignores_non_finite_input() {
188        let mut sd = StdDev::new(3).unwrap();
189        let out = sd.batch(&[2.0, 4.0, 6.0]);
190        let last = out[2];
191        assert!(last.is_some());
192        assert_eq!(sd.update(f64::NAN), last);
193        assert_eq!(sd.update(f64::INFINITY), last);
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut sd = StdDev::new(3).unwrap();
199        sd.batch(&[1.0, 2.0, 3.0, 4.0]);
200        assert!(sd.is_ready());
201        sd.reset();
202        assert!(!sd.is_ready());
203        assert_eq!(sd.update(1.0), None);
204    }
205
206    #[test]
207    fn batch_equals_streaming() {
208        let prices: Vec<f64> = (1..=60)
209            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
210            .collect();
211        let batch = StdDev::new(14).unwrap().batch(&prices);
212        let mut b = StdDev::new(14).unwrap();
213        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
214        assert_eq!(batch, streamed);
215    }
216}