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 self.window.len() == self.period {
93            self.window.pop_front();
94        }
95        self.window.push_back(a - b);
96        if self.window.len() < self.period {
97            return None;
98        }
99        // OLS slope ρ of the level on its own lag over the window.
100        let spreads: Vec<f64> = self.window.iter().copied().collect();
101        let count = (spreads.len() - 1) as f64;
102        let mut sum_level = 0.0;
103        let mut sum_next = 0.0;
104        let mut sum_ll = 0.0;
105        let mut sum_ln = 0.0;
106        for pair in spreads.windows(2) {
107            let level = pair[0];
108            let next = pair[1];
109            sum_level += level;
110            sum_next += next;
111            sum_ll += level * level;
112            sum_ln += level * next;
113        }
114        let mean_level = sum_level / count;
115        let mean_next = sum_next / count;
116        let var_level = sum_ll / count - mean_level * mean_level;
117        if var_level <= 0.0 {
118            // Flat spread: the regression has no defined slope.
119            return Some(0.0);
120        }
121        let cov = sum_ln / count - mean_level * mean_next;
122        Some(cov / var_level)
123    }
124
125    fn reset(&mut self) {
126        self.window.clear();
127    }
128
129    fn warmup_period(&self) -> usize {
130        self.period
131    }
132
133    fn is_ready(&self) -> bool {
134        self.window.len() == self.period
135    }
136
137    fn name(&self) -> &'static str {
138        "SpreadAr1Coefficient"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_period_below_three() {
150        assert!(SpreadAr1Coefficient::new(2).is_err());
151        assert!(SpreadAr1Coefficient::new(3).is_ok());
152    }
153
154    #[test]
155    fn accessors_and_metadata() {
156        let ar1 = SpreadAr1Coefficient::new(30).unwrap();
157        assert_eq!(ar1.period(), 30);
158        assert_eq!(ar1.warmup_period(), 30);
159        assert_eq!(ar1.name(), "SpreadAr1Coefficient");
160        assert!(!ar1.is_ready());
161    }
162
163    #[test]
164    fn warmup_returns_none() {
165        let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
166        assert_eq!(ar1.update((1.0, 0.0)), None);
167        assert_eq!(ar1.update((2.0, 0.0)), None);
168        assert_eq!(ar1.update((3.0, 0.0)), None);
169        assert!(ar1.update((4.0, 0.0)).is_some());
170        assert!(ar1.is_ready());
171    }
172
173    #[test]
174    fn mean_reverting_spread_has_rho_below_one() {
175        // Fast sinusoidal spread around zero ⇒ stationary ⇒ 0 < ρ < 1.
176        let pairs: Vec<(f64, f64)> = (0..120)
177            .map(|t| {
178                let b = 100.0 + f64::from(t);
179                let a = b + 2.0 * (f64::from(t) * 0.9).sin();
180                (a, b)
181            })
182            .collect();
183        let last = SpreadAr1Coefficient::new(40)
184            .unwrap()
185            .batch(&pairs)
186            .into_iter()
187            .flatten()
188            .last()
189            .unwrap();
190        assert!(last > 0.0 && last < 1.0, "rho {last}");
191    }
192
193    #[test]
194    fn random_walk_spread_has_rho_near_one() {
195        // Spread = a − b grows by exactly 1 each bar ⇒ next = level + 1 ⇒
196        // the OLS slope is exactly 1 (unit root).
197        let pairs: Vec<(f64, f64)> = (0..40)
198            .map(|t| (2.0 * f64::from(t), f64::from(t)))
199            .collect();
200        let last = SpreadAr1Coefficient::new(20)
201            .unwrap()
202            .batch(&pairs)
203            .into_iter()
204            .flatten()
205            .last()
206            .unwrap();
207        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
208    }
209
210    #[test]
211    fn flat_spread_returns_zero() {
212        // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
213        let pairs: Vec<(f64, f64)> = (0..30)
214            .map(|t| (5.0 + f64::from(t), f64::from(t)))
215            .collect();
216        let last = SpreadAr1Coefficient::new(10)
217            .unwrap()
218            .batch(&pairs)
219            .into_iter()
220            .flatten()
221            .last()
222            .unwrap();
223        assert_eq!(last, 0.0);
224    }
225
226    #[test]
227    fn reset_clears_state() {
228        let mut ar1 = SpreadAr1Coefficient::new(5).unwrap();
229        for t in 0..10 {
230            ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
231        }
232        assert!(ar1.is_ready());
233        ar1.reset();
234        assert!(!ar1.is_ready());
235        assert_eq!(ar1.update((1.0, 0.0)), None);
236    }
237
238    #[test]
239    fn batch_equals_streaming() {
240        let pairs: Vec<(f64, f64)> = (0..80)
241            .map(|t| {
242                let b = 50.0 + 0.5 * f64::from(t);
243                (b + (f64::from(t) * 0.6).sin(), b)
244            })
245            .collect();
246        let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs);
247        let mut ar1 = SpreadAr1Coefficient::new(25).unwrap();
248        let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
249        assert_eq!(batch, streamed);
250    }
251}