Skip to main content

wickra_core/indicators/
pearson_correlation.rs

1//! Rolling Pearson correlation between two synchronised 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 Pearson correlation between two synchronised series.
10///
11/// Each `update` receives one `(x, y)` pair (e.g. the latest close of the
12/// asset and of the benchmark). Over the trailing window of `period`
13/// pairs:
14///
15/// ```text
16/// cov_xy   = (1/n) · Σ x·y − x̄·ȳ
17/// var_x    = (1/n) · Σ x² − x̄²
18/// var_y    = (1/n) · Σ y² − ȳ²
19/// Pearson  = cov_xy / √(var_x · var_y)
20/// ```
21///
22/// Output is in `[−1, +1]`. `+1` means a perfect positive linear
23/// relationship; `−1` is a perfect inverse one; `0` means no linear
24/// relationship. It is the same statistic `SciPy` / `NumPy` report as
25/// `pearsonr` and the standardised relative of [`crate::Beta`] — Beta
26/// scales Pearson by the ratio of standard deviations.
27///
28/// Each `update` is O(1): five running sums (`Σx`, `Σy`, `Σx²`, `Σy²`,
29/// `Σxy`) are maintained as the window slides. A flat series in either
30/// channel gives an undefined ratio; the indicator returns `0` in that
31/// case rather than producing `NaN`. The output is clamped to `[−1, +1]`
32/// to absorb tiny floating-point overshoots near the boundaries.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, PearsonCorrelation};
38///
39/// let mut indicator = PearsonCorrelation::new(20).unwrap();
40/// let mut last = None;
41/// for i in 0..40 {
42///     last = indicator.update((f64::from(i), 2.0 * f64::from(i) + 1.0));
43/// }
44/// // A perfectly linear pair → +1.
45/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
46/// ```
47#[derive(Debug, Clone)]
48pub struct PearsonCorrelation {
49    period: usize,
50    window: VecDeque<(f64, f64)>,
51    moments: ShiftedPairMoments,
52}
53
54impl PearsonCorrelation {
55    /// Construct a new rolling Pearson correlation.
56    ///
57    /// # Errors
58    /// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is
59    /// undefined for fewer than two pairs.
60    pub fn new(period: usize) -> Result<Self> {
61        if period < 2 {
62            return Err(Error::InvalidPeriod {
63                message: "pearson 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            window: VecDeque::with_capacity(period),
74            moments: ShiftedPairMoments::new(),
75        })
76    }
77
78    /// Configured period.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82}
83
84impl Indicator for PearsonCorrelation {
85    type Input = (f64, f64);
86    type Output = f64;
87
88    #[inline]
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        if self.window.len() == self.period {
95            let (ox, oy) = self.window.pop_front().expect("non-empty");
96            self.moments.evict(ox, oy);
97        }
98        self.window.push_back((x, y));
99        self.moments.push(x, y);
100        if self.moments.needs_reseed(self.period) {
101            self.moments.reseed(self.window.iter().copied());
102        }
103        if self.window.len() < self.period {
104            return None;
105        }
106        let var_x = self.moments.var_a(self.period);
107        let var_y = self.moments.var_b(self.period);
108        let cov = self.moments.cov(self.period);
109        let denom = (var_x * var_y).sqrt();
110        if denom == 0.0 {
111            // At least one channel is flat: correlation is undefined.
112            return Some(0.0);
113        }
114        Some((cov / denom).clamp(-1.0, 1.0))
115    }
116
117    fn reset(&mut self) {
118        self.window.clear();
119        self.moments.reset();
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        self.period
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        "PearsonCorrelation"
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!(PearsonCorrelation::new(0).is_err());
147        assert!(PearsonCorrelation::new(1).is_err());
148        assert!(PearsonCorrelation::new(2).is_ok());
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let p = PearsonCorrelation::new(14).unwrap();
154        assert_eq!(p.period(), 14);
155        assert_eq!(p.warmup_period(), 14);
156        assert_eq!(p.name(), "PearsonCorrelation");
157    }
158
159    #[test]
160    fn perfect_positive_is_one() {
161        let pairs: Vec<(f64, f64)> = (0..10)
162            .map(|i| (f64::from(i), 3.0 * f64::from(i) + 1.0))
163            .collect();
164        let last = PearsonCorrelation::new(5)
165            .unwrap()
166            .batch(&pairs)
167            .into_iter()
168            .flatten()
169            .last()
170            .unwrap();
171        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
172    }
173
174    #[test]
175    fn perfect_negative_is_minus_one() {
176        let pairs: Vec<(f64, f64)> = (0..10)
177            .map(|i| (f64::from(i), -2.0 * f64::from(i) + 5.0))
178            .collect();
179        let last = PearsonCorrelation::new(5)
180            .unwrap()
181            .batch(&pairs)
182            .into_iter()
183            .flatten()
184            .last()
185            .unwrap();
186        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
187    }
188
189    #[test]
190    fn constant_channel_yields_zero() {
191        let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
192        let last = PearsonCorrelation::new(5)
193            .unwrap()
194            .batch(&pairs)
195            .into_iter()
196            .flatten()
197            .last()
198            .unwrap();
199        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn output_in_minus_one_to_one_range() {
204        let pairs: Vec<(f64, f64)> = (0..60)
205            .map(|i| {
206                let t = f64::from(i);
207                (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
208            })
209            .collect();
210        let mut p = PearsonCorrelation::new(20).unwrap();
211        for v in p.batch(&pairs).into_iter().flatten() {
212            assert!((-1.0..=1.0).contains(&v));
213        }
214    }
215
216    #[test]
217    fn reset_clears_state() {
218        let mut p = PearsonCorrelation::new(5).unwrap();
219        p.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
220        assert!(p.is_ready());
221        p.reset();
222        assert!(!p.is_ready());
223        assert_eq!(p.update((1.0, 1.0)), None);
224    }
225
226    #[test]
227    fn batch_equals_streaming() {
228        let pairs: Vec<(f64, f64)> = (0..60)
229            .map(|i| {
230                let t = f64::from(i);
231                (t.sin(), (t * 0.5).cos())
232            })
233            .collect();
234        let batch = PearsonCorrelation::new(14).unwrap().batch(&pairs);
235        let mut b = PearsonCorrelation::new(14).unwrap();
236        let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
237        assert_eq!(batch, streamed);
238    }
239
240    #[test]
241    fn non_finite_input_returns_none() {
242        let mut p = PearsonCorrelation::new(3).unwrap();
243        assert_eq!(p.update((f64::NAN, 1.0)), None);
244        assert_eq!(p.update((1.0, f64::INFINITY)), None);
245        // The rejected ticks leave no trace: a fresh window still warms up.
246        assert_eq!(p.update((1.0, 2.0)), None);
247        assert_eq!(p.update((2.0, 5.0)), None);
248        assert!(p.update((3.0, 7.0)).is_some());
249    }
250}