Skip to main content

wickra_core/indicators/
ou_half_life.rs

1//! Ornstein–Uhlenbeck half-life of mean reversion for the spread of two series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::centred_moments;
7use crate::traits::Indicator;
8
9/// Half-life of mean reversion of the spread `a − b`, from an Ornstein–Uhlenbeck
10/// fit.
11///
12/// Each `update` takes one `(a, b)` price pair and forms the spread
13/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
14/// fits the discrete Ornstein–Uhlenbeck (mean-reverting AR(1)) model by
15/// ordinary least squares of the change on the level:
16///
17/// ```text
18/// Δsₜ = λ · sₜ₋₁ + c + εₜ
19/// half_life = −ln(2) / λ        (only when λ < 0)
20/// ```
21///
22/// `λ` is the speed of mean reversion: a more negative `λ` pulls the spread back
23/// to its mean faster. The **half-life** is the number of bars for a deviation
24/// to decay by half — the single most useful number for sizing a pairs trade's
25/// holding period and look-back. When the spread is not mean-reverting
26/// (`λ ≥ 0`, a random walk or a trend) or the regression is degenerate (a flat
27/// spread), the indicator returns `0`, meaning "no finite half-life".
28///
29/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
30/// running geometry. Output is in bars and is always `≥ 0`.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, OuHalfLife};
36///
37/// let mut hl = OuHalfLife::new(40).unwrap();
38/// let mut last = None;
39/// for t in 0..120 {
40///     let b = 100.0 + f64::from(t);
41///     // `a` hugs `b` with a fast mean-reverting wobble ⇒ short half-life.
42///     let a = b + 2.0 * (f64::from(t) * 0.9).sin();
43///     last = hl.update((a, b));
44/// }
45/// let half_life = last.unwrap();
46/// assert!(half_life > 0.0 && half_life < 40.0);
47/// ```
48#[derive(Debug, Clone)]
49pub struct OuHalfLife {
50    period: usize,
51    window: VecDeque<f64>,
52}
53
54impl OuHalfLife {
55    /// Construct a new Ornstein–Uhlenbeck half-life estimator.
56    ///
57    /// # Errors
58    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
59    /// needs at least two observations (a slope and an intercept).
60    pub fn new(period: usize) -> Result<Self> {
61        if period < 3 {
62            return Err(Error::InvalidPeriod {
63                message: "OU half-life needs period >= 3",
64            });
65        }
66        if period > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            period,
73            window: VecDeque::with_capacity(period),
74        })
75    }
76
77    /// Configured look-back window of spreads.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81}
82
83impl Indicator for OuHalfLife {
84    type Input = (f64, f64);
85    type Output = f64;
86
87    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
88        let (a, b) = input;
89        if !a.is_finite() || !b.is_finite() {
90            return None;
91        }
92        if self.window.len() == self.period {
93            self.window.pop_front();
94        }
95        self.window.push_back(a - b);
96        if self.window.len() < self.period {
97            return None;
98        }
99        // OLS slope λ of Δsₜ on sₜ₋₁ over the window. The (level, change)
100        // pairs are produced lazily and traversed twice rather than collected,
101        // so this no longer allocates per update either.
102        let moments = centred_moments(
103            self.window
104                .iter()
105                .zip(self.window.iter().skip(1))
106                .map(|(&level, &next)| (level, next - level)),
107        );
108        if moments.var_x <= 0.0 {
109            // Flat spread: the regression has no defined slope.
110            return Some(0.0);
111        }
112        let lambda = moments.cov / moments.var_x;
113        if lambda >= 0.0 {
114            // Not mean-reverting (random walk or diverging): no finite half-life.
115            return Some(0.0);
116        }
117        Some(-std::f64::consts::LN_2 / lambda)
118    }
119
120    fn reset(&mut self) {
121        self.window.clear();
122    }
123
124    #[inline]
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.window.len() == self.period
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "OuHalfLife"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144    use approx::assert_relative_eq;
145
146    #[test]
147    fn rejects_period_below_three() {
148        assert!(OuHalfLife::new(2).is_err());
149        assert!(OuHalfLife::new(3).is_ok());
150    }
151
152    #[test]
153    fn accessors_and_metadata() {
154        let hl = OuHalfLife::new(30).unwrap();
155        assert_eq!(hl.period(), 30);
156        assert_eq!(hl.warmup_period(), 30);
157        assert_eq!(hl.name(), "OuHalfLife");
158        assert!(!hl.is_ready());
159    }
160
161    #[test]
162    fn warmup_returns_none() {
163        let mut hl = OuHalfLife::new(4).unwrap();
164        assert_eq!(hl.update((1.0, 0.0)), None);
165        assert_eq!(hl.update((2.0, 0.0)), None);
166        assert_eq!(hl.update((3.0, 0.0)), None);
167        assert!(hl.update((4.0, 0.0)).is_some());
168        assert!(hl.is_ready());
169    }
170
171    #[test]
172    fn mean_reverting_spread_has_positive_half_life() {
173        // Fast sinusoidal spread around zero ⇒ strong mean reversion.
174        let pairs: Vec<(f64, f64)> = (0..120)
175            .map(|t| {
176                let b = 100.0 + f64::from(t);
177                let a = b + 2.0 * (f64::from(t) * 0.9).sin();
178                (a, b)
179            })
180            .collect();
181        let last = OuHalfLife::new(40)
182            .unwrap()
183            .batch(&pairs)
184            .into_iter()
185            .flatten()
186            .last()
187            .unwrap();
188        assert!(last > 0.0 && last < 40.0, "half-life {last}");
189    }
190
191    #[test]
192    fn trending_spread_has_zero_half_life() {
193        // Spread = a − b grows monotonically (λ ≥ 0) ⇒ no finite half-life.
194        let pairs: Vec<(f64, f64)> = (0..40)
195            .map(|t| (2.0 * f64::from(t), f64::from(t)))
196            .collect();
197        let last = OuHalfLife::new(20)
198            .unwrap()
199            .batch(&pairs)
200            .into_iter()
201            .flatten()
202            .last()
203            .unwrap();
204        assert_eq!(last, 0.0);
205    }
206
207    #[test]
208    fn flat_spread_returns_zero() {
209        // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
210        let pairs: Vec<(f64, f64)> = (0..30)
211            .map(|t| (5.0 + f64::from(t), f64::from(t)))
212            .collect();
213        let last = OuHalfLife::new(10)
214            .unwrap()
215            .batch(&pairs)
216            .into_iter()
217            .flatten()
218            .last()
219            .unwrap();
220        assert_eq!(last, 0.0);
221    }
222
223    #[test]
224    fn reset_clears_state() {
225        let mut hl = OuHalfLife::new(5).unwrap();
226        for t in 0..10 {
227            hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
228        }
229        assert!(hl.is_ready());
230        hl.reset();
231        assert!(!hl.is_ready());
232        assert_eq!(hl.update((1.0, 0.0)), None);
233    }
234
235    #[test]
236    fn batch_equals_streaming() {
237        let pairs: Vec<(f64, f64)> = (0..80)
238            .map(|t| {
239                let b = 50.0 + 0.5 * f64::from(t);
240                (b + (f64::from(t) * 0.6).sin(), b)
241            })
242            .collect();
243        let batch = OuHalfLife::new(25).unwrap().batch(&pairs);
244        let mut hl = OuHalfLife::new(25).unwrap();
245        let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
246        assert_eq!(batch, streamed);
247    }
248
249    #[test]
250    fn non_finite_input_returns_none() {
251        let mut hl = OuHalfLife::new(4).unwrap();
252        assert_eq!(hl.update((f64::NAN, 1.0)), None);
253        assert_eq!(hl.update((1.0, f64::INFINITY)), None);
254        // The rejected ticks leave no trace: a fresh window still warms up.
255        assert_eq!(hl.update((1.0, 0.0)), None);
256        assert_eq!(hl.update((2.0, 0.0)), None);
257        assert_eq!(hl.update((3.0, 0.0)), None);
258        assert!(hl.update((4.0, 0.0)).is_some());
259    }
260
261    /// Same defect and same regime as `SpreadAr1Coefficient`: the spread of a
262    /// cointegrated pair sits at a large constant offset with only a small
263    /// wobble on top, and the regression accumulated raw power sums of that
264    /// offset level. On the series below -- a spread of 5000 wobbling by 0.1 --
265    /// the one-pass form deviates from a two-pass reference by 2.8e-06, and it
266    /// degrades as the spread tightens: 2.6e-04 at a wobble of 0.01. The
267    /// half-life is the more sensitive of the two spread regressions because it
268    /// inverts the slope. Centring the window makes it exact.
269    #[test]
270    fn offset_spread_matches_a_two_pass_reference() {
271        const PERIOD: usize = 20;
272        const BARS: usize = 400;
273
274        // The wobble oscillates fast enough to mean-revert several times inside
275        // a 20-bar window, so the regression reports a finite half-life rather
276        // than taking the not-mean-reverting branch.
277        let series: Vec<(f64, f64)> = (0..BARS)
278            .map(|i| {
279                let t = i as f64;
280                let base = 1e5 * (1.0 + 0.002 * (t * 0.01).sin());
281                (base + 5000.0 + 0.1 * (t * 0.9).sin(), base)
282            })
283            .collect();
284
285        let mut ind = OuHalfLife::new(PERIOD).unwrap();
286        let mut spreads: Vec<f64> = Vec::new();
287        let mut mean_reverting = 0_usize;
288        for &(a, b) in &series {
289            let got = ind.update((a, b));
290            spreads.push(a - b);
291            let Some(half_life) = got else { continue };
292            let window = &spreads[spreads.len() - PERIOD..];
293            let levels = &window[..PERIOD - 1];
294            let deltas: Vec<f64> = window.windows(2).map(|p| p[1] - p[0]).collect();
295            let n = (PERIOD - 1) as f64;
296            let mean_level = levels.iter().sum::<f64>() / n;
297            let mean_delta = deltas.iter().sum::<f64>() / n;
298            let var_level = levels
299                .iter()
300                .map(|v| (v - mean_level) * (v - mean_level))
301                .sum::<f64>()
302                / n;
303            let lambda = levels
304                .iter()
305                .zip(&deltas)
306                .map(|(u, v)| (u - mean_level) * (v - mean_delta))
307                .sum::<f64>()
308                / n
309                / var_level;
310            assert!(lambda < 0.0, "the probe series must mean-revert");
311            mean_reverting += 1;
312            assert_relative_eq!(
313                half_life,
314                -std::f64::consts::LN_2 / lambda,
315                max_relative = 1e-12
316            );
317        }
318        assert_eq!(mean_reverting, BARS - ind.warmup_period() + 1);
319    }
320}