Skip to main content

wickra_core/indicators/
rolling_correlation.rs

1//! Rolling Pearson correlation 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 correlation of the **returns** of two synchronised series.
10///
11/// Where [`crate::PearsonCorrelation`] correlates the raw *levels* `(x, y)`,
12/// this indicator first differences each channel into a one-step return and
13/// correlates those returns over the trailing window:
14///
15/// ```text
16/// rxₜ = xₜ − xₜ₋₁          ryₜ = yₜ − yₜ₋₁
17/// corr = cov(rx, ry) / √(var(rx) · var(ry))
18/// ```
19///
20/// Return correlation is the quantity that matters for hedging and portfolio
21/// risk: two assets can trend together (high level correlation) while their
22/// day-to-day moves are nearly independent (low return correlation). The output
23/// is in `[−1, +1]`; a flat return channel makes the ratio undefined and the
24/// indicator reports `0` rather than `NaN`. The value is clamped to `[−1, +1]`
25/// to absorb tiny floating-point overshoots near the boundaries.
26///
27/// Each `update` is O(1): the five running sums (`Σrx`, `Σry`, `Σrx²`, `Σry²`,
28/// `Σrxry`) are maintained as the window of returns slides. The first level in
29/// each channel produces no return, so a `period`-pair correlation needs
30/// `period + 1` updates of warmup.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, RollingCorrelation};
36///
37/// let mut rc = RollingCorrelation::new(10).unwrap();
38/// let mut last = None;
39/// for i in 0..40 {
40///     // A varying path where y always moves with x ⇒ return correlation +1.
41///     let x = (f64::from(i) * 0.5).sin() * 10.0;
42///     last = rc.update((x, 2.0 * x));
43/// }
44/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
45/// ```
46#[derive(Debug, Clone)]
47pub struct RollingCorrelation {
48    period: usize,
49    prev: Option<(f64, f64)>,
50    window: VecDeque<(f64, f64)>,
51    moments: ShiftedPairMoments,
52}
53
54impl RollingCorrelation {
55    /// Construct a new rolling return-correlation.
56    ///
57    /// # Errors
58    /// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation 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 correlation 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 RollingCorrelation {
86    type Input = (f64, f64);
87    type Output = f64;
88
89    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
90        let (x, y) = input;
91        if !x.is_finite() || !y.is_finite() {
92            return None;
93        }
94        let Some((px, py)) = self.prev else {
95            // First level in each channel: store it, no return yet.
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        let var_x = self.moments.var_a(self.period);
114        let var_y = self.moments.var_b(self.period);
115        let cov = self.moments.cov(self.period);
116        let denom = (var_x * var_y).sqrt();
117        if denom == 0.0 {
118            // At least one return channel is flat: correlation is undefined.
119            return Some(0.0);
120        }
121        Some((cov / denom).clamp(-1.0, 1.0))
122    }
123
124    fn reset(&mut self) {
125        self.prev = None;
126        self.window.clear();
127        self.moments.reset();
128    }
129
130    #[inline]
131    fn warmup_period(&self) -> usize {
132        self.period + 1
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.window.len() == self.period
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "RollingCorrelation"
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::traits::BatchExt;
150    use approx::assert_relative_eq;
151
152    #[test]
153    fn rejects_period_below_two() {
154        assert!(RollingCorrelation::new(0).is_err());
155        assert!(RollingCorrelation::new(1).is_err());
156        assert!(RollingCorrelation::new(2).is_ok());
157    }
158
159    #[test]
160    fn accessors_and_metadata() {
161        let rc = RollingCorrelation::new(14).unwrap();
162        assert_eq!(rc.period(), 14);
163        assert_eq!(rc.warmup_period(), 15);
164        assert_eq!(rc.name(), "RollingCorrelation");
165        assert!(!rc.is_ready());
166    }
167
168    #[test]
169    fn warmup_needs_period_plus_one() {
170        let mut rc = RollingCorrelation::new(3).unwrap();
171        // First update only seeds the previous level ⇒ None.
172        assert_eq!(rc.update((1.0, 1.0)), None);
173        assert_eq!(rc.update((2.0, 3.0)), None); // 1 return
174        assert_eq!(rc.update((3.0, 5.0)), None); // 2 returns
175        assert!(rc.update((4.0, 7.0)).is_some()); // 3 returns ⇒ ready
176        assert!(rc.is_ready());
177    }
178
179    #[test]
180    fn comoving_returns_are_plus_one() {
181        // y always moves by 2x x's move ⇒ perfectly correlated returns.
182        let pairs: Vec<(f64, f64)> = (0..20)
183            .map(|i| {
184                let x = (f64::from(i) * 0.5).sin() * 10.0;
185                (x, 2.0 * x + 100.0)
186            })
187            .collect();
188        let last = RollingCorrelation::new(8)
189            .unwrap()
190            .batch(&pairs)
191            .into_iter()
192            .flatten()
193            .last()
194            .unwrap();
195        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
196    }
197
198    #[test]
199    fn opposing_returns_are_minus_one() {
200        let pairs: Vec<(f64, f64)> = (0..20)
201            .map(|i| {
202                let x = (f64::from(i) * 0.5).sin() * 10.0;
203                (x, -1.5 * x + 50.0)
204            })
205            .collect();
206        let last = RollingCorrelation::new(8)
207            .unwrap()
208            .batch(&pairs)
209            .into_iter()
210            .flatten()
211            .last()
212            .unwrap();
213        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
214    }
215
216    #[test]
217    fn flat_return_channel_yields_zero() {
218        // y is constant ⇒ its returns are all zero ⇒ undefined ⇒ 0.
219        let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
220        let last = RollingCorrelation::new(6)
221            .unwrap()
222            .batch(&pairs)
223            .into_iter()
224            .flatten()
225            .last()
226            .unwrap();
227        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
228    }
229
230    #[test]
231    fn output_in_range() {
232        let pairs: Vec<(f64, f64)> = (0..80)
233            .map(|i| {
234                let t = f64::from(i);
235                (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
236            })
237            .collect();
238        let mut rc = RollingCorrelation::new(20).unwrap();
239        for v in rc.batch(&pairs).into_iter().flatten() {
240            assert!((-1.0..=1.0).contains(&v));
241        }
242    }
243
244    #[test]
245    fn reset_clears_state() {
246        let mut rc = RollingCorrelation::new(4).unwrap();
247        rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
248        assert!(rc.is_ready());
249        rc.reset();
250        assert!(!rc.is_ready());
251        assert_eq!(rc.update((1.0, 1.0)), None);
252    }
253
254    #[test]
255    fn batch_equals_streaming() {
256        let pairs: Vec<(f64, f64)> = (0..60)
257            .map(|i| {
258                let t = f64::from(i);
259                (t.sin(), (t * 0.5).cos())
260            })
261            .collect();
262        let batch = RollingCorrelation::new(14).unwrap().batch(&pairs);
263        let mut rc = RollingCorrelation::new(14).unwrap();
264        let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
265        assert_eq!(batch, streamed);
266    }
267
268    #[test]
269    fn non_finite_input_returns_none() {
270        let mut rc = RollingCorrelation::new(2).unwrap();
271        assert_eq!(rc.update((f64::NAN, 1.0)), None);
272        assert_eq!(rc.update((1.0, f64::INFINITY)), None);
273        // First finite tick seeds prev; two more returns fill the window.
274        assert_eq!(rc.update((1.0, 1.0)), None);
275        assert_eq!(rc.update((2.0, 3.0)), None);
276        assert!(rc.update((3.0, 5.0)).is_some());
277    }
278}