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    /// Reusable scratch buffer to avoid allocating per `update`.
67    scratch: Vec<f64>,
68}
69
70impl HurstExponent {
71    /// Construct a new Hurst Exponent over a window of `period` inputs,
72    /// fitted across `chunks` log-log points.
73    ///
74    /// `chunks` controls the number of R/S pairs that go into the slope
75    /// fit; the typical value is `4` (the original Hurst paper used 5 — 9
76    /// points; smaller windows constrain the choice).
77    ///
78    /// # Errors
79    /// Returns [`Error::InvalidPeriod`] if `chunks < 2` or
80    /// `period < 2 · chunks`.
81    pub fn new(period: usize, chunks: usize) -> Result<Self> {
82        if chunks < 2 {
83            return Err(Error::InvalidPeriod {
84                message: "Hurst chunks must be >= 2",
85            });
86        }
87        if chunks > crate::error::MAX_PERIOD {
88            return Err(Error::InvalidPeriod {
89                message: crate::error::PERIOD_ABOVE_MAX,
90            });
91        }
92        if period < 2 * chunks {
93            return Err(Error::InvalidPeriod {
94                message: "Hurst period must be >= 2 * chunks",
95            });
96        }
97        Ok(Self {
98            period,
99            chunks,
100            window: VecDeque::with_capacity(period),
101            scratch: Vec::with_capacity(period),
102        })
103    }
104
105    /// Configured window period.
106    pub const fn period(&self) -> usize {
107        self.period
108    }
109
110    /// Configured chunk count.
111    pub const fn chunks(&self) -> usize {
112        self.chunks
113    }
114}
115
116/// R/S over a single chunk; returns `None` if the chunk has zero dispersion
117/// (its stddev is zero, so the ratio is undefined).
118fn rescaled_range(chunk: &[f64]) -> Option<f64> {
119    let n = chunk.len() as f64;
120    let mean = chunk.iter().sum::<f64>() / n;
121    let mut cum = 0.0;
122    let mut hi = f64::NEG_INFINITY;
123    let mut lo = f64::INFINITY;
124    let mut sum_sq = 0.0;
125    for &x in chunk {
126        let d = x - mean;
127        cum += d;
128        if cum > hi {
129            hi = cum;
130        }
131        if cum < lo {
132            lo = cum;
133        }
134        sum_sq += d * d;
135    }
136    let r = hi - lo;
137    let s = (sum_sq / n).sqrt();
138    if s == 0.0 || r == 0.0 {
139        return None;
140    }
141    Some(r / s)
142}
143
144impl Indicator for HurstExponent {
145    type Input = f64;
146    type Output = f64;
147
148    fn update(&mut self, value: f64) -> Option<f64> {
149        if !value.is_finite() {
150            return None;
151        }
152        if self.window.len() == self.period {
153            self.window.pop_front();
154        }
155        self.window.push_back(value);
156        if self.window.len() < self.period {
157            return None;
158        }
159
160        // Materialise the window contiguously so chunk slicing is trivial.
161        self.scratch.clear();
162        self.scratch.extend(self.window.iter().copied());
163        let buf = &self.scratch;
164        // Build (log m, log(R/S)) points. The chunk size sweeps from period
165        // (one big chunk) down to period / chunks (chunks small chunks).
166        let mut sum_x = 0.0;
167        let mut sum_y = 0.0;
168        let mut sum_xy = 0.0;
169        let mut sum_xx = 0.0;
170        let mut count = 0usize;
171        for k in 1..=self.chunks {
172            // k chunks each of size m; ignore the integer-division leftover
173            // bars at the end of the window. The `period >= 2 * chunks`
174            // constructor invariant guarantees m >= 2 for every k in range.
175            let m = self.period / k;
176            // Average R/S across the k chunks of size m to reduce noise.
177            let mut acc = 0.0;
178            let mut chunks_used = 0;
179            for c in 0..k {
180                let start = c * m;
181                let end = start + m;
182                if let Some(rs) = rescaled_range(&buf[start..end]) {
183                    acc += rs;
184                    chunks_used += 1;
185                }
186            }
187            if chunks_used == 0 {
188                continue;
189            }
190            let avg_rs = acc / f64::from(chunks_used);
191            let x = (m as f64).ln();
192            let y = avg_rs.ln();
193            sum_x += x;
194            sum_y += y;
195            sum_xy += x * y;
196            sum_xx += x * x;
197            count += 1;
198        }
199        if count < 2 {
200            // A perfectly flat window yields no usable R/S point; the
201            // canonical fallback for R/S on white noise is H = 0.5.
202            return Some(0.5);
203        }
204        // With chunks >= 2 and period >= 2 * chunks, m_1 = period and
205        // m_2 = period / 2 are always distinct, so the variance of the
206        // log-m values is strictly positive and `denom > 0`.
207        let n = count as f64;
208        let denom = n * sum_xx - sum_x * sum_x;
209        let slope = (n * sum_xy - sum_x * sum_y) / denom;
210        Some(slope.clamp(0.0, 1.0))
211    }
212
213    fn reset(&mut self) {
214        self.window.clear();
215        self.scratch.clear();
216    }
217
218    #[inline]
219    fn warmup_period(&self) -> usize {
220        self.period
221    }
222
223    #[inline]
224    fn is_ready(&self) -> bool {
225        self.window.len() == self.period
226    }
227
228    #[inline]
229    fn name(&self) -> &'static str {
230        "HurstExponent"
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::traits::BatchExt;
238    use approx::assert_relative_eq;
239
240    #[test]
241    fn rejects_invalid_parameters() {
242        assert!(HurstExponent::new(10, 0).is_err());
243        assert!(HurstExponent::new(10, 1).is_err());
244        assert!(HurstExponent::new(3, 2).is_err());
245        assert!(HurstExponent::new(4, 2).is_ok());
246    }
247
248    #[test]
249    fn accessors_and_metadata() {
250        let h = HurstExponent::new(100, 4).unwrap();
251        assert_eq!(h.period(), 100);
252        assert_eq!(h.chunks(), 4);
253        assert_eq!(h.warmup_period(), 100);
254        assert_eq!(h.name(), "HurstExponent");
255    }
256
257    #[test]
258    fn constant_series_is_one_half() {
259        let mut h = HurstExponent::new(40, 4).unwrap();
260        for v in h.batch(&[42.0; 80]).into_iter().flatten() {
261            assert_relative_eq!(v, 0.5, epsilon = 1e-12);
262        }
263    }
264
265    #[test]
266    fn output_stays_in_zero_one_range() {
267        let prices: Vec<f64> = (0..400)
268            .map(|i| {
269                100.0
270                    + (f64::from(i) * 0.05).sin() * 8.0
271                    + (f64::from(i) * 0.21).cos() * 3.0
272                    + f64::from(i) * 0.1
273            })
274            .collect();
275        let mut h = HurstExponent::new(100, 4).unwrap();
276        for v in h.batch(&prices).into_iter().flatten() {
277            assert!((0.0..=1.0).contains(&v), "Hurst out of range: {v}");
278        }
279    }
280
281    #[test]
282    fn trending_series_above_half() {
283        // A clean monotonic ramp is the textbook persistent series; the R/S
284        // pairs must lie above the random-walk baseline.
285        let prices: Vec<f64> = (0..200).map(f64::from).collect();
286        let mut h = HurstExponent::new(100, 4).unwrap();
287        let last = h.batch(&prices).into_iter().flatten().last().unwrap();
288        assert!(
289            last > 0.5,
290            "trending series should have H > 0.5, got {last}"
291        );
292    }
293
294    #[test]
295    fn reset_clears_state() {
296        let mut h = HurstExponent::new(20, 4).unwrap();
297        for i in 0..20 {
298            h.update(f64::from(i));
299        }
300        assert!(h.is_ready());
301        h.reset();
302        assert!(!h.is_ready());
303        assert_eq!(h.update(1.0), None);
304    }
305
306    #[test]
307    fn batch_equals_streaming() {
308        let prices: Vec<f64> = (0..200)
309            .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 5.0)
310            .collect();
311        let batch = HurstExponent::new(50, 4).unwrap().batch(&prices);
312        let mut b = HurstExponent::new(50, 4).unwrap();
313        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
314        assert_eq!(batch, streamed);
315    }
316}