Skip to main content

wickra_core/indicators/
hurst_exponent.rs

1//! Rolling Hurst Exponent via simplified R/S analysis.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Hurst Exponent of the last `period` values, estimated by rescaled-range
9/// (R/S) analysis.
10///
11/// The classic Hurst-Mandelbrot estimator forms log-log pairs of `(n,
12/// R(n)/S(n))` for several window lengths `n` and reports the slope of the
13/// least-squares fit. Wickra uses a streaming-friendly variant that
14/// partitions the trailing window into `chunks` of equal size,
15/// computes `(R/S)` for each chunk length, and fits a log-log line to the
16/// resulting points:
17///
18/// ```text
19/// for each chunk size m ∈ {n/2, n/3, …, n/chunks}:
20///     mean_m   = (1/m) · Σ x_i               over the chunk
21///     dev_m_i  = (Σ_{j ≤ i} (x_j − mean_m))  // cumulative deviation
22///     R_m      = max(dev_m) − min(dev_m)
23///     S_m      = population_stddev(chunk)
24///     pair     = (log m, log(R_m / S_m))
25/// H = slope of OLS line through the (log m, log(R/S)) points
26/// ```
27///
28/// The interpretation is unchanged from the textbook:
29///
30/// - `H ≈ 0.5` → random walk; recent moves carry no information about
31///   future direction (the efficient-markets baseline).
32/// - `H > 0.5` → persistent / trending; up moves are likelier to be
33///   followed by more up moves.
34/// - `H < 0.5` → anti-persistent / mean-reverting; up moves tend to
35///   reverse.
36///
37/// Use it as a regime filter: trend-following strategies prefer
38/// `H > 0.55`; mean-reversion prefers `H < 0.45`. The output is clamped
39/// to `[0, 1]` to absorb degenerate fits on very small windows.
40///
41/// `period` must be at least `2 · chunks` so every chunk has at least two
42/// points (otherwise its stddev is zero). A perfectly flat window has all
43/// `R/S = 0` and the indicator returns `0.5` (random-walk baseline) to
44/// avoid divide-by-zero / log-zero failures.
45///
46/// Each `update` is O(period); the window is stored in a deque and the
47/// chunked R/S computation runs once per emission, not per input.
48///
49/// # Example
50///
51/// ```
52/// use wickra_core::{HurstExponent, Indicator};
53///
54/// let mut indicator = HurstExponent::new(100, 4).unwrap();
55/// let mut last = None;
56/// for i in 0..200 {
57///     last = indicator.update(f64::from(i));
58/// }
59/// assert!(last.is_some());
60/// ```
61#[derive(Debug, Clone)]
62pub struct HurstExponent {
63    period: usize,
64    chunks: usize,
65    window: VecDeque<f64>,
66}
67
68impl HurstExponent {
69    /// Construct a new Hurst Exponent over a window of `period` inputs,
70    /// fitted across `chunks` log-log points.
71    ///
72    /// `chunks` controls the number of R/S pairs that go into the slope
73    /// fit; the typical value is `4` (the original Hurst paper used 5 — 9
74    /// points; smaller windows constrain the choice).
75    ///
76    /// # Errors
77    /// Returns [`Error::InvalidPeriod`] if `chunks < 2` or
78    /// `period < 2 · chunks`.
79    pub fn new(period: usize, chunks: usize) -> Result<Self> {
80        if chunks < 2 {
81            return Err(Error::InvalidPeriod {
82                message: "Hurst chunks must be >= 2",
83            });
84        }
85        if period < 2 * chunks {
86            return Err(Error::InvalidPeriod {
87                message: "Hurst period must be >= 2 * chunks",
88            });
89        }
90        Ok(Self {
91            period,
92            chunks,
93            window: VecDeque::with_capacity(period),
94        })
95    }
96
97    /// Configured window period.
98    pub const fn period(&self) -> usize {
99        self.period
100    }
101
102    /// Configured chunk count.
103    pub const fn chunks(&self) -> usize {
104        self.chunks
105    }
106}
107
108/// R/S over a single chunk; returns `None` if the chunk has zero dispersion
109/// (its stddev is zero, so the ratio is undefined).
110fn rescaled_range(chunk: &[f64]) -> Option<f64> {
111    let n = chunk.len() as f64;
112    let mean = chunk.iter().sum::<f64>() / n;
113    let mut cum = 0.0;
114    let mut hi = f64::NEG_INFINITY;
115    let mut lo = f64::INFINITY;
116    let mut sum_sq = 0.0;
117    for &x in chunk {
118        let d = x - mean;
119        cum += d;
120        if cum > hi {
121            hi = cum;
122        }
123        if cum < lo {
124            lo = cum;
125        }
126        sum_sq += d * d;
127    }
128    let r = hi - lo;
129    let s = (sum_sq / n).sqrt();
130    if s == 0.0 || r == 0.0 {
131        return None;
132    }
133    Some(r / s)
134}
135
136impl Indicator for HurstExponent {
137    type Input = f64;
138    type Output = f64;
139
140    fn update(&mut self, value: f64) -> Option<f64> {
141        if self.window.len() == self.period {
142            self.window.pop_front();
143        }
144        self.window.push_back(value);
145        if self.window.len() < self.period {
146            return None;
147        }
148
149        // Materialise the window contiguously so chunk slicing is trivial.
150        let buf: Vec<f64> = self.window.iter().copied().collect();
151        // Build (log m, log(R/S)) points. The chunk size sweeps from period
152        // (one big chunk) down to period / chunks (chunks small chunks).
153        let mut sum_x = 0.0;
154        let mut sum_y = 0.0;
155        let mut sum_xy = 0.0;
156        let mut sum_xx = 0.0;
157        let mut count = 0usize;
158        for k in 1..=self.chunks {
159            // k chunks each of size m; ignore the integer-division leftover
160            // bars at the end of the window. The `period >= 2 * chunks`
161            // constructor invariant guarantees m >= 2 for every k in range.
162            let m = self.period / k;
163            // Average R/S across the k chunks of size m to reduce noise.
164            let mut acc = 0.0;
165            let mut chunks_used = 0;
166            for c in 0..k {
167                let start = c * m;
168                let end = start + m;
169                if let Some(rs) = rescaled_range(&buf[start..end]) {
170                    acc += rs;
171                    chunks_used += 1;
172                }
173            }
174            if chunks_used == 0 {
175                continue;
176            }
177            let avg_rs = acc / f64::from(chunks_used);
178            let x = (m as f64).ln();
179            let y = avg_rs.ln();
180            sum_x += x;
181            sum_y += y;
182            sum_xy += x * y;
183            sum_xx += x * x;
184            count += 1;
185        }
186        if count < 2 {
187            // A perfectly flat window yields no usable R/S point; the
188            // canonical fallback for R/S on white noise is H = 0.5.
189            return Some(0.5);
190        }
191        // With chunks >= 2 and period >= 2 * chunks, m_1 = period and
192        // m_2 = period / 2 are always distinct, so the variance of the
193        // log-m values is strictly positive and `denom > 0`.
194        let n = count as f64;
195        let denom = n * sum_xx - sum_x * sum_x;
196        let slope = (n * sum_xy - sum_x * sum_y) / denom;
197        Some(slope.clamp(0.0, 1.0))
198    }
199
200    fn reset(&mut self) {
201        self.window.clear();
202    }
203
204    fn warmup_period(&self) -> usize {
205        self.period
206    }
207
208    fn is_ready(&self) -> bool {
209        self.window.len() == self.period
210    }
211
212    fn name(&self) -> &'static str {
213        "HurstExponent"
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::traits::BatchExt;
221    use approx::assert_relative_eq;
222
223    #[test]
224    fn rejects_invalid_parameters() {
225        assert!(HurstExponent::new(10, 0).is_err());
226        assert!(HurstExponent::new(10, 1).is_err());
227        assert!(HurstExponent::new(3, 2).is_err());
228        assert!(HurstExponent::new(4, 2).is_ok());
229    }
230
231    #[test]
232    fn accessors_and_metadata() {
233        let h = HurstExponent::new(100, 4).unwrap();
234        assert_eq!(h.period(), 100);
235        assert_eq!(h.chunks(), 4);
236        assert_eq!(h.warmup_period(), 100);
237        assert_eq!(h.name(), "HurstExponent");
238    }
239
240    #[test]
241    fn constant_series_is_one_half() {
242        let mut h = HurstExponent::new(40, 4).unwrap();
243        for v in h.batch(&[42.0; 80]).into_iter().flatten() {
244            assert_relative_eq!(v, 0.5, epsilon = 1e-12);
245        }
246    }
247
248    #[test]
249    fn output_stays_in_zero_one_range() {
250        let prices: Vec<f64> = (0..400)
251            .map(|i| {
252                100.0
253                    + (f64::from(i) * 0.05).sin() * 8.0
254                    + (f64::from(i) * 0.21).cos() * 3.0
255                    + f64::from(i) * 0.1
256            })
257            .collect();
258        let mut h = HurstExponent::new(100, 4).unwrap();
259        for v in h.batch(&prices).into_iter().flatten() {
260            assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
261        }
262    }
263
264    #[test]
265    fn trending_series_above_half() {
266        // A clean monotonic ramp is the textbook persistent series; the R/S
267        // pairs must lie above the random-walk baseline.
268        let prices: Vec<f64> = (0..200).map(f64::from).collect();
269        let mut h = HurstExponent::new(100, 4).unwrap();
270        let last = h.batch(&prices).into_iter().flatten().last().unwrap();
271        assert!(
272            last > 0.5,
273            "trending series should have H > 0.5, got {last}"
274        );
275    }
276
277    #[test]
278    fn reset_clears_state() {
279        let mut h = HurstExponent::new(20, 4).unwrap();
280        for i in 0..20 {
281            h.update(f64::from(i));
282        }
283        assert!(h.is_ready());
284        h.reset();
285        assert!(!h.is_ready());
286        assert_eq!(h.update(1.0), None);
287    }
288
289    #[test]
290    fn batch_equals_streaming() {
291        let prices: Vec<f64> = (0..200)
292            .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
293            .collect();
294        let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
295        let mut b = HurstExponent::new(50, 4).unwrap();
296        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
297        assert_eq!(batch, streamed);
298    }
299}