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