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 !value.is_finite() {
142            return None;
143        }
144        if self.window.len() == self.period {
145            self.window.pop_front();
146        }
147        self.window.push_back(value);
148        if self.window.len() < self.period {
149            return None;
150        }
151
152        // Materialise the window contiguously so chunk slicing is trivial.
153        let buf: Vec<f64> = self.window.iter().copied().collect();
154        // Build (log m, log(R/S)) points. The chunk size sweeps from period
155        // (one big chunk) down to period / chunks (chunks small chunks).
156        let mut sum_x = 0.0;
157        let mut sum_y = 0.0;
158        let mut sum_xy = 0.0;
159        let mut sum_xx = 0.0;
160        let mut count = 0usize;
161        for k in 1..=self.chunks {
162            // k chunks each of size m; ignore the integer-division leftover
163            // bars at the end of the window. The `period >= 2 * chunks`
164            // constructor invariant guarantees m >= 2 for every k in range.
165            let m = self.period / k;
166            // Average R/S across the k chunks of size m to reduce noise.
167            let mut acc = 0.0;
168            let mut chunks_used = 0;
169            for c in 0..k {
170                let start = c * m;
171                let end = start + m;
172                if let Some(rs) = rescaled_range(&buf[start..end]) {
173                    acc += rs;
174                    chunks_used += 1;
175                }
176            }
177            if chunks_used == 0 {
178                continue;
179            }
180            let avg_rs = acc / f64::from(chunks_used);
181            let x = (m as f64).ln();
182            let y = avg_rs.ln();
183            sum_x += x;
184            sum_y += y;
185            sum_xy += x * y;
186            sum_xx += x * x;
187            count += 1;
188        }
189        if count < 2 {
190            // A perfectly flat window yields no usable R/S point; the
191            // canonical fallback for R/S on white noise is H = 0.5.
192            return Some(0.5);
193        }
194        // With chunks >= 2 and period >= 2 * chunks, m_1 = period and
195        // m_2 = period / 2 are always distinct, so the variance of the
196        // log-m values is strictly positive and `denom > 0`.
197        let n = count as f64;
198        let denom = n * sum_xx - sum_x * sum_x;
199        let slope = (n * sum_xy - sum_x * sum_y) / denom;
200        Some(slope.clamp(0.0, 1.0))
201    }
202
203    fn reset(&mut self) {
204        self.window.clear();
205    }
206
207    fn warmup_period(&self) -> usize {
208        self.period
209    }
210
211    fn is_ready(&self) -> bool {
212        self.window.len() == self.period
213    }
214
215    fn name(&self) -> &'static str {
216        "HurstExponent"
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::traits::BatchExt;
224    use approx::assert_relative_eq;
225
226    #[test]
227    fn rejects_invalid_parameters() {
228        assert!(HurstExponent::new(10, 0).is_err());
229        assert!(HurstExponent::new(10, 1).is_err());
230        assert!(HurstExponent::new(3, 2).is_err());
231        assert!(HurstExponent::new(4, 2).is_ok());
232    }
233
234    #[test]
235    fn accessors_and_metadata() {
236        let h = HurstExponent::new(100, 4).unwrap();
237        assert_eq!(h.period(), 100);
238        assert_eq!(h.chunks(), 4);
239        assert_eq!(h.warmup_period(), 100);
240        assert_eq!(h.name(), "HurstExponent");
241    }
242
243    #[test]
244    fn constant_series_is_one_half() {
245        let mut h = HurstExponent::new(40, 4).unwrap();
246        for v in h.batch(&[42.0; 80]).into_iter().flatten() {
247            assert_relative_eq!(v, 0.5, epsilon = 1e-12);
248        }
249    }
250
251    #[test]
252    fn output_stays_in_zero_one_range() {
253        let prices: Vec<f64> = (0..400)
254            .map(|i| {
255                100.0
256                    + (f64::from(i) * 0.05).sin() * 8.0
257                    + (f64::from(i) * 0.21).cos() * 3.0
258                    + f64::from(i) * 0.1
259            })
260            .collect();
261        let mut h = HurstExponent::new(100, 4).unwrap();
262        for v in h.batch(&prices).into_iter().flatten() {
263            assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
264        }
265    }
266
267    #[test]
268    fn trending_series_above_half() {
269        // A clean monotonic ramp is the textbook persistent series; the R/S
270        // pairs must lie above the random-walk baseline.
271        let prices: Vec<f64> = (0..200).map(f64::from).collect();
272        let mut h = HurstExponent::new(100, 4).unwrap();
273        let last = h.batch(&prices).into_iter().flatten().last().unwrap();
274        assert!(
275            last > 0.5,
276            "trending series should have H > 0.5, got {last}"
277        );
278    }
279
280    #[test]
281    fn reset_clears_state() {
282        let mut h = HurstExponent::new(20, 4).unwrap();
283        for i in 0..20 {
284            h.update(f64::from(i));
285        }
286        assert!(h.is_ready());
287        h.reset();
288        assert!(!h.is_ready());
289        assert_eq!(h.update(1.0), None);
290    }
291
292    #[test]
293    fn batch_equals_streaming() {
294        let prices: Vec<f64> = (0..200)
295            .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
296            .collect();
297        let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
298        let mut b = HurstExponent::new(50, 4).unwrap();
299        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
300        assert_eq!(batch, streamed);
301    }
302}