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