Skip to main content

wickra_core/indicators/
variance.rs

1//! Rolling population variance.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling population variance over the last `period` values.
9///
10/// ```text
11/// mean     = (1/n) · Σ price
12/// Variance = (1/n) · Σ price² − mean²
13/// ```
14///
15/// Variance is the squared standard deviation. It is the second central
16/// moment of the rolling distribution and the natural input to risk
17/// calculations that expect squared returns (e.g. portfolio variance,
18/// covariance matrices). Use [`crate::StdDev`] when you need the
19/// scale-preserving square root instead.
20///
21/// Floating-point cancellation can drive the running expression slightly
22/// negative on perfectly constant inputs; the result is clamped to zero
23/// before being returned so it stays a valid variance.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, Variance};
29///
30/// let mut indicator = Variance::new(20).unwrap();
31/// let mut last = None;
32/// for i in 0..40 {
33///     last = indicator.update(100.0 + f64::from(i));
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct Variance {
39    period: usize,
40    window: VecDeque<f64>,
41    sum: f64,
42    sum_sq: f64,
43}
44
45impl Variance {
46    /// Construct a new rolling variance with the given period.
47    ///
48    /// # Errors
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        })
60    }
61
62    /// Configured period.
63    pub const fn period(&self) -> usize {
64        self.period
65    }
66}
67
68impl Indicator for Variance {
69    type Input = f64;
70    type Output = f64;
71
72    fn update(&mut self, value: f64) -> Option<f64> {
73        if !value.is_finite() {
74            return None;
75        }
76        if self.window.len() == self.period {
77            let old = self.window.pop_front().expect("non-empty");
78            self.sum -= old;
79            self.sum_sq -= old * old;
80        }
81        self.window.push_back(value);
82        self.sum += value;
83        self.sum_sq += value * value;
84        if self.window.len() < self.period {
85            return None;
86        }
87        let n = self.period as f64;
88        let mean = self.sum / n;
89        Some((self.sum_sq / n - mean * mean).max(0.0))
90    }
91
92    fn reset(&mut self) {
93        self.window.clear();
94        self.sum = 0.0;
95        self.sum_sq = 0.0;
96    }
97
98    fn warmup_period(&self) -> usize {
99        self.period
100    }
101
102    fn is_ready(&self) -> bool {
103        self.window.len() == self.period
104    }
105
106    fn name(&self) -> &'static str {
107        "Variance"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115    use approx::assert_relative_eq;
116
117    #[test]
118    fn rejects_zero_period() {
119        assert!(matches!(Variance::new(0), Err(Error::PeriodZero)));
120    }
121
122    #[test]
123    fn accessors_and_metadata() {
124        let v = Variance::new(14).unwrap();
125        assert_eq!(v.period(), 14);
126        assert_eq!(v.warmup_period(), 14);
127        assert_eq!(v.name(), "Variance");
128    }
129
130    #[test]
131    fn reference_value() {
132        // Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3.
133        let mut v = Variance::new(3).unwrap();
134        let out = v.batch(&[2.0, 4.0, 6.0]);
135        assert_eq!(out[0], None);
136        assert_eq!(out[1], None);
137        assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12);
138    }
139
140    #[test]
141    fn constant_series_yields_zero() {
142        let mut v = Variance::new(5).unwrap();
143        for o in v.batch(&[42.0; 20]).into_iter().flatten() {
144            assert_relative_eq!(o, 0.0, epsilon = 1e-12);
145        }
146    }
147
148    #[test]
149    fn first_value_on_period_th_input() {
150        let mut v = Variance::new(5).unwrap();
151        let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
152        for (i, x) in out.iter().enumerate().take(4) {
153            assert!(x.is_none(), "index {i} must be None during warmup");
154        }
155        assert!(out[4].is_some());
156    }
157
158    #[test]
159    fn reset_clears_state() {
160        let mut v = Variance::new(5).unwrap();
161        v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
162        assert!(v.is_ready());
163        v.reset();
164        assert!(!v.is_ready());
165        assert_eq!(v.update(1.0), None);
166    }
167
168    #[test]
169    fn equals_stddev_squared() {
170        // The rolling Variance must equal the rolling population StdDev squared.
171        let prices: Vec<f64> = (0..60)
172            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0)
173            .collect();
174        let mut var = Variance::new(14).unwrap();
175        let mut sd = crate::StdDev::new(14).unwrap();
176        for &p in &prices {
177            let (v, s) = (var.update(p), sd.update(p));
178            assert_eq!(v.is_some(), s.is_some());
179            if let (Some(v), Some(s)) = (v, s) {
180                assert_relative_eq!(v, s * s, epsilon = 1e-9);
181            }
182        }
183    }
184
185    #[test]
186    fn batch_equals_streaming() {
187        let prices: Vec<f64> = (0..60)
188            .map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0)
189            .collect();
190        let batch = Variance::new(14).unwrap().batch(&prices);
191        let mut b = Variance::new(14).unwrap();
192        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
193        assert_eq!(batch, streamed);
194    }
195}