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::traits::Indicator;
7
8/// First-order autoregression coefficient `ρ` of the spread `a − b`.
9///
10/// Each `update` takes one `(a, b)` price pair and forms the spread
11/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
12/// fits the discrete AR(1) model by ordinary least squares of the level on its
13/// own lag:
14///
15/// ```text
16/// sₜ = ρ · sₜ₋₁ + c + εₜ
17/// ρ  = cov(sₜ₋₁, sₜ) / var(sₜ₋₁)
18/// ```
19///
20/// `ρ` is the direct measure of cointegration / mean-reversion strength of the
21/// pair:
22///
23/// - `ρ` near `0` — the spread snaps back to its mean almost instantly (very
24///   strong mean reversion).
25/// - `ρ` near `1` — the spread behaves like a random walk (a unit root: no
26///   reliable reversion, the pair is *not* cointegrated).
27/// - `ρ > 1` — the spread is explosive (diverging).
28///
29/// This is the complement of [`OuHalfLife`](crate::OuHalfLife): the OU half-life
30/// is `−ln(2) / ln(ρ)` for `0 < ρ < 1`, but `ρ` itself is the raw, unbounded
31/// stationarity statistic many pairs-trading screens threshold on directly
32/// (e.g. "trade only pairs with `ρ < 0.9`"). When the spread is flat over the
33/// window (`var(sₜ₋₁) = 0`) the regression slope is undefined and the indicator
34/// returns `0`.
35///
36/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
37/// running geometry.
38///
39/// # Example
40///
41/// ```
42/// use wickra_core::{Indicator, SpreadAr1Coefficient};
43///
44/// let mut ar1 = SpreadAr1Coefficient::new(40).unwrap();
45/// let mut last = None;
46/// for t in 0..120 {
47///     let b = 100.0 + f64::from(t);
48///     // `a` hugs `b` with a fast mean-reverting wobble ⇒ ρ well below 1.
49///     let a = b + 2.0 * (f64::from(t) * 0.9).sin();
50///     last = ar1.update((a, b));
51/// }
52/// let rho = last.unwrap();
53/// assert!(rho > 0.0 && rho < 1.0);
54/// ```
55#[derive(Debug, Clone)]
56pub struct SpreadAr1Coefficient {
57    period: usize,
58    window: VecDeque<f64>,
59}
60
61impl SpreadAr1Coefficient {
62    /// Construct a new AR(1) spread-coefficient estimator.
63    ///
64    /// # Errors
65    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
66    /// needs at least two `(level, next)` observations (a slope and an
67    /// intercept).
68    pub fn new(period: usize) -> Result<Self> {
69        if period < 3 {
70            return Err(Error::InvalidPeriod {
71                message: "AR(1) spread coefficient needs period >= 3",
72            });
73        }
74        Ok(Self {
75            period,
76            window: VecDeque::with_capacity(period),
77        })
78    }
79
80    /// Configured look-back window of spreads.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84}
85
86impl Indicator for SpreadAr1Coefficient {
87    type Input = (f64, f64);
88    type Output = f64;
89
90    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
91        let (a, b) = input;
92        if !a.is_finite() || !b.is_finite() {
93            return None;
94        }
95        if self.window.len() == self.period {
96            self.window.pop_front();
97        }
98        self.window.push_back(a - b);
99        if self.window.len() < self.period {
100            return None;
101        }
102        // OLS slope ρ of the level on its own lag over the window.
103        let spreads: Vec<f64> = self.window.iter().copied().collect();
104        let count = (spreads.len() - 1) as f64;
105        let mut sum_level = 0.0;
106        let mut sum_next = 0.0;
107        let mut sum_ll = 0.0;
108        let mut sum_ln = 0.0;
109        for pair in spreads.windows(2) {
110            let level = pair[0];
111            let next = pair[1];
112            sum_level += level;
113            sum_next += next;
114            sum_ll += level * level;
115            sum_ln += level * next;
116        }
117        let mean_level = sum_level / count;
118        let mean_next = sum_next / count;
119        let var_level = sum_ll / count - mean_level * mean_level;
120        if var_level <= 0.0 {
121            // Flat spread: the regression has no defined slope.
122            return Some(0.0);
123        }
124        let cov = sum_ln / count - mean_level * mean_next;
125        Some(cov / var_level)
126    }
127
128    fn reset(&mut self) {
129        self.window.clear();
130    }
131
132    fn warmup_period(&self) -> usize {
133        self.period
134    }
135
136    fn is_ready(&self) -> bool {
137        self.window.len() == self.period
138    }
139
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}