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}