Skip to main content

wickra_core/indicators/
spread_ar1_coefficient.rs

1//! AR(1) autoregression coefficient of the spread of two series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::centred_moments;
7use crate::traits::Indicator;
8
9/// First-order autoregression coefficient `ρ` of the spread `a − b`.
10///
11/// Each `update` takes one `(a, b)` price pair and forms the spread
12/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
13/// fits the discrete AR(1) model by ordinary least squares of the level on its
14/// own lag:
15///
16/// ```text
17/// sₜ = ρ · sₜ₋₁ + c + εₜ
18/// ρ  = cov(sₜ₋₁, sₜ) / var(sₜ₋₁)
19/// ```
20///
21/// `ρ` is the direct measure of cointegration / mean-reversion strength of the
22/// pair:
23///
24/// - `ρ` near `0` — the spread snaps back to its mean almost instantly (very
25///   strong mean reversion).
26/// - `ρ` near `1` — the spread behaves like a random walk (a unit root: no
27///   reliable reversion, the pair is *not* cointegrated).
28/// - `ρ > 1` — the spread is explosive (diverging).
29///
30/// This is the complement of [`OuHalfLife`](crate::OuHalfLife): the OU half-life
31/// is `−ln(2) / ln(ρ)` for `0 < ρ < 1`, but `ρ` itself is the raw, unbounded
32/// stationarity statistic many pairs-trading screens threshold on directly
33/// (e.g. "trade only pairs with `ρ < 0.9`"). When the spread is flat over the
34/// window (`var(sₜ₋₁) = 0`) the regression slope is undefined and the indicator
35/// returns `0`.
36///
37/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
38/// running geometry.
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Indicator, SpreadAr1Coefficient};
44///
45/// let mut ar1 = SpreadAr1Coefficient::new(40).unwrap();
46/// let mut last = None;
47/// for t in 0..120 {
48///     let b = 100.0 + f64::from(t);
49///     // `a` hugs `b` with a fast mean-reverting wobble ⇒ ρ well below 1.
50///     let a = b + 2.0 * (f64::from(t) * 0.9).sin();
51///     last = ar1.update((a, b));
52/// }
53/// let rho = last.unwrap();
54/// assert!(rho > 0.0 && rho < 1.0);
55/// ```
56#[derive(Debug, Clone)]
57pub struct SpreadAr1Coefficient {
58    period: usize,
59    window: VecDeque<f64>,
60}
61
62impl SpreadAr1Coefficient {
63    /// Construct a new AR(1) spread-coefficient estimator.
64    ///
65    /// # Errors
66    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
67    /// needs at least two `(level, next)` observations (a slope and an
68    /// intercept).
69    pub fn new(period: usize) -> Result<Self> {
70        if period < 3 {
71            return Err(Error::InvalidPeriod {
72                message: "AR(1) spread coefficient needs period >= 3",
73            });
74        }
75        if period > crate::error::MAX_PERIOD {
76            return Err(Error::InvalidPeriod {
77                message: crate::error::PERIOD_ABOVE_MAX,
78            });
79        }
80        Ok(Self {
81            period,
82            window: VecDeque::with_capacity(period),
83        })
84    }
85
86    /// Configured look-back window of spreads.
87    pub const fn period(&self) -> usize {
88        self.period
89    }
90}
91
92impl Indicator for SpreadAr1Coefficient {
93    type Input = (f64, f64);
94    type Output = f64;
95
96    #[inline]
97    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
98        let (a, b) = input;
99        if !a.is_finite() || !b.is_finite() {
100            return None;
101        }
102        if self.window.len() == self.period {
103            self.window.pop_front();
104        }
105        self.window.push_back(a - b);
106        if self.window.len() < self.period {
107            return None;
108        }
109        // OLS slope ρ of the level on its own lag over the window. The pairs
110        // are produced lazily and traversed twice rather than collected, so
111        // this no longer allocates per update either.
112        let moments = centred_moments(
113            self.window
114                .iter()
115                .zip(self.window.iter().skip(1))
116                .map(|(&level, &next)| (level, next)),
117        );
118        if moments.var_x <= 0.0 {
119            // Flat spread: the regression has no defined slope.
120            return Some(0.0);
121        }
122        Some(moments.cov / moments.var_x)
123    }
124
125    fn reset(&mut self) {
126        self.window.clear();
127    }
128
129    #[inline]
130    fn warmup_period(&self) -> usize {
131        self.period
132    }
133
134    #[inline]
135    fn is_ready(&self) -> bool {
136        self.window.len() == self.period
137    }
138
139    #[inline]
140    fn name(&self) -> &'static str {
141        "SpreadAr1Coefficient"
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::traits::BatchExt;
149    use approx::assert_relative_eq;
150
151    #[test]
152    fn rejects_period_below_three() {
153        assert!(SpreadAr1Coefficient::new(2).is_err());
154        assert!(SpreadAr1Coefficient::new(3).is_ok());
155    }
156
157    #[test]
158    fn accessors_and_metadata() {
159        let ar1 = SpreadAr1Coefficient::new(30).unwrap();
160        assert_eq!(ar1.period(), 30);
161        assert_eq!(ar1.warmup_period(), 30);
162        assert_eq!(ar1.name(), "SpreadAr1Coefficient");
163        assert!(!ar1.is_ready());
164    }
165
166    #[test]
167    fn warmup_returns_none() {
168        let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
169        assert_eq!(ar1.update((1.0, 0.0)), None);
170        assert_eq!(ar1.update((2.0, 0.0)), None);
171        assert_eq!(ar1.update((3.0, 0.0)), None);
172        assert!(ar1.update((4.0, 0.0)).is_some());
173        assert!(ar1.is_ready());
174    }
175
176    #[test]
177    fn mean_reverting_spread_has_rho_below_one() {
178        // Fast sinusoidal spread around zero ⇒ stationary ⇒ 0 < ρ < 1.
179        let pairs: Vec<(f64, f64)> = (0..120)
180            .map(|t| {
181                let b = 100.0 + f64::from(t);
182                let a = b + 2.0 * (f64::from(t) * 0.9).sin();
183                (a, b)
184            })
185            .collect();
186        let last = SpreadAr1Coefficient::new(40)
187            .unwrap()
188            .batch(&pairs)
189            .into_iter()
190            .flatten()
191            .last()
192            .unwrap();
193        assert!(last > 0.0 && last < 1.0, "rho {last}");
194    }
195
196    #[test]
197    fn random_walk_spread_has_rho_near_one() {
198        // Spread = a − b grows by exactly 1 each bar ⇒ next = level + 1 ⇒
199        // the OLS slope is exactly 1 (unit root).
200        let pairs: Vec<(f64, f64)> = (0..40)
201            .map(|t| (2.0 * f64::from(t), f64::from(t)))
202            .collect();
203        let last = SpreadAr1Coefficient::new(20)
204            .unwrap()
205            .batch(&pairs)
206            .into_iter()
207            .flatten()
208            .last()
209            .unwrap();
210        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
211    }
212
213    #[test]
214    fn flat_spread_returns_zero() {
215        // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
216        let pairs: Vec<(f64, f64)> = (0..30)
217            .map(|t| (5.0 + f64::from(t), f64::from(t)))
218            .collect();
219        let last = SpreadAr1Coefficient::new(10)
220            .unwrap()
221            .batch(&pairs)
222            .into_iter()
223            .flatten()
224            .last()
225            .unwrap();
226        assert_eq!(last, 0.0);
227    }
228
229    #[test]
230    fn reset_clears_state() {
231        let mut ar1 = SpreadAr1Coefficient::new(5).unwrap();
232        for t in 0..10 {
233            ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
234        }
235        assert!(ar1.is_ready());
236        ar1.reset();
237        assert!(!ar1.is_ready());
238        assert_eq!(ar1.update((1.0, 0.0)), None);
239    }
240
241    #[test]
242    fn batch_equals_streaming() {
243        let pairs: Vec<(f64, f64)> = (0..80)
244            .map(|t| {
245                let b = 50.0 + 0.5 * f64::from(t);
246                (b + (f64::from(t) * 0.6).sin(), b)
247            })
248            .collect();
249        let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs);
250        let mut ar1 = SpreadAr1Coefficient::new(25).unwrap();
251        let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
252        assert_eq!(batch, streamed);
253    }
254
255    #[test]
256    fn non_finite_input_returns_none() {
257        let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
258        assert_eq!(ar1.update((f64::NAN, 1.0)), None);
259        assert_eq!(ar1.update((1.0, f64::INFINITY)), None);
260        // The rejected ticks leave no trace: a fresh window still warms up.
261        assert_eq!(ar1.update((1.0, 0.0)), None);
262        assert_eq!(ar1.update((2.0, 0.0)), None);
263        assert_eq!(ar1.update((3.0, 0.0)), None);
264        assert!(ar1.update((4.0, 0.0)).is_some());
265    }
266
267    /// A cointegrated pair trades at two different price levels, so the spread
268    /// carries a large constant offset with only a small wobble on top -- and
269    /// the regression was accumulating raw power sums of that offset level.
270    /// Two legs around 1e5 whose spread wobbles by 1e-3 measured 4.9e-08
271    /// against a two-pass reference. Centring the window makes it exact.
272    #[test]
273    fn offset_spread_matches_a_two_pass_reference() {
274        const PERIOD: usize = 20;
275        let series: Vec<(f64, f64)> = (0..400)
276            .map(|i| {
277                let t = f64::from(i);
278                let base = 1e5 * (1.0 + 0.01 * (t * 0.03).sin());
279                (base * 1.05 + 1e-3 * (t * 0.23).sin(), base)
280            })
281            .collect();
282
283        let mut ind = SpreadAr1Coefficient::new(PERIOD).unwrap();
284        let mut spreads: Vec<f64> = Vec::new();
285        let mut compared = 0_usize;
286        for &(a, b) in &series {
287            let got = ind.update((a, b));
288            spreads.push(a - b);
289            let Some(rho) = got else { continue };
290            let window = &spreads[spreads.len() - PERIOD..];
291            let levels = &window[..PERIOD - 1];
292            let nexts = &window[1..];
293            let n = (PERIOD - 1) as f64;
294            let mean_level = levels.iter().sum::<f64>() / n;
295            let mean_next = nexts.iter().sum::<f64>() / n;
296            let var_level = levels
297                .iter()
298                .map(|v| (v - mean_level) * (v - mean_level))
299                .sum::<f64>()
300                / n;
301            let cov = levels
302                .iter()
303                .zip(nexts)
304                .map(|(u, v)| (u - mean_level) * (v - mean_next))
305                .sum::<f64>()
306                / n;
307            compared += 1;
308            assert_relative_eq!(rho, cov / var_level, max_relative = 1e-12);
309        }
310        assert_eq!(compared, series.len() - ind.warmup_period() + 1);
311    }
312}