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 self.window.len() == self.period {
92            let (oa, ob) = self.window.pop_front().expect("non-empty");
93            self.sum_a -= oa;
94            self.sum_b -= ob;
95            self.sum_bb -= ob * ob;
96            self.sum_ab -= oa * ob;
97        }
98        self.window.push_back((a, b));
99        self.sum_a += a;
100        self.sum_b += b;
101        self.sum_bb += b * b;
102        self.sum_ab += a * b;
103        if self.window.len() < self.period {
104            return None;
105        }
106        let n = self.period as f64;
107        let mean_a = self.sum_a / n;
108        let mean_b = self.sum_b / n;
109        let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
110        let (beta, intercept) = if var_b == 0.0 {
111            (0.0, mean_a)
112        } else {
113            let cov = self.sum_ab / n - mean_a * mean_b;
114            let slope = cov / var_b;
115            (slope, mean_a - slope * mean_b)
116        };
117        Some(a - (intercept + beta * b))
118    }
119
120    fn reset(&mut self) {
121        self.window.clear();
122        self.sum_a = 0.0;
123        self.sum_b = 0.0;
124        self.sum_bb = 0.0;
125        self.sum_ab = 0.0;
126    }
127
128    fn warmup_period(&self) -> usize {
129        self.period
130    }
131
132    fn is_ready(&self) -> bool {
133        self.window.len() == self.period
134    }
135
136    fn name(&self) -> &'static str {
137        "BetaNeutralSpread"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::traits::BatchExt;
145    use approx::assert_relative_eq;
146
147    #[test]
148    fn rejects_period_below_two() {
149        assert!(BetaNeutralSpread::new(1).is_err());
150        assert!(BetaNeutralSpread::new(2).is_ok());
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let s = BetaNeutralSpread::new(20).unwrap();
156        assert_eq!(s.period(), 20);
157        assert_eq!(s.warmup_period(), 20);
158        assert_eq!(s.name(), "BetaNeutralSpread");
159        assert!(!s.is_ready());
160    }
161
162    #[test]
163    fn warmup_returns_none() {
164        let mut s = BetaNeutralSpread::new(3).unwrap();
165        assert_eq!(s.update((1.0, 1.0)), None);
166        assert_eq!(s.update((2.0, 2.0)), None);
167        assert!(s.update((3.0, 3.0)).is_some());
168        assert!(s.is_ready());
169    }
170
171    #[test]
172    fn perfect_linear_relationship_has_zero_spread() {
173        let pairs: Vec<(f64, f64)> = (0..40)
174            .map(|t| {
175                let b = 100.0 + f64::from(t);
176                (2.0 * b + 5.0, b)
177            })
178            .collect();
179        let last = BetaNeutralSpread::new(20)
180            .unwrap()
181            .batch(&pairs)
182            .into_iter()
183            .flatten()
184            .last()
185            .unwrap();
186        assert_relative_eq!(last, 0.0, epsilon = 1e-6);
187    }
188
189    #[test]
190    fn dislocation_produces_nonzero_spread() {
191        // a tracks 2·b, then the last bar jumps up ⇒ positive residual.
192        let mut pairs: Vec<(f64, f64)> = (0..19)
193            .map(|t| {
194                let b = 100.0 + f64::from(t);
195                (2.0 * b + 5.0, b)
196            })
197            .collect();
198        pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0));
199        let last = BetaNeutralSpread::new(20)
200            .unwrap()
201            .batch(&pairs)
202            .into_iter()
203            .flatten()
204            .last()
205            .unwrap();
206        assert!(last > 1.0, "spread {last}");
207    }
208
209    #[test]
210    fn flat_b_falls_back_to_demeaned_a() {
211        // b constant ⇒ β = 0 ⇒ spread = a − mean(a). Last window of a = 0..9,
212        // mean = 4.5, last a = 9 ⇒ spread = 4.5.
213        let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect();
214        let last = BetaNeutralSpread::new(10)
215            .unwrap()
216            .batch(&pairs)
217            .into_iter()
218            .flatten()
219            .last()
220            .unwrap();
221        assert_relative_eq!(last, 4.5, epsilon = 1e-12);
222    }
223
224    #[test]
225    fn reset_clears_state() {
226        let mut s = BetaNeutralSpread::new(4).unwrap();
227        s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]);
228        assert!(s.is_ready());
229        s.reset();
230        assert!(!s.is_ready());
231        assert_eq!(s.update((1.0, 1.0)), None);
232    }
233
234    #[test]
235    fn batch_equals_streaming() {
236        let pairs: Vec<(f64, f64)> = (0..60)
237            .map(|t| {
238                let b = 30.0 + 0.7 * f64::from(t);
239                (1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
240            })
241            .collect();
242        let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs);
243        let mut s = BetaNeutralSpread::new(20).unwrap();
244        let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
245        assert_eq!(batch, streamed);
246    }
247}