Skip to main content

wickra_core/indicators/
spread_hurst.rs

1//! Hurst exponent of the spread of two series, for pairs-trading regime detection.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Hurst exponent of the spread `a − b` over a rolling window.
9///
10/// Each `update` takes one `(a, b)` price pair and forms the spread
11/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
12/// estimates the Hurst exponent `H` from how the variance of `τ`-lagged
13/// differences grows with the lag `τ`:
14///
15/// ```text
16/// V(τ) = mean_t (s_{t+τ} − s_t)²   ∝   τ^(2H)
17/// H    = slope of log V(τ) on log τ, divided by two
18/// ```
19///
20/// `H` classifies the spread's regime:
21///
22/// * `H < 0.5` — **mean-reverting** (anti-persistent): the spread snaps back,
23///   the regime pairs traders want.
24/// * `H ≈ 0.5` — a **random walk**: no exploitable structure.
25/// * `H > 0.5` — **trending** (persistent): the spread keeps diverging.
26///
27/// The fit uses lags `1..=period/4` (at least two). When the spread is flat —
28/// every lagged difference is zero, so the log-regression has fewer than two
29/// usable points — the indicator returns the neutral `0.5`. The output is
30/// clamped to `[0, 1]`.
31///
32/// Each `update` is `O(period · period/4)`, bounded by the fixed window.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, SpreadHurst};
38///
39/// let mut h = SpreadHurst::new(60).unwrap();
40/// let mut last = None;
41/// for t in 0..200 {
42///     let b = 100.0 + f64::from(t);
43///     // A tight oscillating spread is anti-persistent ⇒ H < 0.5.
44///     let a = b + 3.0 * (f64::from(t) * 0.8).sin();
45///     last = h.update((a, b));
46/// }
47/// assert!(last.unwrap() < 0.5);
48/// ```
49#[derive(Debug, Clone)]
50pub struct SpreadHurst {
51    period: usize,
52    max_lag: usize,
53    window: VecDeque<f64>,
54    /// Reusable scratch buffer to avoid allocating per `update`.
55    scratch: Vec<f64>,
56    /// Reusable `(log lag, log variance)` buffers for the regression.
57    log_lag: Vec<f64>,
58    log_var: Vec<f64>,
59}
60
61impl SpreadHurst {
62    /// Construct a new spread Hurst estimator.
63    ///
64    /// # Errors
65    /// Returns [`Error::InvalidPeriod`] if `period < 8` — fewer than eight
66    /// spreads cannot support a two-lag log–log regression.
67    pub fn new(period: usize) -> Result<Self> {
68        if period < 8 {
69            return Err(Error::InvalidPeriod {
70                message: "spread Hurst needs period >= 8",
71            });
72        }
73        if period > crate::error::MAX_PERIOD {
74            return Err(Error::InvalidPeriod {
75                message: crate::error::PERIOD_ABOVE_MAX,
76            });
77        }
78        Ok(Self {
79            period,
80            max_lag: (period / 4).max(2),
81            window: VecDeque::with_capacity(period),
82            scratch: Vec::with_capacity(period),
83            log_lag: Vec::with_capacity((period / 4).max(2)),
84            log_var: Vec::with_capacity((period / 4).max(2)),
85        })
86    }
87
88    /// Configured look-back window of spreads.
89    pub const fn period(&self) -> usize {
90        self.period
91    }
92}
93
94impl Indicator for SpreadHurst {
95    type Input = (f64, f64);
96    type Output = f64;
97
98    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
99        let (a, b) = input;
100        if !a.is_finite() || !b.is_finite() {
101            return None;
102        }
103        if self.window.len() == self.period {
104            self.window.pop_front();
105        }
106        self.window.push_back(a - b);
107        if self.window.len() < self.period {
108            return None;
109        }
110        self.scratch.clear();
111        self.scratch.extend(self.window.iter().copied());
112        let spreads = &self.scratch;
113        // Collect (log τ, log V(τ)) for every lag whose variance is positive.
114        let log_lag = &mut self.log_lag;
115        let log_var = &mut self.log_var;
116        log_lag.clear();
117        log_var.clear();
118        for lag in 1..=self.max_lag {
119            let mut sum_sq = 0.0;
120            let mut count = 0.0;
121            for pair in spreads.windows(lag + 1) {
122                let diff = pair[lag] - pair[0];
123                sum_sq += diff * diff;
124                count += 1.0;
125            }
126            let var = sum_sq / count;
127            if var > 0.0 {
128                log_lag.push((lag as f64).ln());
129                log_var.push(var.ln());
130            }
131        }
132        if log_lag.len() < 2 {
133            // Degenerate (flat) spread: report the random-walk midpoint.
134            return Some(0.5);
135        }
136        let n = log_lag.len() as f64;
137        let mean_lag = log_lag.iter().sum::<f64>() / n;
138        let mean_var = log_var.iter().sum::<f64>() / n;
139        let mut cov = 0.0;
140        let mut var_lag = 0.0;
141        for (lx, lv) in log_lag.iter().zip(log_var.iter()) {
142            cov += (lx - mean_lag) * (lv - mean_var);
143            var_lag += (lx - mean_lag) * (lx - mean_lag);
144        }
145        // `log_lag` holds at least two *distinct* lag logarithms, so the lag
146        // variance is strictly positive — no degenerate-slope guard is needed.
147        let slope = cov / var_lag;
148        Some((slope / 2.0).clamp(0.0, 1.0))
149    }
150
151    fn reset(&mut self) {
152        self.window.clear();
153        self.scratch.clear();
154        self.log_lag.clear();
155        self.log_var.clear();
156    }
157
158    #[inline]
159    fn warmup_period(&self) -> usize {
160        self.period
161    }
162
163    #[inline]
164    fn is_ready(&self) -> bool {
165        self.window.len() == self.period
166    }
167
168    #[inline]
169    fn name(&self) -> &'static str {
170        "SpreadHurst"
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::traits::BatchExt;
178    use approx::assert_relative_eq;
179
180    #[test]
181    fn rejects_period_below_eight() {
182        assert!(SpreadHurst::new(7).is_err());
183        assert!(SpreadHurst::new(8).is_ok());
184    }
185
186    #[test]
187    fn accessors_and_metadata() {
188        let h = SpreadHurst::new(40).unwrap();
189        assert_eq!(h.period(), 40);
190        assert_eq!(h.warmup_period(), 40);
191        assert_eq!(h.name(), "SpreadHurst");
192        assert!(!h.is_ready());
193    }
194
195    #[test]
196    fn warmup_returns_none() {
197        let mut h = SpreadHurst::new(8).unwrap();
198        for t in 0..7 {
199            assert_eq!(h.update((f64::from(t), 0.0)), None);
200        }
201        assert!(h.update((7.0, 0.0)).is_some());
202        assert!(h.is_ready());
203    }
204
205    #[test]
206    fn oscillating_spread_is_anti_persistent() {
207        let pairs: Vec<(f64, f64)> = (0..200)
208            .map(|t| {
209                let b = 100.0 + f64::from(t);
210                (b + 3.0 * (f64::from(t) * 0.8).sin(), b)
211            })
212            .collect();
213        let last = SpreadHurst::new(60)
214            .unwrap()
215            .batch(&pairs)
216            .into_iter()
217            .flatten()
218            .last()
219            .unwrap();
220        assert!(last < 0.5, "H {last}");
221    }
222
223    #[test]
224    fn linear_trend_spread_is_persistent() {
225        // Spread = a − b = t ⇒ τ-lagged differences are all τ ⇒ V(τ) = τ² ⇒ H = 1.
226        let pairs: Vec<(f64, f64)> = (0..40)
227            .map(|t| (2.0 * f64::from(t), f64::from(t)))
228            .collect();
229        let last = SpreadHurst::new(20)
230            .unwrap()
231            .batch(&pairs)
232            .into_iter()
233            .flatten()
234            .last()
235            .unwrap();
236        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
237    }
238
239    #[test]
240    fn flat_spread_returns_midpoint() {
241        // a − b is constant ⇒ all lagged differences zero ⇒ neutral 0.5.
242        let pairs: Vec<(f64, f64)> = (0..30)
243            .map(|t| (5.0 + f64::from(t), f64::from(t)))
244            .collect();
245        let last = SpreadHurst::new(16)
246            .unwrap()
247            .batch(&pairs)
248            .into_iter()
249            .flatten()
250            .last()
251            .unwrap();
252        assert_relative_eq!(last, 0.5, epsilon = 1e-12);
253    }
254
255    #[test]
256    fn output_in_unit_range() {
257        let pairs: Vec<(f64, f64)> = (0..150)
258            .map(|t| {
259                let b = 50.0 + 0.3 * f64::from(t);
260                (
261                    b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(),
262                    b,
263                )
264            })
265            .collect();
266        let mut h = SpreadHurst::new(48).unwrap();
267        for v in h.batch(&pairs).into_iter().flatten() {
268            assert!((0.0..=1.0).contains(&v));
269        }
270    }
271
272    #[test]
273    fn reset_clears_state() {
274        let mut h = SpreadHurst::new(8).unwrap();
275        for t in 0..12 {
276            h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
277        }
278        assert!(h.is_ready());
279        h.reset();
280        assert!(!h.is_ready());
281        assert_eq!(h.update((1.0, 0.0)), None);
282    }
283
284    #[test]
285    fn batch_equals_streaming() {
286        let pairs: Vec<(f64, f64)> = (0..100)
287            .map(|t| {
288                let b = 30.0 + 0.7 * f64::from(t);
289                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
290            })
291            .collect();
292        let batch = SpreadHurst::new(32).unwrap().batch(&pairs);
293        let mut h = SpreadHurst::new(32).unwrap();
294        let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
295        assert_eq!(batch, streamed);
296    }
297
298    #[test]
299    fn non_finite_input_returns_none() {
300        let mut h = SpreadHurst::new(8).unwrap();
301        assert_eq!(h.update((f64::NAN, 1.0)), None);
302        assert_eq!(h.update((1.0, f64::INFINITY)), None);
303        // The rejected ticks leave no trace: a fresh window still warms up.
304        for t in 0..7 {
305            assert_eq!(h.update((f64::from(t), 0.0)), None);
306        }
307        assert!(h.update((7.0, 0.0)).is_some());
308    }
309}