Skip to main content

quantwave_core/indicators/
hurst.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4
5/// Hurst Exponent
6///
7/// Measures the long-term memory of time series.
8/// H < 0.5: Mean reverting (anti-persistent)
9/// H = 0.5: Random walk
10/// H > 0.5: Trending (persistent)
11///
12/// This implementation uses the Rescaled Range (R/S) analysis over a fixed window.
13#[derive(Debug, Clone)]
14pub struct HurstExponent {
15    period: usize,
16    window: VecDeque<f64>,
17}
18
19impl HurstExponent {
20    pub fn new(period: usize) -> Self {
21        Self {
22            period: period.max(2),
23            window: VecDeque::with_capacity(period),
24        }
25    }
26}
27
28impl Next<f64> for HurstExponent {
29    type Output = f64;
30
31    fn next(&mut self, input: f64) -> Self::Output {
32        self.window.push_back(input);
33        if self.window.len() > self.period {
34            self.window.pop_front();
35        }
36
37        if self.window.len() < self.period {
38            return f64::NAN;
39        }
40
41        // 1. Calculate Mean
42        let sum: f64 = self.window.iter().sum();
43        let mean = sum / self.period as f64;
44
45        // 2. Mean-adjusted series and cumulative deviates
46        // 3. Calculate Range
47        let mut cumulative_deviate = 0.0;
48        let mut max_z = f64::MIN;
49        let mut min_z = f64::MAX;
50
51        for &val in self.window.iter() {
52            cumulative_deviate += val - mean;
53            if cumulative_deviate > max_z {
54                max_z = cumulative_deviate;
55            }
56            if cumulative_deviate < min_z {
57                min_z = cumulative_deviate;
58            }
59        }
60
61        let range = max_z - min_z;
62
63        // 4. Calculate Standard Deviation
64        let mut variance_sum = 0.0;
65        for &val in self.window.iter() {
66            let diff = val - mean;
67            variance_sum += diff * diff;
68        }
69        let std_dev = (variance_sum / self.period as f64).sqrt();
70
71        if std_dev == 0.0 {
72            return 0.5; // Random walk if no variance
73        }
74
75        // 5. Calculate Hurst Exponent
76        // H = log(R/S) / log(N)
77        let rs = range / std_dev;
78        (rs.ln()) / (self.period as f64).ln()
79    }
80}
81
82pub const HURST_EXPONENT_METADATA: IndicatorMetadata = IndicatorMetadata {
83    name: "Hurst Exponent",
84    description: "Measures the persistence or anti-persistence of a time series using R/S analysis.",
85    usage: "Use to classify the current market regime. H > 0.5 suggests a trending market (persistent); H < 0.5 suggests a mean-reverting market (anti-persistent). Useful as a filter for trend-following or mean-reversion strategies.",
86    keywords: &[
87        "statistics",
88        "regime-detection",
89        "hurst",
90        "ml",
91        "trending",
92        "mean-reversion",
93    ],
94    ehlers_summary: "The Hurst Exponent, pioneered by Harold Edwin Hurst in 1951, quantifies the 'memory' of a time series. In technical analysis, it distinguishes between trending, mean-reverting, and random walk price action. It is a critical feature for machine learning models to adapt their logic to the underlying market structure.",
95    params: &[ParamDef {
96        name: "period",
97        default: "100",
98        description: "Lookback period for R/S analysis",
99    }],
100    formula_source: "https://en.wikipedia.org/wiki/Hurst_exponent",
101    formula_latex: r#"
102\[
103H = \frac{\ln(R/S)}{\ln(N)}
104\]
105"#,
106    gold_standard_file: "hurst_exponent.json",
107    category: "ML Features",
108};
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::traits::Next;
114    use proptest::prelude::*;
115
116    #[test]
117    fn test_hurst_basic() {
118        let period = 10;
119        let mut hurst = HurstExponent::new(period);
120        // Upward trend should have H > 0.5
121        for i in 0..period {
122            let res = hurst.next(i as f64);
123            if i < period - 1 {
124                assert!(res.is_nan());
125            } else {
126                assert!(res > 0.5);
127            }
128        }
129    }
130
131    proptest! {
132        #[test]
133        fn test_hurst_parity(
134            inputs in prop::collection::vec(1.0..100.0, 50..100),
135        ) {
136            let period = 20;
137            let mut hurst = HurstExponent::new(period);
138            let streaming_results: Vec<f64> = inputs.iter().map(|&x| hurst.next(x)).collect();
139
140            // Reference implementation
141            let mut batch_results = Vec::with_capacity(inputs.len());
142            for i in 0..inputs.len() {
143                if i < period - 1 {
144                    batch_results.push(f64::NAN);
145                    continue;
146                }
147
148                let slice = &inputs[i + 1 - period..=i];
149                let sum: f64 = slice.iter().sum();
150                let mean = sum / period as f64;
151
152                let mut cum_dev = 0.0;
153                let mut max_z = f64::MIN;
154                let mut min_z = f64::MAX;
155                for &val in slice {
156                    cum_dev += val - mean;
157                    if cum_dev > max_z { max_z = cum_dev; }
158                    if cum_dev < min_z { min_z = cum_dev; }
159                }
160                let range = max_z - min_z;
161
162                let var_sum: f64 = slice.iter().map(|&v| (v - mean).powi(2)).sum();
163                let std_dev = (var_sum / period as f64).sqrt();
164
165                let res = if std_dev == 0.0 { 0.5 } else { (range / std_dev).ln() / (period as f64).ln() };
166                batch_results.push(res);
167            }
168
169            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
170                if s.is_nan() {
171                    assert!(b.is_nan());
172                } else {
173                    approx::assert_relative_eq!(s, b, epsilon = 1e-10);
174                }
175            }
176        }
177    }
178}