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