Skip to main content

wickra_core/indicators/
pearson_correlation.rs

1//! Rolling Pearson correlation between two synchronised series.
2
3use crate::error::{Error, Result};
4use crate::indicators::rolling_moments::ShiftedPairMoments;
5use crate::traits::Indicator;
6
7/// Rolling Pearson correlation between two synchronised series.
8///
9/// Each `update` receives one `(x, y)` pair (e.g. the latest close of the
10/// asset and of the benchmark). Over the trailing window of `period`
11/// pairs:
12///
13/// ```text
14/// cov_xy   = (1/n) · Σ x·y − x̄·ȳ
15/// var_x    = (1/n) · Σ x² − x̄²
16/// var_y    = (1/n) · Σ y² − ȳ²
17/// Pearson  = cov_xy / √(var_x · var_y)
18/// ```
19///
20/// Output is in `[−1, +1]`. `+1` means a perfect positive linear
21/// relationship; `−1` is a perfect inverse one; `0` means no linear
22/// relationship. It is the same statistic `SciPy` / `NumPy` report as
23/// `pearsonr` and the standardised relative of [`crate::Beta`] — Beta
24/// scales Pearson by the ratio of standard deviations.
25///
26/// Each `update` is O(1): five running sums (`Σx`, `Σy`, `Σx²`, `Σy²`,
27/// `Σxy`) are maintained as the window slides. A flat series in either
28/// channel gives an undefined ratio; the indicator returns `0` in that
29/// case rather than producing `NaN`. The output is clamped to `[−1, +1]`
30/// to absorb tiny floating-point overshoots near the boundaries.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, PearsonCorrelation};
36///
37/// let mut indicator = PearsonCorrelation::new(20).unwrap();
38/// let mut last = None;
39/// for i in 0..40 {
40///     last = indicator.update((f64::from(i), 2.0 * f64::from(i) + 1.0));
41/// }
42/// // A perfectly linear pair → +1.
43/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
44/// ```
45#[derive(Debug, Clone)]
46pub struct PearsonCorrelation {
47    period: usize,
48    /// Ring buffer of the last `period` pairs; `head` is the next slot to write
49    /// and, once full, the oldest pair.
50    buf: Box<[(f64, f64)]>,
51    head: usize,
52    /// Pairs held, saturating at `period`.
53    count: usize,
54    moments: ShiftedPairMoments,
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        if period > crate::error::MAX_PERIOD {
70            return Err(Error::InvalidPeriod {
71                message: crate::error::PERIOD_ABOVE_MAX,
72            });
73        }
74        Ok(Self {
75            period,
76            buf: vec![(0.0, 0.0); period].into_boxed_slice(),
77            head: 0,
78            count: 0,
79            moments: ShiftedPairMoments::new(),
80        })
81    }
82
83    /// Configured period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87}
88
89impl PearsonCorrelation {
90    /// Exact batch over two columns: one output per pair (`NaN` during warmup),
91    /// bit for bit what replaying `update` gives, written into `out`.
92    ///
93    /// # Panics
94    ///
95    /// Panics if `a`, `b` and `out` differ in length.
96    pub fn batch_pairs_into(&mut self, a: &[f64], b: &[f64], out: &mut [f64]) {
97        assert!(
98            a.len() == b.len() && out.len() == a.len(),
99            "both series and the output must be equal length"
100        );
101        for ((slot, &x), &y) in out.iter_mut().zip(a).zip(b) {
102            *slot = self.update((x, y)).unwrap_or(f64::NAN);
103        }
104    }
105
106    /// Opt-in fast variant of [`batch_pairs_into`](Self::batch_pairs_into):
107    /// the shifted sums of `a`, `b`, `a²`, `b²` and `a·b` run as SIMD
108    /// prefix scans, re-centred every window like the exact accumulator, and
109    /// the correlation is finished lane-parallel. Every value
110    /// agrees with the exact batch to within a few units in the last place;
111    /// warmup `NaN`s and length are identical, and the result is the same on
112    /// every platform. Only a fresh indicator over finite values within
113    /// `1e100`, at least one window long, takes the kernel; anything else is
114    /// the exact batch. The correlation only remembers its last `period` pairs,
115    /// so afterwards the state is rebuilt exactly by replaying them.
116    ///
117    /// # Panics
118    ///
119    /// Panics if `a`, `b` and `out` differ in length.
120    pub fn batch_pairs_fast_into(&mut self, a: &[f64], b: &[f64], out: &mut [f64]) {
121        assert!(
122            a.len() == b.len() && out.len() == a.len(),
123            "both series and the output must be equal length"
124        );
125        let p = self.period;
126        let n = a.len();
127        if self.count != 0 || n < p || !crate::fast::in_range(a) || !crate::fast::in_range(b) {
128            self.batch_pairs_into(a, b, out);
129            return;
130        }
131        crate::fast::with_scratch(crate::fast::power_scratch_len(5, p), |scratch| {
132            wickra_simd::dispatch(crate::fast::PearsonFast {
133                a,
134                b,
135                period: p,
136                scratch,
137                out,
138                _borrow: std::marker::PhantomData,
139            });
140        });
141        self.reset();
142        for (&x, &y) in a[n - p..].iter().zip(&b[n - p..]) {
143            let _ = self.update((x, y));
144        }
145    }
146}
147
148impl Indicator for PearsonCorrelation {
149    type Input = (f64, f64);
150    type Output = f64;
151
152    #[inline]
153    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
154        let (x, y) = input;
155        if !x.is_finite() || !y.is_finite() {
156            return None;
157        }
158        // One indexed slot for both the evicted pair and the new one.
159        let slot = &mut self.buf[self.head];
160        if self.count == self.period {
161            let (ox, oy) = std::mem::replace(slot, (x, y));
162            self.moments.evict(ox, oy);
163        } else {
164            *slot = (x, y);
165            self.count += 1;
166        }
167        self.head += 1;
168        if self.head == self.period {
169            self.head = 0;
170        }
171        self.moments.push(x, y);
172        if self.moments.needs_reseed(self.period) {
173            // Chronological order, oldest at `head`. A reseed is due every
174            // `period` pushes and the first `period` pushes fill the window,
175            // so the window is always full here.
176            debug_assert_eq!(self.count, self.period);
177            let (newer, older) = self.buf.split_at(self.head);
178            self.moments.reseed(older.iter().chain(newer).copied());
179        }
180        if self.count < self.period {
181            return None;
182        }
183        let var_x = self.moments.var_a(self.period);
184        let var_y = self.moments.var_b(self.period);
185        let cov = self.moments.cov(self.period);
186        let denom = (var_x * var_y).sqrt();
187        if denom == 0.0 {
188            // At least one channel is flat: correlation is undefined.
189            return Some(0.0);
190        }
191        Some((cov / denom).clamp(-1.0, 1.0))
192    }
193
194    fn reset(&mut self) {
195        self.head = 0;
196        self.count = 0;
197        self.moments.reset();
198    }
199
200    #[inline]
201    fn warmup_period(&self) -> usize {
202        self.period
203    }
204
205    #[inline]
206    fn is_ready(&self) -> bool {
207        self.count == self.period
208    }
209
210    #[inline]
211    fn name(&self) -> &'static str {
212        "PearsonCorrelation"
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::traits::BatchExt;
220    use approx::assert_relative_eq;
221
222    #[test]
223    fn rejects_period_below_two() {
224        assert!(PearsonCorrelation::new(0).is_err());
225        assert!(PearsonCorrelation::new(1).is_err());
226        assert!(PearsonCorrelation::new(2).is_ok());
227    }
228
229    #[test]
230    fn accessors_and_metadata() {
231        let p = PearsonCorrelation::new(14).unwrap();
232        assert_eq!(p.period(), 14);
233        assert_eq!(p.warmup_period(), 14);
234        assert_eq!(p.name(), "PearsonCorrelation");
235    }
236
237    #[test]
238    fn perfect_positive_is_one() {
239        let pairs: Vec<(f64, f64)> = (0..10)
240            .map(|i| (f64::from(i), 3.0 * f64::from(i) + 1.0))
241            .collect();
242        let last = PearsonCorrelation::new(5)
243            .unwrap()
244            .batch(&pairs)
245            .into_iter()
246            .flatten()
247            .last()
248            .unwrap();
249        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
250    }
251
252    #[test]
253    fn perfect_negative_is_minus_one() {
254        let pairs: Vec<(f64, f64)> = (0..10)
255            .map(|i| (f64::from(i), -2.0 * f64::from(i) + 5.0))
256            .collect();
257        let last = PearsonCorrelation::new(5)
258            .unwrap()
259            .batch(&pairs)
260            .into_iter()
261            .flatten()
262            .last()
263            .unwrap();
264        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
265    }
266
267    #[test]
268    fn constant_channel_yields_zero() {
269        let pairs: Vec<(f64, f64)> = (0..10).map(|i| (f64::from(i), 7.0)).collect();
270        let last = PearsonCorrelation::new(5)
271            .unwrap()
272            .batch(&pairs)
273            .into_iter()
274            .flatten()
275            .last()
276            .unwrap();
277        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
278    }
279
280    #[test]
281    fn output_in_minus_one_to_one_range() {
282        let pairs: Vec<(f64, f64)> = (0..60)
283            .map(|i| {
284                let t = f64::from(i);
285                (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
286            })
287            .collect();
288        let mut p = PearsonCorrelation::new(20).unwrap();
289        for v in p.batch(&pairs).into_iter().flatten() {
290            assert!((-1.0..=1.0).contains(&v));
291        }
292    }
293
294    #[test]
295    fn reset_clears_state() {
296        let mut p = PearsonCorrelation::new(5).unwrap();
297        p.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
298        assert!(p.is_ready());
299        p.reset();
300        assert!(!p.is_ready());
301        assert_eq!(p.update((1.0, 1.0)), None);
302    }
303
304    #[test]
305    fn batch_equals_streaming() {
306        let pairs: Vec<(f64, f64)> = (0..60)
307            .map(|i| {
308                let t = f64::from(i);
309                (t.sin(), (t * 0.5).cos())
310            })
311            .collect();
312        let batch = PearsonCorrelation::new(14).unwrap().batch(&pairs);
313        let mut b = PearsonCorrelation::new(14).unwrap();
314        let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
315        assert_eq!(batch, streamed);
316    }
317
318    #[test]
319    fn non_finite_input_returns_none() {
320        let mut p = PearsonCorrelation::new(3).unwrap();
321        assert_eq!(p.update((f64::NAN, 1.0)), None);
322        assert_eq!(p.update((1.0, f64::INFINITY)), None);
323        // The rejected ticks leave no trace: a fresh window still warms up.
324        assert_eq!(p.update((1.0, 2.0)), None);
325        assert_eq!(p.update((2.0, 5.0)), None);
326        assert!(p.update((3.0, 7.0)).is_some());
327    }
328
329    #[test]
330    fn rejects_period_above_maximum() {
331        assert!(matches!(
332            PearsonCorrelation::new(crate::error::MAX_PERIOD + 1),
333            Err(Error::InvalidPeriod { .. })
334        ));
335    }
336}