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 self.window.len() == self.period {
84            self.window.pop_front();
85        }
86        self.window.push_back(a - b);
87        if self.window.len() < self.period {
88            return None;
89        }
90        // OLS slope λ of Δsₜ on sₜ₋₁ over the window.
91        let spreads: Vec<f64> = self.window.iter().copied().collect();
92        let count = (spreads.len() - 1) as f64;
93        let mut sum_level = 0.0;
94        let mut sum_delta = 0.0;
95        let mut sum_ll = 0.0;
96        let mut sum_ld = 0.0;
97        for pair in spreads.windows(2) {
98            let level = pair[0];
99            let delta = pair[1] - pair[0];
100            sum_level += level;
101            sum_delta += delta;
102            sum_ll += level * level;
103            sum_ld += level * delta;
104        }
105        let mean_level = sum_level / count;
106        let mean_delta = sum_delta / count;
107        let var_level = sum_ll / count - mean_level * mean_level;
108        if var_level <= 0.0 {
109            // Flat spread: the regression has no defined slope.
110            return Some(0.0);
111        }
112        let cov = sum_ld / count - mean_level * mean_delta;
113        let lambda = cov / var_level;
114        if lambda >= 0.0 {
115            // Not mean-reverting (random walk or diverging): no finite half-life.
116            return Some(0.0);
117        }
118        Some(-std::f64::consts::LN_2 / lambda)
119    }
120
121    fn reset(&mut self) {
122        self.window.clear();
123    }
124
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    fn is_ready(&self) -> bool {
130        self.window.len() == self.period
131    }
132
133    fn name(&self) -> &'static str {
134        "OuHalfLife"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142
143    #[test]
144    fn rejects_period_below_three() {
145        assert!(OuHalfLife::new(2).is_err());
146        assert!(OuHalfLife::new(3).is_ok());
147    }
148
149    #[test]
150    fn accessors_and_metadata() {
151        let hl = OuHalfLife::new(30).unwrap();
152        assert_eq!(hl.period(), 30);
153        assert_eq!(hl.warmup_period(), 30);
154        assert_eq!(hl.name(), "OuHalfLife");
155        assert!(!hl.is_ready());
156    }
157
158    #[test]
159    fn warmup_returns_none() {
160        let mut hl = OuHalfLife::new(4).unwrap();
161        assert_eq!(hl.update((1.0, 0.0)), None);
162        assert_eq!(hl.update((2.0, 0.0)), None);
163        assert_eq!(hl.update((3.0, 0.0)), None);
164        assert!(hl.update((4.0, 0.0)).is_some());
165        assert!(hl.is_ready());
166    }
167
168    #[test]
169    fn mean_reverting_spread_has_positive_half_life() {
170        // Fast sinusoidal spread around zero ⇒ strong mean reversion.
171        let pairs: Vec<(f64, f64)> = (0..120)
172            .map(|t| {
173                let b = 100.0 + f64::from(t);
174                let a = b + 2.0 * (f64::from(t) * 0.9).sin();
175                (a, b)
176            })
177            .collect();
178        let last = OuHalfLife::new(40)
179            .unwrap()
180            .batch(&pairs)
181            .into_iter()
182            .flatten()
183            .last()
184            .unwrap();
185        assert!(last > 0.0 && last < 40.0, "half-life {last}");
186    }
187
188    #[test]
189    fn trending_spread_has_zero_half_life() {
190        // Spread = a − b grows monotonically (λ ≥ 0) ⇒ no finite half-life.
191        let pairs: Vec<(f64, f64)> = (0..40)
192            .map(|t| (2.0 * f64::from(t), f64::from(t)))
193            .collect();
194        let last = OuHalfLife::new(20)
195            .unwrap()
196            .batch(&pairs)
197            .into_iter()
198            .flatten()
199            .last()
200            .unwrap();
201        assert_eq!(last, 0.0);
202    }
203
204    #[test]
205    fn flat_spread_returns_zero() {
206        // a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
207        let pairs: Vec<(f64, f64)> = (0..30)
208            .map(|t| (5.0 + f64::from(t), f64::from(t)))
209            .collect();
210        let last = OuHalfLife::new(10)
211            .unwrap()
212            .batch(&pairs)
213            .into_iter()
214            .flatten()
215            .last()
216            .unwrap();
217        assert_eq!(last, 0.0);
218    }
219
220    #[test]
221    fn reset_clears_state() {
222        let mut hl = OuHalfLife::new(5).unwrap();
223        for t in 0..10 {
224            hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
225        }
226        assert!(hl.is_ready());
227        hl.reset();
228        assert!(!hl.is_ready());
229        assert_eq!(hl.update((1.0, 0.0)), None);
230    }
231
232    #[test]
233    fn batch_equals_streaming() {
234        let pairs: Vec<(f64, f64)> = (0..80)
235            .map(|t| {
236                let b = 50.0 + 0.5 * f64::from(t);
237                (b + (f64::from(t) * 0.6).sin(), b)
238            })
239            .collect();
240        let batch = OuHalfLife::new(25).unwrap().batch(&pairs);
241        let mut hl = OuHalfLife::new(25).unwrap();
242        let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
243        assert_eq!(batch, streamed);
244    }
245}