Skip to main content

wickra_core/indicators/
beta_neutral_spread.rs

1//! Beta-neutral spread: the rolling OLS regression residual of two series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// The beta-neutral spread between two assets — the residual of a rolling
9/// ordinary-least-squares regression of `a` on `b`.
10///
11/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
12/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by
13/// OLS and reports the **current** residual:
14///
15/// ```text
16/// β = cov(a, b) / var(b)        α = ā − β · b̄
17/// spread = a_now − (α + β · b_now)
18/// ```
19///
20/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market-
21/// (beta-)neutral: it is what is left after the common factor is hedged out.
22/// Positive means `a` is rich relative to its hedge, negative means cheap — the
23/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises
24/// this residual into a z-score and [`crate::Cointegration`] bundles it with an
25/// ADF test, this indicator returns the residual itself, in price units.
26///
27/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the
28/// indicator falls back to `β = 0`, so the spread becomes `a_now − ā`.
29///
30/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are
31/// maintained as the window slides.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{BetaNeutralSpread, Indicator};
37///
38/// let mut s = BetaNeutralSpread::new(20).unwrap();
39/// let mut last = None;
40/// for t in 0..40 {
41///     let b = 100.0 + f64::from(t);
42///     // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0.
43///     last = s.update((2.0 * b + 5.0, b));
44/// }
45/// assert!(last.unwrap().abs() < 1e-6);
46/// ```
47#[derive(Debug, Clone)]
48pub struct BetaNeutralSpread {
49    period: usize,
50    window: VecDeque<(f64, f64)>,
51    sum_a: f64,
52    sum_b: f64,
53    sum_bb: f64,
54    sum_ab: f64,
55}
56
57impl BetaNeutralSpread {
58    /// Construct a new beta-neutral spread.
59    ///
60    /// # Errors
61    /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope
62    /// needs at least two points.
63    pub fn new(period: usize) -> Result<Self> {
64        if period < 2 {
65            return Err(Error::InvalidPeriod {
66                message: "beta-neutral spread needs period >= 2",
67            });
68        }
69        Ok(Self {
70            period,
71            window: VecDeque::with_capacity(period),
72            sum_a: 0.0,
73            sum_b: 0.0,
74            sum_bb: 0.0,
75            sum_ab: 0.0,
76        })
77    }
78
79    /// Configured look-back window.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83}
84
85impl Indicator for BetaNeutralSpread {
86    type Input = (f64, f64);
87    type Output = f64;
88
89    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
90        let (a, b) = input;
91        if !a.is_finite() || !b.is_finite() {
92            return None;
93        }
94        if self.window.len() == self.period {
95            let (oa, ob) = self.window.pop_front().expect("non-empty");
96            self.sum_a -= oa;
97            self.sum_b -= ob;
98            self.sum_bb -= ob * ob;
99            self.sum_ab -= oa * ob;
100        }
101        self.window.push_back((a, b));
102        self.sum_a += a;
103        self.sum_b += b;
104        self.sum_bb += b * b;
105        self.sum_ab += a * b;
106        if self.window.len() < self.period {
107            return None;
108        }
109        let n = self.period as f64;
110        let mean_a = self.sum_a / n;
111        let mean_b = self.sum_b / n;
112        let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
113        let (beta, intercept) = if var_b == 0.0 {
114            (0.0, mean_a)
115        } else {
116            let cov = self.sum_ab / n - mean_a * mean_b;
117            let slope = cov / var_b;
118            (slope, mean_a - slope * mean_b)
119        };
120        Some(a - (intercept + beta * b))
121    }
122
123    fn reset(&mut self) {
124        self.window.clear();
125        self.sum_a = 0.0;
126        self.sum_b = 0.0;
127        self.sum_bb = 0.0;
128        self.sum_ab = 0.0;
129    }
130
131    fn warmup_period(&self) -> usize {
132        self.period
133    }
134
135    fn is_ready(&self) -> bool {
136        self.window.len() == self.period
137    }
138
139    fn name(&self) -> &'static str {
140        "BetaNeutralSpread"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::traits::BatchExt;
148    use approx::assert_relative_eq;
149
150    #[test]
151    fn rejects_period_below_two() {
152        assert!(BetaNeutralSpread::new(1).is_err());
153        assert!(BetaNeutralSpread::new(2).is_ok());
154    }
155
156    #[test]
157    fn accessors_and_metadata() {
158        let s = BetaNeutralSpread::new(20).unwrap();
159        assert_eq!(s.period(), 20);
160        assert_eq!(s.warmup_period(), 20);
161        assert_eq!(s.name(), "BetaNeutralSpread");
162        assert!(!s.is_ready());
163    }
164
165    #[test]
166    fn warmup_returns_none() {
167        let mut s = BetaNeutralSpread::new(3).unwrap();
168        assert_eq!(s.update((1.0, 1.0)), None);
169        assert_eq!(s.update((2.0, 2.0)), None);
170        assert!(s.update((3.0, 3.0)).is_some());
171        assert!(s.is_ready());
172    }
173
174    #[test]
175    fn perfect_linear_relationship_has_zero_spread() {
176        let pairs: Vec<(f64, f64)> = (0..40)
177            .map(|t| {
178                let b = 100.0 + f64::from(t);
179                (2.0 * b + 5.0, b)
180            })
181            .collect();
182        let last = BetaNeutralSpread::new(20)
183            .unwrap()
184            .batch(&pairs)
185            .into_iter()
186            .flatten()
187            .last()
188            .unwrap();
189        assert_relative_eq!(last, 0.0, epsilon = 1e-6);
190    }
191
192    #[test]
193    fn dislocation_produces_nonzero_spread() {
194        // a tracks 2·b, then the last bar jumps up ⇒ positive residual.
195        let mut pairs: Vec<(f64, f64)> = (0..19)
196            .map(|t| {
197                let b = 100.0 + f64::from(t);
198                (2.0 * b + 5.0, b)
199            })
200            .collect();
201        pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0));
202        let last = BetaNeutralSpread::new(20)
203            .unwrap()
204            .batch(&pairs)
205            .into_iter()
206            .flatten()
207            .last()
208            .unwrap();
209        assert!(last > 1.0, "spread {last}");
210    }
211
212    #[test]
213    fn flat_b_falls_back_to_demeaned_a() {
214        // b constant ⇒ β = 0 ⇒ spread = a − mean(a). Last window of a = 0..9,
215        // mean = 4.5, last a = 9 ⇒ spread = 4.5.
216        let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect();
217        let last = BetaNeutralSpread::new(10)
218            .unwrap()
219            .batch(&pairs)
220            .into_iter()
221            .flatten()
222            .last()
223            .unwrap();
224        assert_relative_eq!(last, 4.5, epsilon = 1e-12);
225    }
226
227    #[test]
228    fn reset_clears_state() {
229        let mut s = BetaNeutralSpread::new(4).unwrap();
230        s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]);
231        assert!(s.is_ready());
232        s.reset();
233        assert!(!s.is_ready());
234        assert_eq!(s.update((1.0, 1.0)), None);
235    }
236
237    #[test]
238    fn batch_equals_streaming() {
239        let pairs: Vec<(f64, f64)> = (0..60)
240            .map(|t| {
241                let b = 30.0 + 0.7 * f64::from(t);
242                (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
243            })
244            .collect();
245        let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs);
246        let mut s = BetaNeutralSpread::new(20).unwrap();
247        let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
248        assert_eq!(batch, streamed);
249    }
250
251    #[test]
252    fn non_finite_input_returns_none() {
253        let mut s = BetaNeutralSpread::new(3).unwrap();
254        assert_eq!(s.update((f64::NAN, 1.0)), None);
255        assert_eq!(s.update((1.0, f64::INFINITY)), None);
256        // The rejected ticks leave no trace: a fresh window still warms up.
257        assert_eq!(s.update((1.0, 2.0)), None);
258        assert_eq!(s.update((2.0, 5.0)), None);
259        assert!(s.update((3.0, 7.0)).is_some());
260    }
261}