wickra_core/indicators/
spread_hurst.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
50pub struct SpreadHurst {
51 period: usize,
52 max_lag: usize,
53 window: VecDeque<f64>,
54 scratch: Vec<f64>,
56 log_lag: Vec<f64>,
58 log_var: Vec<f64>,
59}
60
61impl SpreadHurst {
62 pub fn new(period: usize) -> Result<Self> {
68 if period < 8 {
69 return Err(Error::InvalidPeriod {
70 message: "spread Hurst needs period >= 8",
71 });
72 }
73 if period > crate::error::MAX_PERIOD {
74 return Err(Error::InvalidPeriod {
75 message: crate::error::PERIOD_ABOVE_MAX,
76 });
77 }
78 Ok(Self {
79 period,
80 max_lag: (period / 4).max(2),
81 window: VecDeque::with_capacity(period),
82 scratch: Vec::with_capacity(period),
83 log_lag: Vec::with_capacity((period / 4).max(2)),
84 log_var: Vec::with_capacity((period / 4).max(2)),
85 })
86 }
87
88 pub const fn period(&self) -> usize {
90 self.period
91 }
92}
93
94impl Indicator for SpreadHurst {
95 type Input = (f64, f64);
96 type Output = f64;
97
98 fn update(&mut self, input: (f64, f64)) -> Option<f64> {
99 let (a, b) = input;
100 if !a.is_finite() || !b.is_finite() {
101 return None;
102 }
103 if self.window.len() == self.period {
104 self.window.pop_front();
105 }
106 self.window.push_back(a - b);
107 if self.window.len() < self.period {
108 return None;
109 }
110 self.scratch.clear();
111 self.scratch.extend(self.window.iter().copied());
112 let spreads = &self.scratch;
113 let log_lag = &mut self.log_lag;
115 let log_var = &mut self.log_var;
116 log_lag.clear();
117 log_var.clear();
118 for lag in 1..=self.max_lag {
119 let mut sum_sq = 0.0;
120 let mut count = 0.0;
121 for pair in spreads.windows(lag + 1) {
122 let diff = pair[lag] - pair[0];
123 sum_sq += diff * diff;
124 count += 1.0;
125 }
126 let var = sum_sq / count;
127 if var > 0.0 {
128 log_lag.push((lag as f64).ln());
129 log_var.push(var.ln());
130 }
131 }
132 if log_lag.len() < 2 {
133 return Some(0.5);
135 }
136 let n = log_lag.len() as f64;
137 let mean_lag = log_lag.iter().sum::<f64>() / n;
138 let mean_var = log_var.iter().sum::<f64>() / n;
139 let mut cov = 0.0;
140 let mut var_lag = 0.0;
141 for (lx, lv) in log_lag.iter().zip(log_var.iter()) {
142 cov += (lx - mean_lag) * (lv - mean_var);
143 var_lag += (lx - mean_lag) * (lx - mean_lag);
144 }
145 let slope = cov / var_lag;
148 Some((slope / 2.0).clamp(0.0, 1.0))
149 }
150
151 fn reset(&mut self) {
152 self.window.clear();
153 self.scratch.clear();
154 self.log_lag.clear();
155 self.log_var.clear();
156 }
157
158 #[inline]
159 fn warmup_period(&self) -> usize {
160 self.period
161 }
162
163 #[inline]
164 fn is_ready(&self) -> bool {
165 self.window.len() == self.period
166 }
167
168 #[inline]
169 fn name(&self) -> &'static str {
170 "SpreadHurst"
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::traits::BatchExt;
178 use approx::assert_relative_eq;
179
180 #[test]
181 fn rejects_period_below_eight() {
182 assert!(SpreadHurst::new(7).is_err());
183 assert!(SpreadHurst::new(8).is_ok());
184 }
185
186 #[test]
187 fn accessors_and_metadata() {
188 let h = SpreadHurst::new(40).unwrap();
189 assert_eq!(h.period(), 40);
190 assert_eq!(h.warmup_period(), 40);
191 assert_eq!(h.name(), "SpreadHurst");
192 assert!(!h.is_ready());
193 }
194
195 #[test]
196 fn warmup_returns_none() {
197 let mut h = SpreadHurst::new(8).unwrap();
198 for t in 0..7 {
199 assert_eq!(h.update((f64::from(t), 0.0)), None);
200 }
201 assert!(h.update((7.0, 0.0)).is_some());
202 assert!(h.is_ready());
203 }
204
205 #[test]
206 fn oscillating_spread_is_anti_persistent() {
207 let pairs: Vec<(f64, f64)> = (0..200)
208 .map(|t| {
209 let b = 100.0 + f64::from(t);
210 (b + 3.0 * (f64::from(t) * 0.8).sin(), b)
211 })
212 .collect();
213 let last = SpreadHurst::new(60)
214 .unwrap()
215 .batch(&pairs)
216 .into_iter()
217 .flatten()
218 .last()
219 .unwrap();
220 assert!(last < 0.5, "H {last}");
221 }
222
223 #[test]
224 fn linear_trend_spread_is_persistent() {
225 let pairs: Vec<(f64, f64)> = (0..40)
227 .map(|t| (2.0 * f64::from(t), f64::from(t)))
228 .collect();
229 let last = SpreadHurst::new(20)
230 .unwrap()
231 .batch(&pairs)
232 .into_iter()
233 .flatten()
234 .last()
235 .unwrap();
236 assert_relative_eq!(last, 1.0, epsilon = 1e-9);
237 }
238
239 #[test]
240 fn flat_spread_returns_midpoint() {
241 let pairs: Vec<(f64, f64)> = (0..30)
243 .map(|t| (5.0 + f64::from(t), f64::from(t)))
244 .collect();
245 let last = SpreadHurst::new(16)
246 .unwrap()
247 .batch(&pairs)
248 .into_iter()
249 .flatten()
250 .last()
251 .unwrap();
252 assert_relative_eq!(last, 0.5, epsilon = 1e-12);
253 }
254
255 #[test]
256 fn output_in_unit_range() {
257 let pairs: Vec<(f64, f64)> = (0..150)
258 .map(|t| {
259 let b = 50.0 + 0.3 * f64::from(t);
260 (
261 b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(),
262 b,
263 )
264 })
265 .collect();
266 let mut h = SpreadHurst::new(48).unwrap();
267 for v in h.batch(&pairs).into_iter().flatten() {
268 assert!((0.0..=1.0).contains(&v));
269 }
270 }
271
272 #[test]
273 fn reset_clears_state() {
274 let mut h = SpreadHurst::new(8).unwrap();
275 for t in 0..12 {
276 h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
277 }
278 assert!(h.is_ready());
279 h.reset();
280 assert!(!h.is_ready());
281 assert_eq!(h.update((1.0, 0.0)), None);
282 }
283
284 #[test]
285 fn batch_equals_streaming() {
286 let pairs: Vec<(f64, f64)> = (0..100)
287 .map(|t| {
288 let b = 30.0 + 0.7 * f64::from(t);
289 (b + (f64::from(t) * 0.4).sin() * 1.5, b)
290 })
291 .collect();
292 let batch = SpreadHurst::new(32).unwrap().batch(&pairs);
293 let mut h = SpreadHurst::new(32).unwrap();
294 let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
295 assert_eq!(batch, streamed);
296 }
297
298 #[test]
299 fn non_finite_input_returns_none() {
300 let mut h = SpreadHurst::new(8).unwrap();
301 assert_eq!(h.update((f64::NAN, 1.0)), None);
302 assert_eq!(h.update((1.0, f64::INFINITY)), None);
303 for t in 0..7 {
305 assert_eq!(h.update((f64::from(t), 0.0)), None);
306 }
307 assert!(h.update((7.0, 0.0)).is_some());
308 }
309}