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