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::indicators::rolling_moments::ShiftedMoments;
7use crate::traits::Indicator;
8
9/// Rolling population standard deviation over the last `period` values.
10///
11/// ```text
12/// mean     = (1/n) · Σ price
13/// variance = (1/n) · Σ (price − mean)²
14/// StdDev   = √variance
15/// ```
16///
17/// This is the **population** standard deviation (divisor `n`, not `n − 1`) —
18/// the same dispersion measure that drives [`BollingerBands`](crate::BollingerBands).
19/// It is maintained as an O(1) rolling state machine: running first and second
20/// moments, updated by one add and one subtract per bar. The moments are
21/// accumulated relative to a reference point inside the window
22/// (`ShiftedMoments`) rather than around zero, because `E[x²] - E[x]²` on raw
23/// price levels cancels catastrophically — at a level of 1e5 with a tight range
24/// it loses most of its significant digits, and at 1e8 it collapses to exactly
25/// zero.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, StdDev};
31///
32/// let mut indicator = StdDev::new(20).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct StdDev {
41    period: usize,
42    window: VecDeque<f64>,
43    moments: ShiftedMoments,
44    last: Option<f64>,
45}
46
47impl StdDev {
48    /// Construct a new rolling standard deviation with the given period.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`Error::PeriodZero`] if `period == 0`.
53    pub fn new(period: usize) -> Result<Self> {
54        if period == 0 {
55            return Err(Error::PeriodZero);
56        }
57        if period > crate::error::MAX_PERIOD {
58            return Err(Error::InvalidPeriod {
59                message: crate::error::PERIOD_ABOVE_MAX,
60            });
61        }
62        Ok(Self {
63            period,
64            window: VecDeque::with_capacity(period),
65            moments: ShiftedMoments::new(),
66            last: None,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74
75    /// Current value if available.
76    pub const fn value(&self) -> Option<f64> {
77        self.last
78    }
79}
80
81impl Indicator for StdDev {
82    type Input = f64;
83    type Output = f64;
84
85    #[inline]
86    fn update(&mut self, input: f64) -> Option<f64> {
87        if !input.is_finite() {
88            // Non-finite input is ignored; the window is left untouched.
89            return None;
90        }
91        if self.window.len() == self.period {
92            let old = self.window.pop_front().expect("window is non-empty");
93            self.moments.evict(old);
94        }
95        self.window.push_back(input);
96        self.moments.push(input);
97        if self.moments.needs_reseed(self.period) {
98            self.moments.reseed(self.window.iter().copied());
99        }
100        if self.window.len() < self.period {
101            return None;
102        }
103        let sd = self.moments.std_dev(self.period);
104        self.last = Some(sd);
105        Some(sd)
106    }
107
108    fn reset(&mut self) {
109        self.window.clear();
110        self.moments.reset();
111        self.last = None;
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        self.period
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.last.is_some()
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "StdDev"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    /// Two-pass population standard deviation — the numerically stable form,
137    /// used as the reference the rolling state machine must match.
138    fn reference_std_dev(window: &[f64]) -> f64 {
139        let n = window.len() as f64;
140        let mean = window.iter().sum::<f64>() / n;
141        (window.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n).sqrt()
142    }
143
144    /// The rolling moments must stay accurate when the values are large relative
145    /// to their spread — exactly the shape of a real price series. The textbook
146    /// `E[x²] - E[x]²` form cancels catastrophically here: at a level of 1e5 it
147    /// loses most of its significant digits, and at 1e8 it collapses to zero.
148    #[test]
149    fn stays_accurate_when_the_level_dwarfs_the_spread() {
150        for level in [1.0e2_f64, 1.0e5, 1.0e8] {
151            let prices: Vec<f64> = (0..60)
152                .map(|i| level + (f64::from(i) * 0.7).sin())
153                .collect();
154            let mut sd = StdDev::new(20).unwrap();
155            let mut got = 0.0;
156            for price in &prices {
157                if let Some(v) = sd.update(*price) {
158                    got = v;
159                }
160            }
161            assert_relative_eq!(got, reference_std_dev(&prices[40..]), max_relative = 1e-9);
162        }
163    }
164
165    #[test]
166    fn new_rejects_zero_period() {
167        assert!(matches!(StdDev::new(0), Err(Error::PeriodZero)));
168    }
169
170    /// Cover the const accessors `period` / `value` and the Indicator-impl
171    /// `warmup_period` / `name` methods (lines 64-71, 110-112, 118-120).
172    /// Existing tests only inspect numeric outputs of `update` / `batch`.
173    #[test]
174    fn accessors_and_metadata() {
175        let mut sd = StdDev::new(14).unwrap();
176        assert_eq!(sd.period(), 14);
177        assert_eq!(sd.warmup_period(), 14);
178        assert_eq!(sd.name(), "StdDev");
179        assert_eq!(sd.value(), None);
180        for i in 1..=14 {
181            sd.update(f64::from(i));
182        }
183        assert!(sd.value().is_some());
184    }
185
186    #[test]
187    fn reference_value() {
188        // StdDev(3) of [2, 4, 6]: mean = 4, variance = (4+0+4)/3 = 8/3.
189        let mut sd = StdDev::new(3).unwrap();
190        let out = sd.batch(&[2.0, 4.0, 6.0]);
191        assert_eq!(out[0], None);
192        assert_eq!(out[1], None);
193        assert_relative_eq!(out[2].unwrap(), (8.0_f64 / 3.0).sqrt(), epsilon = 1e-12);
194    }
195
196    #[test]
197    fn constant_series_yields_zero() {
198        let mut sd = StdDev::new(5).unwrap();
199        let out = sd.batch(&[42.0; 20]);
200        for v in out.iter().skip(4).flatten() {
201            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
202        }
203    }
204
205    #[test]
206    fn matches_naive_definition() {
207        let prices: Vec<f64> = (1..=60)
208            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
209            .collect();
210        let period = 10;
211        let got = StdDev::new(period).unwrap().batch(&prices);
212        for (i, g) in got.iter().enumerate() {
213            if let Some(value) = g {
214                let window = &prices[i + 1 - period..=i];
215                let mean = window.iter().sum::<f64>() / period as f64;
216                let var = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
217                assert_relative_eq!(*value, var.sqrt(), epsilon = 1e-9);
218            }
219        }
220    }
221
222    #[test]
223    fn ignores_non_finite_input() {
224        let mut sd = StdDev::new(3).unwrap();
225        let out = sd.batch(&[2.0, 4.0, 6.0]);
226        let last = out[2];
227        assert!(last.is_some());
228        assert_eq!(sd.update(f64::NAN), None);
229        assert_eq!(sd.update(f64::INFINITY), None);
230    }
231
232    #[test]
233    fn reset_clears_state() {
234        let mut sd = StdDev::new(3).unwrap();
235        sd.batch(&[1.0, 2.0, 3.0, 4.0]);
236        assert!(sd.is_ready());
237        sd.reset();
238        assert!(!sd.is_ready());
239        assert_eq!(sd.update(1.0), None);
240    }
241
242    #[test]
243    fn batch_equals_streaming() {
244        let prices: Vec<f64> = (1..=60)
245            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
246            .collect();
247        let batch = StdDev::new(14).unwrap().batch(&prices);
248        let mut b = StdDev::new(14).unwrap();
249        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
250        assert_eq!(batch, streamed);
251    }
252}