wickra_core/indicators/
ou_half_life.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::centred_moments;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
49pub struct OuHalfLife {
50 period: usize,
51 window: VecDeque<f64>,
52}
53
54impl OuHalfLife {
55 pub fn new(period: usize) -> Result<Self> {
61 if period < 3 {
62 return Err(Error::InvalidPeriod {
63 message: "OU half-life needs period >= 3",
64 });
65 }
66 if period > crate::error::MAX_PERIOD {
67 return Err(Error::InvalidPeriod {
68 message: crate::error::PERIOD_ABOVE_MAX,
69 });
70 }
71 Ok(Self {
72 period,
73 window: VecDeque::with_capacity(period),
74 })
75 }
76
77 pub const fn period(&self) -> usize {
79 self.period
80 }
81}
82
83impl Indicator for OuHalfLife {
84 type Input = (f64, f64);
85 type Output = f64;
86
87 fn update(&mut self, input: (f64, f64)) -> Option<f64> {
88 let (a, b) = input;
89 if !a.is_finite() || !b.is_finite() {
90 return None;
91 }
92 if self.window.len() == self.period {
93 self.window.pop_front();
94 }
95 self.window.push_back(a - b);
96 if self.window.len() < self.period {
97 return None;
98 }
99 let moments = centred_moments(
103 self.window
104 .iter()
105 .zip(self.window.iter().skip(1))
106 .map(|(&level, &next)| (level, next - level)),
107 );
108 if moments.var_x <= 0.0 {
109 return Some(0.0);
111 }
112 let lambda = moments.cov / moments.var_x;
113 if lambda >= 0.0 {
114 return Some(0.0);
116 }
117 Some(-std::f64::consts::LN_2 / lambda)
118 }
119
120 fn reset(&mut self) {
121 self.window.clear();
122 }
123
124 #[inline]
125 fn warmup_period(&self) -> usize {
126 self.period
127 }
128
129 #[inline]
130 fn is_ready(&self) -> bool {
131 self.window.len() == self.period
132 }
133
134 #[inline]
135 fn name(&self) -> &'static str {
136 "OuHalfLife"
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::traits::BatchExt;
144 use approx::assert_relative_eq;
145
146 #[test]
147 fn rejects_period_below_three() {
148 assert!(OuHalfLife::new(2).is_err());
149 assert!(OuHalfLife::new(3).is_ok());
150 }
151
152 #[test]
153 fn accessors_and_metadata() {
154 let hl = OuHalfLife::new(30).unwrap();
155 assert_eq!(hl.period(), 30);
156 assert_eq!(hl.warmup_period(), 30);
157 assert_eq!(hl.name(), "OuHalfLife");
158 assert!(!hl.is_ready());
159 }
160
161 #[test]
162 fn warmup_returns_none() {
163 let mut hl = OuHalfLife::new(4).unwrap();
164 assert_eq!(hl.update((1.0, 0.0)), None);
165 assert_eq!(hl.update((2.0, 0.0)), None);
166 assert_eq!(hl.update((3.0, 0.0)), None);
167 assert!(hl.update((4.0, 0.0)).is_some());
168 assert!(hl.is_ready());
169 }
170
171 #[test]
172 fn mean_reverting_spread_has_positive_half_life() {
173 let pairs: Vec<(f64, f64)> = (0..120)
175 .map(|t| {
176 let b = 100.0 + f64::from(t);
177 let a = b + 2.0 * (f64::from(t) * 0.9).sin();
178 (a, b)
179 })
180 .collect();
181 let last = OuHalfLife::new(40)
182 .unwrap()
183 .batch(&pairs)
184 .into_iter()
185 .flatten()
186 .last()
187 .unwrap();
188 assert!(last > 0.0 && last < 40.0, "half-life {last}");
189 }
190
191 #[test]
192 fn trending_spread_has_zero_half_life() {
193 let pairs: Vec<(f64, f64)> = (0..40)
195 .map(|t| (2.0 * f64::from(t), f64::from(t)))
196 .collect();
197 let last = OuHalfLife::new(20)
198 .unwrap()
199 .batch(&pairs)
200 .into_iter()
201 .flatten()
202 .last()
203 .unwrap();
204 assert_eq!(last, 0.0);
205 }
206
207 #[test]
208 fn flat_spread_returns_zero() {
209 let pairs: Vec<(f64, f64)> = (0..30)
211 .map(|t| (5.0 + f64::from(t), f64::from(t)))
212 .collect();
213 let last = OuHalfLife::new(10)
214 .unwrap()
215 .batch(&pairs)
216 .into_iter()
217 .flatten()
218 .last()
219 .unwrap();
220 assert_eq!(last, 0.0);
221 }
222
223 #[test]
224 fn reset_clears_state() {
225 let mut hl = OuHalfLife::new(5).unwrap();
226 for t in 0..10 {
227 hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
228 }
229 assert!(hl.is_ready());
230 hl.reset();
231 assert!(!hl.is_ready());
232 assert_eq!(hl.update((1.0, 0.0)), None);
233 }
234
235 #[test]
236 fn batch_equals_streaming() {
237 let pairs: Vec<(f64, f64)> = (0..80)
238 .map(|t| {
239 let b = 50.0 + 0.5 * f64::from(t);
240 (b + (f64::from(t) * 0.6).sin(), b)
241 })
242 .collect();
243 let batch = OuHalfLife::new(25).unwrap().batch(&pairs);
244 let mut hl = OuHalfLife::new(25).unwrap();
245 let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
246 assert_eq!(batch, streamed);
247 }
248
249 #[test]
250 fn non_finite_input_returns_none() {
251 let mut hl = OuHalfLife::new(4).unwrap();
252 assert_eq!(hl.update((f64::NAN, 1.0)), None);
253 assert_eq!(hl.update((1.0, f64::INFINITY)), None);
254 assert_eq!(hl.update((1.0, 0.0)), None);
256 assert_eq!(hl.update((2.0, 0.0)), None);
257 assert_eq!(hl.update((3.0, 0.0)), None);
258 assert!(hl.update((4.0, 0.0)).is_some());
259 }
260
261 #[test]
270 fn offset_spread_matches_a_two_pass_reference() {
271 const PERIOD: usize = 20;
272 const BARS: usize = 400;
273
274 let series: Vec<(f64, f64)> = (0..BARS)
278 .map(|i| {
279 let t = i as f64;
280 let base = 1e5 * (1.0 + 0.002 * (t * 0.01).sin());
281 (base + 5000.0 + 0.1 * (t * 0.9).sin(), base)
282 })
283 .collect();
284
285 let mut ind = OuHalfLife::new(PERIOD).unwrap();
286 let mut spreads: Vec<f64> = Vec::new();
287 let mut mean_reverting = 0_usize;
288 for &(a, b) in &series {
289 let got = ind.update((a, b));
290 spreads.push(a - b);
291 let Some(half_life) = got else { continue };
292 let window = &spreads[spreads.len() - PERIOD..];
293 let levels = &window[..PERIOD - 1];
294 let deltas: Vec<f64> = window.windows(2).map(|p| p[1] - p[0]).collect();
295 let n = (PERIOD - 1) as f64;
296 let mean_level = levels.iter().sum::<f64>() / n;
297 let mean_delta = deltas.iter().sum::<f64>() / n;
298 let var_level = levels
299 .iter()
300 .map(|v| (v - mean_level) * (v - mean_level))
301 .sum::<f64>()
302 / n;
303 let lambda = levels
304 .iter()
305 .zip(&deltas)
306 .map(|(u, v)| (u - mean_level) * (v - mean_delta))
307 .sum::<f64>()
308 / n
309 / var_level;
310 assert!(lambda < 0.0, "the probe series must mean-revert");
311 mean_reverting += 1;
312 assert_relative_eq!(
313 half_life,
314 -std::f64::consts::LN_2 / lambda,
315 max_relative = 1e-12
316 );
317 }
318 assert_eq!(mean_reverting, BARS - ind.warmup_period() + 1);
319 }
320}