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}
55
56impl SpreadHurst {
57    /// Construct a new spread Hurst estimator.
58    ///
59    /// # Errors
60    /// Returns [`Error::InvalidPeriod`] if `period < 8` — fewer than eight
61    /// spreads cannot support a two-lag log–log regression.
62    pub fn new(period: usize) -> Result<Self> {
63        if period < 8 {
64            return Err(Error::InvalidPeriod {
65                message: "spread Hurst needs period >= 8",
66            });
67        }
68        Ok(Self {
69            period,
70            max_lag: (period / 4).max(2),
71            window: VecDeque::with_capacity(period),
72        })
73    }
74
75    /// Configured look-back window of spreads.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79}
80
81impl Indicator for SpreadHurst {
82    type Input = (f64, f64);
83    type Output = f64;
84
85    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
86        let (a, b) = input;
87        if self.window.len() == self.period {
88            self.window.pop_front();
89        }
90        self.window.push_back(a - b);
91        if self.window.len() < self.period {
92            return None;
93        }
94        let spreads: Vec<f64> = self.window.iter().copied().collect();
95        // Collect (log τ, log V(τ)) for every lag whose variance is positive.
96        let mut log_lag = Vec::with_capacity(self.max_lag);
97        let mut log_var = Vec::with_capacity(self.max_lag);
98        for lag in 1..=self.max_lag {
99            let mut sum_sq = 0.0;
100            let mut count = 0.0;
101            for pair in spreads.windows(lag + 1) {
102                let diff = pair[lag] - pair[0];
103                sum_sq += diff * diff;
104                count += 1.0;
105            }
106            let var = sum_sq / count;
107            if var > 0.0 {
108                log_lag.push((lag as f64).ln());
109                log_var.push(var.ln());
110            }
111        }
112        if log_lag.len() < 2 {
113            // Degenerate (flat) spread: report the random-walk midpoint.
114            return Some(0.5);
115        }
116        let n = log_lag.len() as f64;
117        let mean_lag = log_lag.iter().sum::<f64>() / n;
118        let mean_var = log_var.iter().sum::<f64>() / n;
119        let mut cov = 0.0;
120        let mut var_lag = 0.0;
121        for (lx, lv) in log_lag.iter().zip(&log_var) {
122            cov += (lx - mean_lag) * (lv - mean_var);
123            var_lag += (lx - mean_lag) * (lx - mean_lag);
124        }
125        // `log_lag` holds at least two *distinct* lag logarithms, so the lag
126        // variance is strictly positive — no degenerate-slope guard is needed.
127        let slope = cov / var_lag;
128        Some((slope / 2.0).clamp(0.0, 1.0))
129    }
130
131    fn reset(&mut self) {
132        self.window.clear();
133    }
134
135    fn warmup_period(&self) -> usize {
136        self.period
137    }
138
139    fn is_ready(&self) -> bool {
140        self.window.len() == self.period
141    }
142
143    fn name(&self) -> &'static str {
144        "SpreadHurst"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn rejects_period_below_eight() {
156        assert!(SpreadHurst::new(7).is_err());
157        assert!(SpreadHurst::new(8).is_ok());
158    }
159
160    #[test]
161    fn accessors_and_metadata() {
162        let h = SpreadHurst::new(40).unwrap();
163        assert_eq!(h.period(), 40);
164        assert_eq!(h.warmup_period(), 40);
165        assert_eq!(h.name(), "SpreadHurst");
166        assert!(!h.is_ready());
167    }
168
169    #[test]
170    fn warmup_returns_none() {
171        let mut h = SpreadHurst::new(8).unwrap();
172        for t in 0..7 {
173            assert_eq!(h.update((f64::from(t), 0.0)), None);
174        }
175        assert!(h.update((7.0, 0.0)).is_some());
176        assert!(h.is_ready());
177    }
178
179    #[test]
180    fn oscillating_spread_is_anti_persistent() {
181        let pairs: Vec<(f64, f64)> = (0..200)
182            .map(|t| {
183                let b = 100.0 + f64::from(t);
184                (b + 3.0 * (f64::from(t) * 0.8).sin(), b)
185            })
186            .collect();
187        let last = SpreadHurst::new(60)
188            .unwrap()
189            .batch(&pairs)
190            .into_iter()
191            .flatten()
192            .last()
193            .unwrap();
194        assert!(last < 0.5, "H {last}");
195    }
196
197    #[test]
198    fn linear_trend_spread_is_persistent() {
199        // Spread = a − b = t ⇒ τ-lagged differences are all τ ⇒ V(τ) = τ² ⇒ H = 1.
200        let pairs: Vec<(f64, f64)> = (0..40)
201            .map(|t| (2.0 * f64::from(t), f64::from(t)))
202            .collect();
203        let last = SpreadHurst::new(20)
204            .unwrap()
205            .batch(&pairs)
206            .into_iter()
207            .flatten()
208            .last()
209            .unwrap();
210        assert_relative_eq!(last, 1.0, epsilon = 1e-9);
211    }
212
213    #[test]
214    fn flat_spread_returns_midpoint() {
215        // a − b is constant ⇒ all lagged differences zero ⇒ neutral 0.5.
216        let pairs: Vec<(f64, f64)> = (0..30)
217            .map(|t| (5.0 + f64::from(t), f64::from(t)))
218            .collect();
219        let last = SpreadHurst::new(16)
220            .unwrap()
221            .batch(&pairs)
222            .into_iter()
223            .flatten()
224            .last()
225            .unwrap();
226        assert_relative_eq!(last, 0.5, epsilon = 1e-12);
227    }
228
229    #[test]
230    fn output_in_unit_range() {
231        let pairs: Vec<(f64, f64)> = (0..150)
232            .map(|t| {
233                let b = 50.0 + 0.3 * f64::from(t);
234                (
235                    b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(),
236                    b,
237                )
238            })
239            .collect();
240        let mut h = SpreadHurst::new(48).unwrap();
241        for v in h.batch(&pairs).into_iter().flatten() {
242            assert!((0.0..=1.0).contains(&v));
243        }
244    }
245
246    #[test]
247    fn reset_clears_state() {
248        let mut h = SpreadHurst::new(8).unwrap();
249        for t in 0..12 {
250            h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
251        }
252        assert!(h.is_ready());
253        h.reset();
254        assert!(!h.is_ready());
255        assert_eq!(h.update((1.0, 0.0)), None);
256    }
257
258    #[test]
259    fn batch_equals_streaming() {
260        let pairs: Vec<(f64, f64)> = (0..100)
261            .map(|t| {
262                let b = 30.0 + 0.7 * f64::from(t);
263                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
264            })
265            .collect();
266        let batch = SpreadHurst::new(32).unwrap().batch(&pairs);
267        let mut h = SpreadHurst::new(32).unwrap();
268        let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
269        assert_eq!(batch, streamed);
270    }
271}