Skip to main content

wickra_core/indicators/
pairwise_beta.rs

1//! Pairwise Beta — rolling OLS slope of one asset's log-returns on another's.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Beta of asset `a`'s **log-returns** on asset `b`'s log-returns.
9///
10/// Each `update` receives one `(a, b)` pair of raw **prices**. Internally the
11/// indicator differences consecutive prices into log-returns
12/// `rₜ = ln(pₜ / pₜ₋₁)` and runs a rolling ordinary-least-squares regression of
13/// `a`'s returns on `b`'s returns over the trailing window of `period` return
14/// pairs:
15///
16/// ```text
17/// cov_ab = (1/n) · Σ rₐ·r_b − r̄ₐ·r̄_b
18/// var_b  = (1/n) · Σ r_b²   − r̄_b²
19/// Beta   = cov_ab / var_b
20/// ```
21///
22/// This is the slope of the OLS line and measures how much asset `a` moves, in
23/// return space, for a unit return of asset `b`. A reading of `1.0` means the
24/// two move together one-for-one; `2.0` means `a` typically doubles `b`'s
25/// moves; negative readings signal an inverse relationship and the basis for a
26/// hedge.
27///
28/// This differs from [`crate::Beta`], which regresses the raw inputs it is
29/// fed. `PairwiseBeta` always works in return space: feed it raw price levels
30/// and it computes the returns for you, which is the conventional way to
31/// measure cross-asset Beta (a Beta on price *levels* is dominated by the
32/// shared trend and rarely what you want).
33///
34/// Each `update` is O(1): four running sums (`Σrₐ`, `Σr_b`, `Σr_b²`,
35/// `Σrₐ·r_b`) are maintained as the window of returns slides. A flat `b`
36/// window has zero return variance and Beta is undefined; the indicator
37/// returns `0` in that case rather than producing `NaN`.
38///
39/// Prices must be strictly positive and finite for the log-return to be
40/// defined. A non-positive or non-finite price breaks the return chain: that
41/// sample is dropped and the next valid price re-seeds the previous-price
42/// reference, exactly as a real feed would resume after a bad tick.
43///
44/// # Example
45///
46/// ```
47/// use wickra_core::{Indicator, PairwiseBeta};
48///
49/// let mut indicator = PairwiseBeta::new(10).unwrap();
50/// let mut last = None;
51/// for i in 0..30 {
52///     // A varying (non-constant-return) positive price path.
53///     let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
54///     // `a = b²`, so a's log-returns are exactly twice b's.
55///     last = indicator.update((b * b, b));
56/// }
57/// assert!((last.unwrap() - 2.0).abs() < 1e-9);
58/// ```
59#[derive(Debug, Clone)]
60pub struct PairwiseBeta {
61    period: usize,
62    prev: Option<(f64, f64)>,
63    window: VecDeque<(f64, f64)>,
64    sum_a: f64,
65    sum_b: f64,
66    sum_bb: f64,
67    sum_ab: f64,
68}
69
70impl PairwiseBeta {
71    /// Construct a new rolling pairwise Beta over `period` return pairs.
72    ///
73    /// # Errors
74    /// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs at
75    /// least two returns).
76    pub fn new(period: usize) -> Result<Self> {
77        if period < 2 {
78            return Err(Error::InvalidPeriod {
79                message: "pairwise beta needs period >= 2",
80            });
81        }
82        Ok(Self {
83            period,
84            prev: None,
85            window: VecDeque::with_capacity(period),
86            sum_a: 0.0,
87            sum_b: 0.0,
88            sum_bb: 0.0,
89            sum_ab: 0.0,
90        })
91    }
92
93    /// Configured period (number of return pairs in the rolling window).
94    pub const fn period(&self) -> usize {
95        self.period
96    }
97
98    fn push_return(&mut self, ra: f64, rb: f64) -> Option<f64> {
99        if self.window.len() == self.period {
100            let (oa, ob) = self.window.pop_front().expect("non-empty");
101            self.sum_a -= oa;
102            self.sum_b -= ob;
103            self.sum_bb -= ob * ob;
104            self.sum_ab -= oa * ob;
105        }
106        self.window.push_back((ra, rb));
107        self.sum_a += ra;
108        self.sum_b += rb;
109        self.sum_bb += rb * rb;
110        self.sum_ab += ra * rb;
111        if self.window.len() < self.period {
112            return None;
113        }
114        let n = self.period as f64;
115        let mean_a = self.sum_a / n;
116        let mean_b = self.sum_b / n;
117        let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
118        let cov = self.sum_ab / n - mean_a * mean_b;
119        if var_b == 0.0 {
120            // A flat benchmark-return window has no defined beta.
121            return Some(0.0);
122        }
123        Some(cov / var_b)
124    }
125}
126
127impl Indicator for PairwiseBeta {
128    /// `(a, b)` price pair.
129    type Input = (f64, f64);
130    type Output = f64;
131
132    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
133        let (a, b) = input;
134        if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
135            // Bad tick: drop it and restart the return chain.
136            self.prev = None;
137            return None;
138        }
139        let Some((pa, pb)) = self.prev else {
140            self.prev = Some((a, b));
141            return None;
142        };
143        self.prev = Some((a, b));
144        let ra = (a / pa).ln();
145        let rb = (b / pb).ln();
146        self.push_return(ra, rb)
147    }
148
149    fn reset(&mut self) {
150        self.prev = None;
151        self.window.clear();
152        self.sum_a = 0.0;
153        self.sum_b = 0.0;
154        self.sum_bb = 0.0;
155        self.sum_ab = 0.0;
156    }
157
158    fn warmup_period(&self) -> usize {
159        // One prior price to seed, then `period` return pairs.
160        self.period + 1
161    }
162
163    fn is_ready(&self) -> bool {
164        self.window.len() == self.period
165    }
166
167    fn name(&self) -> &'static str {
168        "PairwiseBeta"
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::traits::BatchExt;
176    use approx::assert_relative_eq;
177
178    #[test]
179    fn rejects_period_below_two() {
180        assert!(PairwiseBeta::new(0).is_err());
181        assert!(PairwiseBeta::new(1).is_err());
182        assert!(PairwiseBeta::new(2).is_ok());
183    }
184
185    #[test]
186    fn accessors_and_metadata() {
187        let b = PairwiseBeta::new(14).unwrap();
188        assert_eq!(b.period(), 14);
189        assert_eq!(b.warmup_period(), 15);
190        assert_eq!(b.name(), "PairwiseBeta");
191    }
192
193    #[test]
194    fn squared_price_gives_beta_two() {
195        // a = b² ⇒ a's log-returns are exactly 2× b's ⇒ beta = 2.
196        let pairs: Vec<(f64, f64)> = (0..20)
197            .map(|i| {
198                let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
199                (b * b, b)
200            })
201            .collect();
202        let last = PairwiseBeta::new(5)
203            .unwrap()
204            .batch(&pairs)
205            .into_iter()
206            .flatten()
207            .last()
208            .unwrap();
209        assert_relative_eq!(last, 2.0, epsilon = 1e-9);
210    }
211
212    #[test]
213    fn inverse_price_gives_beta_minus_one() {
214        // a = 1/b ⇒ a's log-returns are −1× b's ⇒ beta = −1.
215        let pairs: Vec<(f64, f64)> = (0..20)
216            .map(|i| {
217                let b = 100.0 + 10.0 * (f64::from(i) * 0.5).sin();
218                (1.0 / b, b)
219            })
220            .collect();
221        let last = PairwiseBeta::new(5)
222            .unwrap()
223            .batch(&pairs)
224            .into_iter()
225            .flatten()
226            .last()
227            .unwrap();
228        assert_relative_eq!(last, -1.0, epsilon = 1e-9);
229    }
230
231    #[test]
232    fn flat_benchmark_returns_zero() {
233        // b constant ⇒ zero return variance ⇒ beta defined as 0.
234        let pairs: Vec<(f64, f64)> = (0..10).map(|i| (100.0 * 1.01_f64.powi(i), 7.0)).collect();
235        let last = PairwiseBeta::new(5)
236            .unwrap()
237            .batch(&pairs)
238            .into_iter()
239            .flatten()
240            .last()
241            .unwrap();
242        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
243    }
244
245    #[test]
246    fn bad_tick_breaks_return_chain() {
247        let mut b = PairwiseBeta::new(3).unwrap();
248        // Seed, one good return, then a non-positive price drops the chain.
249        assert_eq!(b.update((100.0, 100.0)), None);
250        assert_eq!(b.update((101.0, 101.0)), None);
251        assert_eq!(b.update((0.0, 50.0)), None); // bad tick, prev reset
252        assert!(!b.is_ready());
253        // A non-finite price is rejected the same way.
254        assert_eq!(b.update((f64::NAN, 50.0)), None);
255        assert!(!b.is_ready());
256        // Recovery: subsequent valid prices rebuild the window cleanly.
257        for i in 0..5 {
258            let p = 100.0 * 1.01_f64.powi(i);
259            b.update((p * p, p));
260        }
261        assert!(b.is_ready());
262    }
263
264    #[test]
265    fn reset_clears_state() {
266        let mut b = PairwiseBeta::new(3).unwrap();
267        for i in 0..6 {
268            let p = 100.0 * 1.01_f64.powi(i);
269            b.update((p * p, p));
270        }
271        assert!(b.is_ready());
272        b.reset();
273        assert!(!b.is_ready());
274        assert_eq!(b.update((100.0, 100.0)), None);
275    }
276
277    #[test]
278    fn batch_equals_streaming() {
279        let pairs: Vec<(f64, f64)> = (0..60)
280            .map(|i| {
281                let t = f64::from(i);
282                let b = 100.0 + 5.0 * t.sin();
283                let a = 100.0 + 3.0 * t.sin() + 0.5 * t.cos();
284                (a, b)
285            })
286            .collect();
287        let batch = PairwiseBeta::new(14).unwrap().batch(&pairs);
288        let mut b = PairwiseBeta::new(14).unwrap();
289        let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
290        assert_eq!(batch, streamed);
291    }
292}