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::indicators::rolling_moments::ShiftedPairMoments;
7use crate::traits::Indicator;
8
9/// The beta-neutral spread between two assets — the residual of a rolling
10/// ordinary-least-squares regression of `a` on `b`.
11///
12/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
13/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by
14/// OLS and reports the **current** residual:
15///
16/// ```text
17/// β = cov(a, b) / var(b)        α = ā − β · b̄
18/// spread = a_now − (α + β · b_now)
19/// ```
20///
21/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market-
22/// (beta-)neutral: it is what is left after the common factor is hedged out.
23/// Positive means `a` is rich relative to its hedge, negative means cheap — the
24/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises
25/// this residual into a z-score and [`crate::Cointegration`] bundles it with an
26/// ADF test, this indicator returns the residual itself, in price units.
27///
28/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the
29/// indicator falls back to `β = 0`, so the spread becomes `a_now − ā`.
30///
31/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are
32/// maintained as the window slides.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{BetaNeutralSpread, Indicator};
38///
39/// let mut s = BetaNeutralSpread::new(20).unwrap();
40/// let mut last = None;
41/// for t in 0..40 {
42///     let b = 100.0 + f64::from(t);
43///     // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0.
44///     last = s.update((2.0 * b + 5.0, b));
45/// }
46/// assert!(last.unwrap().abs() < 1e-6);
47/// ```
48#[derive(Debug, Clone)]
49pub struct BetaNeutralSpread {
50    period: usize,
51    window: VecDeque<(f64, f64)>,
52    moments: ShiftedPairMoments,
53}
54
55impl BetaNeutralSpread {
56    /// Construct a new beta-neutral spread.
57    ///
58    /// # Errors
59    /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope
60    /// needs at least two points.
61    pub fn new(period: usize) -> Result<Self> {
62        if period < 2 {
63            return Err(Error::InvalidPeriod {
64                message: "beta-neutral spread needs period >= 2",
65            });
66        }
67        if period > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        Ok(Self {
73            period,
74            window: VecDeque::with_capacity(period),
75            moments: ShiftedPairMoments::new(),
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    #[inline]
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            let (oa, ob) = self.window.pop_front().expect("non-empty");
97            self.moments.evict(oa, ob);
98        }
99        self.window.push_back((a, b));
100        self.moments.push(a, b);
101        if self.moments.needs_reseed(self.period) {
102            self.moments.reseed(self.window.iter().copied());
103        }
104        if self.window.len() < self.period {
105            return None;
106        }
107        let mean_a = self.moments.mean_a(self.period);
108        let mean_b = self.moments.mean_b(self.period);
109        let var_b = self.moments.var_b(self.period);
110        let (beta, intercept) = if var_b == 0.0 {
111            (0.0, mean_a)
112        } else {
113            let cov = self.moments.cov(self.period);
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.moments.reset();
123    }
124
125    #[inline]
126    fn warmup_period(&self) -> usize {
127        self.period
128    }
129
130    #[inline]
131    fn is_ready(&self) -> bool {
132        self.window.len() == self.period
133    }
134
135    #[inline]
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
248    #[test]
249    fn non_finite_input_returns_none() {
250        let mut s = BetaNeutralSpread::new(3).unwrap();
251        assert_eq!(s.update((f64::NAN, 1.0)), None);
252        assert_eq!(s.update((1.0, f64::INFINITY)), None);
253        // The rejected ticks leave no trace: a fresh window still warms up.
254        assert_eq!(s.update((1.0, 2.0)), None);
255        assert_eq!(s.update((2.0, 5.0)), None);
256        assert!(s.update((3.0, 7.0)).is_some());
257    }
258}