wickra_core/indicators/
spread_ar1_coefficient.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)]
57pub struct SpreadAr1Coefficient {
58 period: usize,
59 window: VecDeque<f64>,
60}
61
62impl SpreadAr1Coefficient {
63 pub fn new(period: usize) -> Result<Self> {
70 if period < 3 {
71 return Err(Error::InvalidPeriod {
72 message: "AR(1) spread coefficient needs period >= 3",
73 });
74 }
75 if period > crate::error::MAX_PERIOD {
76 return Err(Error::InvalidPeriod {
77 message: crate::error::PERIOD_ABOVE_MAX,
78 });
79 }
80 Ok(Self {
81 period,
82 window: VecDeque::with_capacity(period),
83 })
84 }
85
86 pub const fn period(&self) -> usize {
88 self.period
89 }
90}
91
92impl Indicator for SpreadAr1Coefficient {
93 type Input = (f64, f64);
94 type Output = f64;
95
96 #[inline]
97 fn update(&mut self, input: (f64, f64)) -> Option<f64> {
98 let (a, b) = input;
99 if !a.is_finite() || !b.is_finite() {
100 return None;
101 }
102 if self.window.len() == self.period {
103 self.window.pop_front();
104 }
105 self.window.push_back(a - b);
106 if self.window.len() < self.period {
107 return None;
108 }
109 let moments = centred_moments(
113 self.window
114 .iter()
115 .zip(self.window.iter().skip(1))
116 .map(|(&level, &next)| (level, next)),
117 );
118 if moments.var_x <= 0.0 {
119 return Some(0.0);
121 }
122 Some(moments.cov / moments.var_x)
123 }
124
125 fn reset(&mut self) {
126 self.window.clear();
127 }
128
129 #[inline]
130 fn warmup_period(&self) -> usize {
131 self.period
132 }
133
134 #[inline]
135 fn is_ready(&self) -> bool {
136 self.window.len() == self.period
137 }
138
139 #[inline]
140 fn name(&self) -> &'static str {
141 "SpreadAr1Coefficient"
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::traits::BatchExt;
149 use approx::assert_relative_eq;
150
151 #[test]
152 fn rejects_period_below_three() {
153 assert!(SpreadAr1Coefficient::new(2).is_err());
154 assert!(SpreadAr1Coefficient::new(3).is_ok());
155 }
156
157 #[test]
158 fn accessors_and_metadata() {
159 let ar1 = SpreadAr1Coefficient::new(30).unwrap();
160 assert_eq!(ar1.period(), 30);
161 assert_eq!(ar1.warmup_period(), 30);
162 assert_eq!(ar1.name(), "SpreadAr1Coefficient");
163 assert!(!ar1.is_ready());
164 }
165
166 #[test]
167 fn warmup_returns_none() {
168 let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
169 assert_eq!(ar1.update((1.0, 0.0)), None);
170 assert_eq!(ar1.update((2.0, 0.0)), None);
171 assert_eq!(ar1.update((3.0, 0.0)), None);
172 assert!(ar1.update((4.0, 0.0)).is_some());
173 assert!(ar1.is_ready());
174 }
175
176 #[test]
177 fn mean_reverting_spread_has_rho_below_one() {
178 let pairs: Vec<(f64, f64)> = (0..120)
180 .map(|t| {
181 let b = 100.0 + f64::from(t);
182 let a = b + 2.0 * (f64::from(t) * 0.9).sin();
183 (a, b)
184 })
185 .collect();
186 let last = SpreadAr1Coefficient::new(40)
187 .unwrap()
188 .batch(&pairs)
189 .into_iter()
190 .flatten()
191 .last()
192 .unwrap();
193 assert!(last > 0.0 && last < 1.0, "rho {last}");
194 }
195
196 #[test]
197 fn random_walk_spread_has_rho_near_one() {
198 let pairs: Vec<(f64, f64)> = (0..40)
201 .map(|t| (2.0 * f64::from(t), f64::from(t)))
202 .collect();
203 let last = SpreadAr1Coefficient::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_zero() {
215 let pairs: Vec<(f64, f64)> = (0..30)
217 .map(|t| (5.0 + f64::from(t), f64::from(t)))
218 .collect();
219 let last = SpreadAr1Coefficient::new(10)
220 .unwrap()
221 .batch(&pairs)
222 .into_iter()
223 .flatten()
224 .last()
225 .unwrap();
226 assert_eq!(last, 0.0);
227 }
228
229 #[test]
230 fn reset_clears_state() {
231 let mut ar1 = SpreadAr1Coefficient::new(5).unwrap();
232 for t in 0..10 {
233 ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
234 }
235 assert!(ar1.is_ready());
236 ar1.reset();
237 assert!(!ar1.is_ready());
238 assert_eq!(ar1.update((1.0, 0.0)), None);
239 }
240
241 #[test]
242 fn batch_equals_streaming() {
243 let pairs: Vec<(f64, f64)> = (0..80)
244 .map(|t| {
245 let b = 50.0 + 0.5 * f64::from(t);
246 (b + (f64::from(t) * 0.6).sin(), b)
247 })
248 .collect();
249 let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs);
250 let mut ar1 = SpreadAr1Coefficient::new(25).unwrap();
251 let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
252 assert_eq!(batch, streamed);
253 }
254
255 #[test]
256 fn non_finite_input_returns_none() {
257 let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
258 assert_eq!(ar1.update((f64::NAN, 1.0)), None);
259 assert_eq!(ar1.update((1.0, f64::INFINITY)), None);
260 assert_eq!(ar1.update((1.0, 0.0)), None);
262 assert_eq!(ar1.update((2.0, 0.0)), None);
263 assert_eq!(ar1.update((3.0, 0.0)), None);
264 assert!(ar1.update((4.0, 0.0)).is_some());
265 }
266
267 #[test]
273 fn offset_spread_matches_a_two_pass_reference() {
274 const PERIOD: usize = 20;
275 let series: Vec<(f64, f64)> = (0..400)
276 .map(|i| {
277 let t = f64::from(i);
278 let base = 1e5 * (1.0 + 0.01 * (t * 0.03).sin());
279 (base * 1.05 + 1e-3 * (t * 0.23).sin(), base)
280 })
281 .collect();
282
283 let mut ind = SpreadAr1Coefficient::new(PERIOD).unwrap();
284 let mut spreads: Vec<f64> = Vec::new();
285 let mut compared = 0_usize;
286 for &(a, b) in &series {
287 let got = ind.update((a, b));
288 spreads.push(a - b);
289 let Some(rho) = got else { continue };
290 let window = &spreads[spreads.len() - PERIOD..];
291 let levels = &window[..PERIOD - 1];
292 let nexts = &window[1..];
293 let n = (PERIOD - 1) as f64;
294 let mean_level = levels.iter().sum::<f64>() / n;
295 let mean_next = nexts.iter().sum::<f64>() / n;
296 let var_level = levels
297 .iter()
298 .map(|v| (v - mean_level) * (v - mean_level))
299 .sum::<f64>()
300 / n;
301 let cov = levels
302 .iter()
303 .zip(nexts)
304 .map(|(u, v)| (u - mean_level) * (v - mean_next))
305 .sum::<f64>()
306 / n;
307 compared += 1;
308 assert_relative_eq!(rho, cov / var_level, max_relative = 1e-12);
309 }
310 assert_eq!(compared, series.len() - ind.warmup_period() + 1);
311 }
312}