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::traits::Indicator;
7
8/// Half-life of mean reversion of the spread `a − b`, from an Ornstein–Uhlenbeck
9/// fit.
10///
11/// Each `update` takes one `(a, b)` price pair and forms the spread
12/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
13/// fits the discrete Ornstein–Uhlenbeck (mean-reverting AR(1)) model by
14/// ordinary least squares of the change on the level:
15///
16/// ```text
17/// Δsₜ = λ · sₜ₋₁ + c + εₜ
18/// half_life = −ln(2) / λ        (only when λ < 0)
19/// ```
20///
21/// `λ` is the speed of mean reversion: a more negative `λ` pulls the spread back
22/// to its mean faster. The **half-life** is the number of bars for a deviation
23/// to decay by half — the single most useful number for sizing a pairs trade's
24/// holding period and look-back. When the spread is not mean-reverting
25/// (`λ ≥ 0`, a random walk or a trend) or the regression is degenerate (a flat
26/// spread), the indicator returns `0`, meaning "no finite half-life".
27///
28/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
29/// running geometry. Output is in bars and is always `≥ 0`.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, OuHalfLife};
35///
36/// let mut hl = OuHalfLife::new(40).unwrap();
37/// let mut last = None;
38/// for t in 0..120 {
39///     let b = 100.0 + f64::from(t);
40///     // `a` hugs `b` with a fast mean-reverting wobble ⇒ short half-life.
41///     let a = b + 2.0 * (f64::from(t) * 0.9).sin();
42///     last = hl.update((a, b));
43/// }
44/// let half_life = last.unwrap();
45/// assert!(half_life > 0.0 && half_life < 40.0);
46/// ```
47#[derive(Debug, Clone)]
48pub struct OuHalfLife {
49    period: usize,
50    window: VecDeque<f64>,
51}
52
53impl OuHalfLife {
54    /// Construct a new Ornstein–Uhlenbeck half-life estimator.
55    ///
56    /// # Errors
57    /// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
58    /// needs at least two observations (a slope and an intercept).
59    pub fn new(period: usize) -> Result<Self> {
60        if period < 3 {
61            return Err(Error::InvalidPeriod {
62                message: "OU half-life needs period >= 3",
63            });
64        }
65        Ok(Self {
66            period,
67            window: VecDeque::with_capacity(period),
68        })
69    }
70
71    /// Configured look-back window of spreads.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75}
76
77impl Indicator for OuHalfLife {
78    type Input = (f64, f64);
79    type Output = f64;
80
81    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
82        let (a, b) = input;
83        if !a.is_finite() || !b.is_finite() {
84            return None;
85        }
86        if self.window.len() == self.period {
87            self.window.pop_front();
88        }
89        self.window.push_back(a - b);
90        if self.window.len() < self.period {
91            return None;
92        }
93        // OLS slope λ of Δsₜ on sₜ₋₁ over the window.
94        let spreads: Vec<f64> = self.window.iter().copied().collect();
95        let count = (spreads.len() - 1) as f64;
96        let mut sum_level = 0.0;
97        let mut sum_delta = 0.0;
98        let mut sum_ll = 0.0;
99        let mut sum_ld = 0.0;
100        for pair in spreads.windows(2) {
101            let level = pair[0];
102            let delta = pair[1] - pair[0];
103            sum_level += level;
104            sum_delta += delta;
105            sum_ll += level * level;
106            sum_ld += level * delta;
107        }
108        let mean_level = sum_level / count;
109        let mean_delta = sum_delta / count;
110        let var_level = sum_ll / count - mean_level * mean_level;
111        if var_level <= 0.0 {
112            // Flat spread: the regression has no defined slope.
113            return Some(0.0);
114        }
115        let cov = sum_ld / count - mean_level * mean_delta;
116        let lambda = cov / var_level;
117        if lambda >= 0.0 {
118            // Not mean-reverting (random walk or diverging): no finite half-life.
119            return Some(0.0);
120        }
121        Some(-std::f64::consts::LN_2 / lambda)
122    }
123
124    fn reset(&mut self) {
125        self.window.clear();
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        "OuHalfLife"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::traits::BatchExt;
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}