Skip to main content

wickra_core/indicators/
rolling_covariance.rs

1//! Rolling covariance of the period-over-period *returns* of two series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedPairMoments;
7use crate::traits::Indicator;
8
9/// Rolling covariance of the **returns** of two synchronised series.
10///
11/// Each `update` takes one `(x, y)` level pair, differences each channel into a
12/// one-step return, and reports the population covariance of those returns over
13/// the trailing window of `period` return pairs:
14///
15/// ```text
16/// rxₜ = xₜ − xₜ₋₁          ryₜ = yₜ − yₜ₋₁
17/// cov = (1/n) · Σ rx·ry − r̄x · r̄y
18/// ```
19///
20/// Unlike [`crate::RollingCorrelation`] the result is **not** normalised to
21/// `[−1, 1]`: it carries the units of the two return streams multiplied
22/// together, so it scales with volatility. It is the raw building block behind
23/// correlation, beta and portfolio variance — positive when the two return
24/// streams tend to move the same way, negative when they offset.
25///
26/// Each `update` is O(1): three running sums (`Σrx`, `Σry`, `Σrxry`) are
27/// maintained as the window slides. The first level in each channel produces no
28/// return, so a `period`-pair covariance needs `period + 1` updates of warmup.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, RollingCovariance};
34///
35/// let mut rc = RollingCovariance::new(5).unwrap();
36/// let mut last = None;
37/// for i in 0..20 {
38///     let x = f64::from(i);
39///     last = rc.update((x, 3.0 * x)); // y's return is 3× x's return
40/// }
41/// // cov(rx, ry) = cov(1, 3) over constant unit returns = 3 · var(rx) = 0
42/// // for a constant return; use a varying path in practice. Here returns are
43/// // constant (1 and 3) ⇒ covariance 0.
44/// assert!(last.unwrap().abs() < 1e-9);
45/// ```
46#[derive(Debug, Clone)]
47pub struct RollingCovariance {
48    period: usize,
49    prev: Option<(f64, f64)>,
50    window: VecDeque<(f64, f64)>,
51    moments: ShiftedPairMoments,
52}
53
54impl RollingCovariance {
55    /// Construct a new rolling return-covariance.
56    ///
57    /// # Errors
58    /// Returns [`Error::InvalidPeriod`] if `period < 2` — covariance is
59    /// undefined for fewer than two return pairs.
60    pub fn new(period: usize) -> Result<Self> {
61        if period < 2 {
62            return Err(Error::InvalidPeriod {
63                message: "rolling covariance needs period >= 2",
64            });
65        }
66        if period > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            period,
73            prev: None,
74            window: VecDeque::with_capacity(period),
75            moments: ShiftedPairMoments::new(),
76        })
77    }
78
79    /// Configured window of returns.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83}
84
85impl Indicator for RollingCovariance {
86    type Input = (f64, f64);
87    type Output = f64;
88
89    #[inline]
90    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
91        let (x, y) = input;
92        if !x.is_finite() || !y.is_finite() {
93            return None;
94        }
95        let Some((px, py)) = self.prev else {
96            self.prev = Some((x, y));
97            return None;
98        };
99        self.prev = Some((x, y));
100        let (rx, ry) = (x - px, y - py);
101        if self.window.len() == self.period {
102            let (ox, oy) = self.window.pop_front().expect("non-empty");
103            self.moments.evict(ox, oy);
104        }
105        self.window.push_back((rx, ry));
106        self.moments.push(rx, ry);
107        if self.moments.needs_reseed(self.period) {
108            self.moments.reseed(self.window.iter().copied());
109        }
110        if self.window.len() < self.period {
111            return None;
112        }
113        Some(self.moments.cov(self.period))
114    }
115
116    fn reset(&mut self) {
117        self.prev = None;
118        self.window.clear();
119        self.moments.reset();
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        self.period + 1
125    }
126
127    #[inline]
128    fn is_ready(&self) -> bool {
129        self.window.len() == self.period
130    }
131
132    #[inline]
133    fn name(&self) -> &'static str {
134        "RollingCovariance"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    #[test]
145    fn rejects_period_below_two() {
146        assert!(RollingCovariance::new(0).is_err());
147        assert!(RollingCovariance::new(1).is_err());
148        assert!(RollingCovariance::new(2).is_ok());
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let rc = RollingCovariance::new(14).unwrap();
154        assert_eq!(rc.period(), 14);
155        assert_eq!(rc.warmup_period(), 15);
156        assert_eq!(rc.name(), "RollingCovariance");
157        assert!(!rc.is_ready());
158    }
159
160    #[test]
161    fn warmup_needs_period_plus_one() {
162        let mut rc = RollingCovariance::new(3).unwrap();
163        assert_eq!(rc.update((1.0, 1.0)), None);
164        assert_eq!(rc.update((2.0, 3.0)), None);
165        assert_eq!(rc.update((3.0, 5.0)), None);
166        assert!(rc.update((4.0, 7.0)).is_some());
167        assert!(rc.is_ready());
168    }
169
170    #[test]
171    fn hand_computed_value() {
172        // Levels x = 0,1,3,6,10 ⇒ returns 1,2,3,4; y = 2x ⇒ returns 2,4,6,8.
173        // With period = 3 the final window is rx = [2,3,4], ry = [4,6,8]:
174        //   Σrx·ry/3 = 58/3, r̄x·r̄y = 3·6 = 18 ⇒ cov = 58/3 − 18 = 4/3.
175        let pairs = [
176            (0.0, 0.0),
177            (1.0, 2.0),
178            (3.0, 6.0),
179            (6.0, 12.0),
180            (10.0, 20.0),
181        ];
182        let last = RollingCovariance::new(3)
183            .unwrap()
184            .batch(&pairs)
185            .into_iter()
186            .flatten()
187            .last()
188            .unwrap();
189        assert_relative_eq!(last, 4.0 / 3.0, epsilon = 1e-9);
190    }
191
192    #[test]
193    fn opposing_returns_give_negative_covariance() {
194        let pairs: Vec<(f64, f64)> = (0..30)
195            .map(|i| {
196                let x = (f64::from(i) * 0.4).sin() * 10.0;
197                (x, -x)
198            })
199            .collect();
200        let last = RollingCovariance::new(10)
201            .unwrap()
202            .batch(&pairs)
203            .into_iter()
204            .flatten()
205            .last()
206            .unwrap();
207        assert!(last < 0.0, "cov {last}");
208    }
209
210    #[test]
211    fn flat_channel_gives_zero() {
212        let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
213        let last = RollingCovariance::new(6)
214            .unwrap()
215            .batch(&pairs)
216            .into_iter()
217            .flatten()
218            .last()
219            .unwrap();
220        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut rc = RollingCovariance::new(4).unwrap();
226        rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 1.0), (4.0, 9.0), (5.0, 2.0)]);
227        assert!(rc.is_ready());
228        rc.reset();
229        assert!(!rc.is_ready());
230        assert_eq!(rc.update((1.0, 1.0)), None);
231    }
232
233    #[test]
234    fn batch_equals_streaming() {
235        let pairs: Vec<(f64, f64)> = (0..60)
236            .map(|i| {
237                let t = f64::from(i);
238                (t.sin() * 4.0, (t * 0.5).cos() * 2.0)
239            })
240            .collect();
241        let batch = RollingCovariance::new(12).unwrap().batch(&pairs);
242        let mut rc = RollingCovariance::new(12).unwrap();
243        let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
244        assert_eq!(batch, streamed);
245    }
246
247    #[test]
248    fn non_finite_input_returns_none() {
249        let mut rc = RollingCovariance::new(2).unwrap();
250        assert_eq!(rc.update((f64::NAN, 1.0)), None);
251        assert_eq!(rc.update((1.0, f64::INFINITY)), None);
252        // First finite tick seeds prev; two more returns fill the window.
253        assert_eq!(rc.update((1.0, 1.0)), None);
254        assert_eq!(rc.update((2.0, 3.0)), None);
255        assert!(rc.update((3.0, 5.0)).is_some());
256    }
257
258    /// The covariance was accumulated as `E[xy] - E[x]E[y]` over running sums
259    /// that were never rebuilt, so it carried both the cancellation of the
260    /// one-pass form and unbounded drift. A centred accumulator with a periodic
261    /// rebuild takes 2000 updates from 1.5e-11 to 3.3e-12 against a two-pass
262    /// reference, and bounds where that residual can go.
263    #[test]
264    fn matches_a_two_pass_reference_over_a_long_stream() {
265        const PERIOD: usize = 20;
266        let series: Vec<(f64, f64)> = (0..2000)
267            .map(|i| {
268                let t = f64::from(i);
269                (
270                    1e5 * (1.0 + 0.05 * (t * 0.11).sin()),
271                    1e5 * (1.0 + 0.04 * (t * 0.07).cos()),
272                )
273            })
274            .collect();
275
276        let mut ind = RollingCovariance::new(PERIOD).unwrap();
277        let (mut dx, mut dy): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
278        let mut prev: Option<(f64, f64)> = None;
279        let mut compared = 0_usize;
280        for &(x, y) in &series {
281            let got = ind.update((x, y));
282            if let Some((px, py)) = prev {
283                dx.push(x - px);
284                dy.push(y - py);
285            }
286            prev = Some((x, y));
287            let Some(cov) = got else { continue };
288            let k = dx.len();
289            let (xs, ys) = (&dx[k - PERIOD..], &dy[k - PERIOD..]);
290            let n = PERIOD as f64;
291            let mean_x = xs.iter().sum::<f64>() / n;
292            let mean_y = ys.iter().sum::<f64>() / n;
293            let want = xs
294                .iter()
295                .zip(ys)
296                .map(|(u, v)| (u - mean_x) * (v - mean_y))
297                .sum::<f64>()
298                / n;
299            compared += 1;
300            assert_relative_eq!(cov, want, max_relative = 1e-10);
301        }
302        assert_eq!(compared, series.len() - ind.warmup_period() + 1);
303    }
304}