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