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 self.window.len() == self.period {
74            let old = self.window.pop_front().expect("non-empty");
75            self.sum -= old;
76            self.sum_sq -= old * old;
77        }
78        self.window.push_back(value);
79        self.sum += value;
80        self.sum_sq += value * value;
81        if self.window.len() < self.period {
82            return None;
83        }
84        let n = self.period as f64;
85        let mean = self.sum / n;
86        Some((self.sum_sq / n - mean * mean).max(0.0))
87    }
88
89    fn reset(&mut self) {
90        self.window.clear();
91        self.sum = 0.0;
92        self.sum_sq = 0.0;
93    }
94
95    fn warmup_period(&self) -> usize {
96        self.period
97    }
98
99    fn is_ready(&self) -> bool {
100        self.window.len() == self.period
101    }
102
103    fn name(&self) -> &'static str {
104        "Variance"
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::traits::BatchExt;
112    use approx::assert_relative_eq;
113
114    #[test]
115    fn rejects_zero_period() {
116        assert!(matches!(Variance::new(0), Err(Error::PeriodZero)));
117    }
118
119    #[test]
120    fn accessors_and_metadata() {
121        let v = Variance::new(14).unwrap();
122        assert_eq!(v.period(), 14);
123        assert_eq!(v.warmup_period(), 14);
124        assert_eq!(v.name(), "Variance");
125    }
126
127    #[test]
128    fn reference_value() {
129        // Variance(3) of [2, 4, 6]: mean = 4, variance = (4 + 0 + 4) / 3 = 8/3.
130        let mut v = Variance::new(3).unwrap();
131        let out = v.batch(&[2.0, 4.0, 6.0]);
132        assert_eq!(out[0], None);
133        assert_eq!(out[1], None);
134        assert_relative_eq!(out[2].unwrap(), 8.0 / 3.0, epsilon = 1e-12);
135    }
136
137    #[test]
138    fn constant_series_yields_zero() {
139        let mut v = Variance::new(5).unwrap();
140        for o in v.batch(&[42.0; 20]).into_iter().flatten() {
141            assert_relative_eq!(o, 0.0, epsilon = 1e-12);
142        }
143    }
144
145    #[test]
146    fn first_value_on_period_th_input() {
147        let mut v = Variance::new(5).unwrap();
148        let out = v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
149        for (i, x) in out.iter().enumerate().take(4) {
150            assert!(x.is_none(), "index {i} must be None during warmup");
151        }
152        assert!(out[4].is_some());
153    }
154
155    #[test]
156    fn reset_clears_state() {
157        let mut v = Variance::new(5).unwrap();
158        v.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
159        assert!(v.is_ready());
160        v.reset();
161        assert!(!v.is_ready());
162        assert_eq!(v.update(1.0), None);
163    }
164
165    #[test]
166    fn equals_stddev_squared() {
167        // The rolling Variance must equal the rolling population StdDev squared.
168        let prices: Vec<f64> = (0..60)
169            .map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 7.0)
170            .collect();
171        let mut var = Variance::new(14).unwrap();
172        let mut sd = crate::StdDev::new(14).unwrap();
173        for &p in &prices {
174            let (v, s) = (var.update(p), sd.update(p));
175            assert_eq!(v.is_some(), s.is_some());
176            if let (Some(v), Some(s)) = (v, s) {
177                assert_relative_eq!(v, s * s, epsilon = 1e-9);
178            }
179        }
180    }
181
182    #[test]
183    fn batch_equals_streaming() {
184        let prices: Vec<f64> = (0..60)
185            .map(|i| 50.0 + (f64::from(i) * 0.3).cos() * 10.0)
186            .collect();
187        let batch = Variance::new(14).unwrap().batch(&prices);
188        let mut b = Variance::new(14).unwrap();
189        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
190        assert_eq!(batch, streamed);
191    }
192}