wickra_core/indicators/
hurst_exponent.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
62pub struct HurstExponent {
63 period: usize,
64 chunks: usize,
65 window: VecDeque<f64>,
66 scratch: Vec<f64>,
68}
69
70impl HurstExponent {
71 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 pub const fn period(&self) -> usize {
107 self.period
108 }
109
110 pub const fn chunks(&self) -> usize {
112 self.chunks
113 }
114}
115
116fn 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 self.scratch.clear();
162 self.scratch.extend(self.window.iter().copied());
163 let buf = &self.scratch;
164 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 let m = self.period / k;
176 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 return Some(0.5);
203 }
204 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 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
317 #[test]
318 fn rejects_chunks_above_maximum() {
319 assert!(matches!(
320 HurstExponent::new(4, crate::error::MAX_PERIOD + 1),
321 Err(Error::InvalidPeriod { .. })
322 ));
323 }
324}