quantwave_core/indicators/incremental/rolling_variance.rs
1//! Numerically stable O(1)-amortised rolling variance over a fixed window.
2//!
3//! # Why this exists (quantwave-qkft)
4//!
5//! The obvious sliding-window variance keeps `Σx` and `Σx²` and evaluates
6//! `E[X²] - E[X]²`. That is O(1) per bar but catastrophically cancels when the
7//! window mean is large relative to the spread: both terms are ~`mean²` while
8//! the answer is ~`σ²`, so the relative error is roughly `eps * (mean/σ)²`.
9//! On raw prices that is harmless (~1e-11), but on a slowly-varying series such
10//! as log-price (`lnP` in 1..9 with per-bar moves of ~1e-4) the ratio is ~1e10
11//! and the result drifts ~1e-6 relative away from a two-pass reference.
12//!
13//! # The algorithm
14//!
15//! Shifted-data variance with a deterministic periodic exact refresh:
16//!
17//! * We keep an `origin` K and accumulate `Σ(x-K)` and `Σ(x-K)²`. Because K sits
18//! inside the current window, `x-K` is on the order of the window *spread*,
19//! not the window *level*, so the `Σd² - (Σd)²/n` subtraction cancels
20//! quantities of order `n·σ²` rather than `n·mean²`. The mean-magnitude
21//! cancellation disappears entirely.
22//! * Add and remove are the exact algebraic inverses of each other, so a single
23//! evict/insert pair introduces no algorithmic drift beyond one rounding.
24//! * Rounding drift and origin staleness (a trending series eventually walks
25//! away from K) are bounded by recomputing `origin`, `Σd` and `Σd²` exactly
26//! from the window every `period` pushes. One O(period) pass per `period`
27//! bars is O(1) amortised, and the window has fully turned over since the
28//! previous refresh, so no rounding error ever survives a whole window.
29//!
30//! # Determinism / batch-streaming parity
31//!
32//! The refresh cadence is driven purely by a push counter, i.e. by bar index —
33//! never by data values, timers, or window content. Batch and streaming both
34//! drive this same accumulator through the same `Next` implementations, so the
35//! refresh lands on the same bars in both paths and the outputs stay
36//! bit-identical. There is no non-deterministic "recompute when error looks
37//! large" heuristic here, deliberately.
38//!
39//! # Tradeoff
40//!
41//! Per-bar cost is O(1) amortised but not O(1) worst case: one bar in every
42//! `period` does an O(period) refresh. A truly O(1)-worst-case removal-based
43//! rolling variance cannot bound drift indefinitely, so bounded periodic
44//! recomputation is the intentional price of correctness.
45
46use crate::utils::RingBuffer;
47
48/// Sliding-window accumulator for population mean/variance.
49#[derive(Debug, Clone)]
50pub struct RollingVariance {
51 period: usize,
52 buf: RingBuffer<f64>,
53 /// Shift reference; always a value near the current window level.
54 origin: f64,
55 /// `Σ(x - origin)` over the window.
56 sum_d: f64,
57 /// `Σ(x - origin)²` over the window.
58 sum_d2: f64,
59 /// Pushes since the last exact refresh.
60 since_refresh: usize,
61}
62
63impl RollingVariance {
64 pub fn new(period: usize) -> Self {
65 Self {
66 period,
67 buf: RingBuffer::with_capacity(period.max(1)),
68 origin: 0.0,
69 sum_d: 0.0,
70 sum_d2: 0.0,
71 since_refresh: 0,
72 }
73 }
74
75 /// Number of samples currently in the window.
76 #[inline]
77 pub fn len(&self) -> usize {
78 self.buf.len()
79 }
80
81 #[inline]
82 pub fn is_empty(&self) -> bool {
83 self.buf.is_empty()
84 }
85
86 /// Window contents, oldest first.
87 pub fn values(&self) -> Vec<f64> {
88 self.buf.iter().cloned().collect()
89 }
90
91 #[inline]
92 pub fn iter(&self) -> impl Iterator<Item = &f64> {
93 self.buf.iter()
94 }
95
96 /// Push a sample, evicting the oldest once the window is full.
97 ///
98 /// Returns `true` once the window holds a full `period` samples.
99 pub fn push(&mut self, v: f64) -> bool {
100 if self.period == 0 {
101 return false;
102 }
103 if self.buf.len() >= self.period
104 && let Some(old) = self.buf.pop_front()
105 {
106 let d = old - self.origin;
107 self.sum_d -= d;
108 self.sum_d2 -= d * d;
109 }
110 self.buf.push_back(v);
111 let d = v - self.origin;
112 self.sum_d += d;
113 self.sum_d2 += d * d;
114
115 self.since_refresh += 1;
116 // Deterministic, index-driven refresh:
117 // * during warmup (partial window) refresh every bar — that is at most
118 // `period` O(period) passes in total, once, at the start of the
119 // stream, and it keeps warmup output exactly two-pass. Without it
120 // the origin would still be its initial 0.0 and the warmup bars
121 // would carry the very cancellation this type exists to remove.
122 // * once full, refresh exactly once per full window turnover.
123 if self.buf.len() < self.period || self.since_refresh >= self.period {
124 self.refresh();
125 }
126
127 self.buf.len() >= self.period
128 }
129
130 /// Recompute `origin`/`sum_d`/`sum_d2` exactly from the window.
131 ///
132 /// `origin` becomes the window mean, which makes `sum_d ≈ 0` and leaves
133 /// `sum_d2` as a pure sum of squared deviations — the two-pass form.
134 fn refresh(&mut self) {
135 self.since_refresh = 0;
136 let n = self.buf.len();
137 if n == 0 {
138 self.origin = 0.0;
139 self.sum_d = 0.0;
140 self.sum_d2 = 0.0;
141 return;
142 }
143 let mut total = 0.0;
144 for &v in self.buf.iter() {
145 total += v;
146 }
147 let k = total / n as f64;
148 let mut sum_d = 0.0;
149 let mut sum_d2 = 0.0;
150 for &v in self.buf.iter() {
151 let d = v - k;
152 sum_d += d;
153 sum_d2 += d * d;
154 }
155 self.origin = k;
156 self.sum_d = sum_d;
157 self.sum_d2 = sum_d2;
158 }
159
160 /// Sum of the window's raw values.
161 ///
162 /// Reconstructed from the shifted accumulator, so it carries the same
163 /// (small) error as the accumulator itself rather than an independently
164 /// drifting `Σx`.
165 #[inline]
166 pub fn sum(&self) -> f64 {
167 self.sum_d + self.origin * self.buf.len() as f64
168 }
169
170 /// Window mean (population), or `NaN` for an empty window.
171 #[inline]
172 pub fn mean(&self) -> f64 {
173 let n = self.buf.len();
174 if n == 0 {
175 return f64::NAN;
176 }
177 self.origin + self.sum_d / n as f64
178 }
179
180 /// Population variance over the current window, clamped at zero.
181 #[inline]
182 pub fn variance(&self) -> f64 {
183 let n = self.buf.len();
184 if n == 0 {
185 return f64::NAN;
186 }
187 let n_f = n as f64;
188 // Σ(x-µ)² = Σd² - (Σd)²/n, with d = x - origin and origin ≈ µ.
189 ((self.sum_d2 - self.sum_d * self.sum_d / n_f) / n_f).max(0.0)
190 }
191
192 /// Population standard deviation over the current window.
193 #[inline]
194 pub fn stddev(&self) -> f64 {
195 self.variance().sqrt()
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::test_utils::{log_price_series, reference_stddev};
203
204 /// The historical one-pass `E[X²] - E[X]²` accumulator, kept here purely so
205 /// the regression test can demonstrate it failing the gate the new
206 /// accumulator passes.
207 fn one_pass_stddev(w: &[f64]) -> f64 {
208 let n = w.len() as f64;
209 let sum: f64 = w.iter().sum();
210 let sum_sq: f64 = w.iter().map(|&x| x * x).sum();
211 let mean = sum / n;
212 (sum_sq / n - mean * mean).max(0.0).sqrt()
213 }
214
215 #[test]
216 fn stable_on_log_price_where_one_pass_fails() {
217 let period = 20;
218 let data = log_price_series(600);
219
220 let mut acc = RollingVariance::new(period);
221 let mut worst_new: f64 = 0.0;
222 let mut worst_old: f64 = 0.0;
223
224 for (i, &x) in data.iter().enumerate() {
225 let full = acc.push(x);
226 if !full {
227 continue;
228 }
229 let window = &data[i + 1 - period..=i];
230 let reference = reference_stddev(window);
231 assert!(reference > 0.0);
232
233 let rel_new = ((acc.stddev() - reference) / reference).abs();
234 let rel_old = ((one_pass_stddev(window) - reference) / reference).abs();
235 worst_new = worst_new.max(rel_new);
236 worst_old = worst_old.max(rel_old);
237 }
238
239 println!("worst rel err: one-pass {worst_old:e}, stable {worst_new:e}");
240 // The bug: the old accumulator blows through the strict 1e-7 gate.
241 assert!(
242 worst_old > 1e-7,
243 "one-pass should fail the 1e-7 gate on log-price, got {worst_old:e}"
244 );
245 // The fix: the new accumulator is at reference precision.
246 assert!(
247 worst_new < 1e-12,
248 "stable accumulator rel err {worst_new:e} (one-pass was {worst_old:e})"
249 );
250 }
251
252 /// The accumulator must not accumulate drift over long streams: every
253 /// value it emits stays at reference precision no matter how many bars
254 /// have gone by.
255 #[test]
256 fn no_drift_over_long_stream() {
257 let period = 30;
258 let data = log_price_series(50_000);
259 let mut acc = RollingVariance::new(period);
260 let mut worst: f64 = 0.0;
261 for (i, &x) in data.iter().enumerate() {
262 if !acc.push(x) {
263 continue;
264 }
265 if i % 97 != 0 {
266 continue;
267 }
268 let reference = reference_stddev(&data[i + 1 - period..=i]);
269 if reference > 0.0 {
270 worst = worst.max(((acc.stddev() - reference) / reference).abs());
271 }
272 }
273 assert!(worst < 1e-12, "drift {worst:e} after 50k bars");
274 }
275
276 /// Raw prices must not regress either.
277 #[test]
278 fn matches_reference_on_raw_prices() {
279 let period = 14;
280 let data: Vec<f64> = (0..500)
281 .map(|i| 2980.0 + (i as f64 * 0.31).sin() * 40.0)
282 .collect();
283 let mut acc = RollingVariance::new(period);
284 for (i, &x) in data.iter().enumerate() {
285 if !acc.push(x) {
286 continue;
287 }
288 let reference = reference_stddev(&data[i + 1 - period..=i]);
289 approx::assert_relative_eq!(acc.stddev(), reference, max_relative = 1e-12);
290 }
291 }
292
293 #[test]
294 fn constant_window_is_exactly_zero() {
295 let mut acc = RollingVariance::new(10);
296 for _ in 0..50 {
297 acc.push(1234.5678);
298 }
299 assert_eq!(acc.stddev(), 0.0);
300 assert_eq!(acc.variance(), 0.0);
301 }
302
303 #[test]
304 fn sum_and_mean_track_the_window() {
305 let mut acc = RollingVariance::new(4);
306 for x in [1.0, 2.0, 3.0, 4.0, 5.0] {
307 acc.push(x);
308 }
309 // window is [2,3,4,5]
310 approx::assert_relative_eq!(acc.sum(), 14.0, max_relative = 1e-12);
311 approx::assert_relative_eq!(acc.mean(), 3.5, max_relative = 1e-12);
312 assert_eq!(acc.len(), 4);
313 assert_eq!(acc.values(), vec![2.0, 3.0, 4.0, 5.0]);
314 }
315
316 /// The refresh is index-driven, so two accumulators fed the same bars in
317 /// the same order agree bit-for-bit — the property batch/streaming parity
318 /// rests on.
319 #[test]
320 fn refresh_cadence_is_deterministic() {
321 let data = log_price_series(1_000);
322 let mut a = RollingVariance::new(20);
323 let mut b = RollingVariance::new(20);
324 for &x in &data {
325 a.push(x);
326 b.push(x);
327 assert_eq!(a.stddev().to_bits(), b.stddev().to_bits());
328 }
329 }
330}