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}