Skip to main content

wickra_core/indicators/
cointegration.rs

1//! Cointegration — rolling Engle–Granger hedge ratio plus an ADF stationarity test.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedPairMoments;
7use crate::traits::Indicator;
8
9/// Output of [`Cointegration`].
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct CointegrationOutput {
12    /// Engle–Granger hedge ratio `β`: the rolling OLS slope of `a` on `b`.
13    pub hedge_ratio: f64,
14    /// The current spread (regression residual) `a − (α + β·b)`.
15    pub spread: f64,
16    /// Augmented Dickey–Fuller `t`-statistic on the spread. **More negative**
17    /// means more strongly mean-reverting (cointegrated); compare against the
18    /// usual ADF/MacKinnon critical values (e.g. roughly `−2.9` at 5%). `0`
19    /// when the test is undefined (a degenerate, zero-variance spread).
20    pub adf_stat: f64,
21}
22
23/// Rolling cointegration test for a pair of assets (Engle–Granger two-step).
24///
25/// Each `update` receives one `(a, b)` pair (price levels, or log-levels if you
26/// prefer). Over the trailing window of `period` pairs the indicator:
27///
28/// 1. fits the **hedge ratio** `β` (and intercept `α`) by ordinary least
29///    squares of `a` on `b`, and forms the **spread** `eₜ = aₜ − (α + β·bₜ)`;
30/// 2. runs an **augmented Dickey–Fuller** test (no constant, no trend, with
31///    `adf_lags` lagged differences) on the spread series and reports its
32///    `t`-statistic.
33///
34/// A strongly negative ADF statistic means the spread reverts to its mean — the
35/// pair is cointegrated and the spread is tradeable. A statistic near zero
36/// means the spread wanders like a random walk (no cointegration). This is the
37/// classic pairs-trading screen: `β` tells you the hedge size, the spread is
38/// what you trade, and the ADF statistic tells you whether it is worth trading.
39///
40/// Each `update` is `O(period + adf_lags³)`: the hedge ratio is maintained from
41/// running sums, while the spread series and the small ADF regression are
42/// recomputed over the window — both bounded by the fixed parameters, not the
43/// series length.
44///
45/// # Example
46///
47/// ```
48/// use wickra_core::{Cointegration, Indicator};
49///
50/// let mut c = Cointegration::new(30, 1).unwrap();
51/// let mut last = None;
52/// for t in 0..60 {
53///     let b = 100.0 + f64::from(t);
54///     // `a` tracks 2·b with a small mean-reverting wobble ⇒ cointegrated.
55///     let a = 2.0 * b + 5.0 + 0.5 * (f64::from(t) * 0.7).sin();
56///     last = c.update((a, b));
57/// }
58/// let out = last.unwrap();
59/// assert!((out.hedge_ratio - 2.0).abs() < 0.1);
60/// assert!(out.adf_stat < 0.0); // mean-reverting spread
61/// ```
62#[derive(Debug, Clone)]
63pub struct Cointegration {
64    period: usize,
65    adf_lags: usize,
66    window: VecDeque<(f64, f64)>,
67    moments: ShiftedPairMoments,
68}
69
70impl Cointegration {
71    /// Construct a new rolling cointegration test.
72    ///
73    /// `period` is the look-back window; `adf_lags` is the number of lagged
74    /// differences in the augmented Dickey–Fuller regression (`0` is the plain
75    /// Dickey–Fuller test).
76    ///
77    /// # Errors
78    /// Returns [`Error::InvalidPeriod`] if `period < 2·adf_lags + 4`, which is
79    /// the smallest window that leaves the ADF regression at least one degree
80    /// of freedom.
81    pub fn new(period: usize, adf_lags: usize) -> Result<Self> {
82        let min_period = 2 * adf_lags + 4;
83        if period < min_period {
84            return Err(Error::InvalidPeriod {
85                message: "cointegration needs period >= 2*adf_lags + 4",
86            });
87        }
88        Ok(Self {
89            period,
90            adf_lags,
91            window: VecDeque::with_capacity(period),
92            moments: ShiftedPairMoments::new(),
93        })
94    }
95
96    /// Look-back window length.
97    pub const fn period(&self) -> usize {
98        self.period
99    }
100
101    /// Number of lagged differences in the ADF regression.
102    pub const fn adf_lags(&self) -> usize {
103        self.adf_lags
104    }
105}
106
107impl Indicator for Cointegration {
108    /// `(a, b)` price pair.
109    type Input = (f64, f64);
110    type Output = CointegrationOutput;
111
112    fn update(&mut self, input: (f64, f64)) -> Option<CointegrationOutput> {
113        let (a, b) = input;
114        if !a.is_finite() || !b.is_finite() {
115            return None;
116        }
117        if self.window.len() == self.period {
118            let (oa, ob) = self.window.pop_front().expect("non-empty");
119            self.moments.evict(oa, ob);
120        }
121        self.window.push_back((a, b));
122        self.moments.push(a, b);
123        if self.moments.needs_reseed(self.period) {
124            self.moments.reseed(self.window.iter().copied());
125        }
126        if self.window.len() < self.period {
127            return None;
128        }
129        let mean_a = self.moments.mean_a(self.period);
130        let mean_b = self.moments.mean_b(self.period);
131        let var_b = self.moments.var_b(self.period);
132        let (hedge_ratio, intercept) = if var_b == 0.0 {
133            // A flat `b` window has no defined slope; fall back to a level shift.
134            (0.0, mean_a)
135        } else {
136            let cov = self.moments.cov(self.period);
137            let beta = cov / var_b;
138            (beta, mean_a - beta * mean_b)
139        };
140        // Build the spread (residual) series over the window, oldest → newest.
141        let spreads: Vec<f64> = self
142            .window
143            .iter()
144            .map(|&(ai, bi)| ai - (intercept + hedge_ratio * bi))
145            .collect();
146        let spread = *spreads.last().expect("window is full");
147        let adf_stat = adf_no_constant(&spreads, self.adf_lags);
148        Some(CointegrationOutput {
149            hedge_ratio,
150            spread,
151            adf_stat,
152        })
153    }
154
155    fn reset(&mut self) {
156        self.window.clear();
157        self.moments.reset();
158    }
159
160    #[inline]
161    fn warmup_period(&self) -> usize {
162        self.period
163    }
164
165    #[inline]
166    fn is_ready(&self) -> bool {
167        self.window.len() == self.period
168    }
169
170    #[inline]
171    fn name(&self) -> &'static str {
172        "Cointegration"
173    }
174}
175
176/// Solve the linear system `mat·x = rhs` for a small square system by Gaussian
177/// elimination, returning `None` if the matrix is (numerically) singular.
178///
179/// `mat` is row-major and consumed; `rhs` is the right-hand side.
180fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
181    let dim = rhs.len();
182    for col in 0..dim {
183        let pivot = mat[col][col];
184        if pivot.abs() < 1e-12 {
185            return None;
186        }
187        let pivot_row = mat[col].clone();
188        for row in (col + 1)..dim {
189            let factor = mat[row][col] / pivot;
190            for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
191                *cell -= factor * above;
192            }
193            rhs[row] -= factor * rhs[col];
194        }
195    }
196    let mut sol = vec![0.0; dim];
197    for row in (0..dim).rev() {
198        let known: f64 = mat[row]
199            .iter()
200            .zip(&sol)
201            .skip(row + 1)
202            .map(|(coeff, value)| coeff * value)
203            .sum();
204        sol[row] = (rhs[row] - known) / mat[row][row];
205    }
206    Some(sol)
207}
208
209/// Augmented Dickey–Fuller `t`-statistic on `series`, with `lags` lagged
210/// differences and **no** constant or trend term (the Engle–Granger residual
211/// form). Returns `0.0` when the regression is degenerate.
212///
213/// The regression is `Δeₜ = ρ·eₜ₋₁ + Σ γᵢ·Δeₜ₋ᵢ + εₜ`; the reported statistic
214/// is `ρ̂ / se(ρ̂)`.
215fn adf_no_constant(series: &[f64], lags: usize) -> f64 {
216    let len = series.len();
217    let num_reg = lags + 1; // regressors: eₜ₋₁ plus `lags` lagged differences
218    let first = lags + 1; // first usable observation index
219    if len <= first {
220        return 0.0;
221    }
222    let num_obs = len - first;
223    if num_obs <= num_reg {
224        return 0.0; // need at least one residual degree of freedom
225    }
226    let regressors = |idx: usize| -> Vec<f64> {
227        let mut row = vec![0.0; num_reg];
228        row[0] = series[idx - 1];
229        for lag in 1..=lags {
230            row[lag] = series[idx - lag] - series[idx - lag - 1];
231        }
232        row
233    };
234    let mut xtx = vec![vec![0.0; num_reg]; num_reg];
235    let mut xty = vec![0.0; num_reg];
236    for idx in first..len {
237        let diff = series[idx] - series[idx - 1];
238        let row = regressors(idx);
239        for (ri, &left) in row.iter().enumerate() {
240            xty[ri] += left * diff;
241            for (ci, &right) in row.iter().enumerate() {
242                xtx[ri][ci] += left * right;
243            }
244        }
245    }
246    let Some(theta) = solve(xtx.clone(), xty) else {
247        return 0.0;
248    };
249    let rho = theta[0];
250    let mut rss = 0.0;
251    for idx in first..len {
252        let diff = series[idx] - series[idx - 1];
253        let pred: f64 = regressors(idx)
254            .iter()
255            .zip(&theta)
256            .map(|(coeff, value)| coeff * value)
257            .sum();
258        let resid = diff - pred;
259        rss += resid * resid;
260    }
261    let dof = (num_obs - num_reg) as f64;
262    let sigma2 = rss / dof;
263    // (XᵀX)⁻¹₀₀ from solving XᵀX·x = e₀. `xtx` is the same matrix the first
264    // solve already factored successfully, so this one cannot be singular.
265    let mut unit = vec![0.0; num_reg];
266    unit[0] = 1.0;
267    let inverse = solve(xtx, unit).expect("xtx is non-singular: the coefficient solve succeeded");
268    let var_rho = sigma2 * inverse[0];
269    if var_rho <= 0.0 {
270        return 0.0;
271    }
272    rho / var_rho.sqrt()
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::traits::BatchExt;
279    use approx::assert_relative_eq;
280
281    #[test]
282    fn rejects_too_small_period() {
283        // period must be >= 2*lags + 4.
284        assert!(Cointegration::new(3, 0).is_err()); // needs >= 4
285        assert!(Cointegration::new(4, 0).is_ok());
286        assert!(Cointegration::new(5, 1).is_err()); // needs >= 6
287        assert!(Cointegration::new(6, 1).is_ok());
288    }
289
290    #[test]
291    fn accessors_and_metadata() {
292        let c = Cointegration::new(30, 2).unwrap();
293        assert_eq!(c.period(), 30);
294        assert_eq!(c.adf_lags(), 2);
295        assert_eq!(c.warmup_period(), 30);
296        assert_eq!(c.name(), "Cointegration");
297    }
298
299    #[test]
300    fn adf_guards_and_degenerate_spread() {
301        // Series too short for any observation ⇒ 0.
302        assert_eq!(adf_no_constant(&[1.0], 1), 0.0);
303        // Long enough but too few degrees of freedom ⇒ 0.
304        assert_eq!(adf_no_constant(&[1.0, 2.0, 3.0], 1), 0.0);
305        // A perfect deterministic AR(1) spread (eₜ = 0.5·eₜ₋₁) is fit exactly,
306        // so the residual variance — and hence the t-statistic — is 0.
307        let geom: Vec<f64> = (0..8).map(|t| 0.5_f64.powi(t)).collect();
308        assert_eq!(adf_no_constant(&geom, 0), 0.0);
309    }
310
311    #[test]
312    fn recovers_hedge_ratio() {
313        // a = 2·b + 5 + small wobble ⇒ β ≈ 2.
314        let pairs: Vec<(f64, f64)> = (0..60)
315            .map(|t| {
316                let b = 100.0 + f64::from(t);
317                let a = 2.0 * b + 5.0 + 0.4 * (f64::from(t) * 0.9).sin();
318                (a, b)
319            })
320            .collect();
321        let out = Cointegration::new(30, 1)
322            .unwrap()
323            .batch(&pairs)
324            .into_iter()
325            .flatten()
326            .last()
327            .unwrap();
328        assert!(
329            (out.hedge_ratio - 2.0).abs() < 0.1,
330            "beta {}",
331            out.hedge_ratio
332        );
333    }
334
335    #[test]
336    fn stationary_spread_is_strongly_negative() {
337        // A clean mean-reverting (sinusoidal) spread ⇒ very negative ADF.
338        let pairs: Vec<(f64, f64)> = (0..80)
339            .map(|t| {
340                let b = 50.0 + 0.5 * f64::from(t);
341                let a = 2.0 * b + 1.0 + 0.5 * (f64::from(t) * 0.6).sin();
342                (a, b)
343            })
344            .collect();
345        let out = Cointegration::new(40, 1)
346            .unwrap()
347            .batch(&pairs)
348            .into_iter()
349            .flatten()
350            .last()
351            .unwrap();
352        assert!(out.adf_stat < -2.0, "adf {}", out.adf_stat);
353    }
354
355    #[test]
356    fn perfect_cointegration_has_zero_spread_and_defined_ratio() {
357        // a = 2·b + 5 exactly ⇒ residuals all zero ⇒ ADF degenerate ⇒ 0.
358        let pairs: Vec<(f64, f64)> = (0..40)
359            .map(|t| {
360                let b = 100.0 + f64::from(t);
361                (2.0 * b + 5.0, b)
362            })
363            .collect();
364        let out = Cointegration::new(20, 1)
365            .unwrap()
366            .batch(&pairs)
367            .into_iter()
368            .flatten()
369            .last()
370            .unwrap();
371        assert_relative_eq!(out.hedge_ratio, 2.0, epsilon = 1e-9);
372        assert_relative_eq!(out.spread, 0.0, epsilon = 1e-6);
373        assert_relative_eq!(out.adf_stat, 0.0, epsilon = 1e-12);
374    }
375
376    #[test]
377    fn flat_b_falls_back_to_level() {
378        // Constant b ⇒ no slope ⇒ hedge ratio 0, spread = a − mean(a).
379        let pairs: Vec<(f64, f64)> = (0..20)
380            .map(|t| (10.0 + 0.3 * (f64::from(t) * 0.5).sin(), 7.0))
381            .collect();
382        let out = Cointegration::new(10, 0)
383            .unwrap()
384            .batch(&pairs)
385            .into_iter()
386            .flatten()
387            .last()
388            .unwrap();
389        assert_relative_eq!(out.hedge_ratio, 0.0, epsilon = 1e-12);
390    }
391
392    #[test]
393    fn plain_dickey_fuller_lags_zero() {
394        // Exercise the lags = 0 path (1×1 ADF system).
395        let pairs: Vec<(f64, f64)> = (0..40)
396            .map(|t| {
397                let b = 20.0 + 0.4 * f64::from(t);
398                let a = 1.5 * b + 0.6 * (f64::from(t) * 0.7).sin();
399                (a, b)
400            })
401            .collect();
402        let out = Cointegration::new(20, 0)
403            .unwrap()
404            .batch(&pairs)
405            .into_iter()
406            .flatten()
407            .last()
408            .unwrap();
409        assert!((out.hedge_ratio - 1.5).abs() < 0.1);
410        assert!(out.adf_stat < 0.0);
411    }
412
413    #[test]
414    fn reset_clears_state() {
415        let mut c = Cointegration::new(10, 1).unwrap();
416        for t in 0..20 {
417            let b = 100.0 + f64::from(t);
418            c.update((2.0 * b + (f64::from(t) * 0.5).sin(), b));
419        }
420        assert!(c.is_ready());
421        c.reset();
422        assert!(!c.is_ready());
423        assert_eq!(c.update((1.0, 1.0)), None);
424    }
425
426    #[test]
427    fn batch_equals_streaming() {
428        let pairs: Vec<(f64, f64)> = (0..80)
429            .map(|t| {
430                let b = 30.0 + 0.7 * f64::from(t);
431                let a = 1.8 * b + 2.0 + 0.5 * (f64::from(t) * 0.4).sin();
432                (a, b)
433            })
434            .collect();
435        let batch = Cointegration::new(25, 2).unwrap().batch(&pairs);
436        let mut c = Cointegration::new(25, 2).unwrap();
437        let streamed: Vec<_> = pairs.iter().map(|p| c.update(*p)).collect();
438        assert_eq!(batch, streamed);
439    }
440
441    #[test]
442    fn non_finite_input_returns_none() {
443        let mut c = Cointegration::new(4, 0).unwrap();
444        assert_eq!(c.update((f64::NAN, 1.0)), None);
445        assert_eq!(c.update((1.0, f64::INFINITY)), None);
446        // The rejected ticks leave no trace: a fresh window still warms up.
447        assert_eq!(c.update((1.0, 2.0)), None);
448        assert_eq!(c.update((2.0, 5.0)), None);
449        assert_eq!(c.update((3.0, 7.0)), None);
450        assert!(c.update((4.0, 11.0)).is_some());
451    }
452}