wickra_core/indicators/
rolling_correlation.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedPairMoments;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
47pub struct RollingCorrelation {
48 period: usize,
49 prev: Option<(f64, f64)>,
50 window: VecDeque<(f64, f64)>,
51 moments: ShiftedPairMoments,
52}
53
54impl RollingCorrelation {
55 pub fn new(period: usize) -> Result<Self> {
61 if period < 2 {
62 return Err(Error::InvalidPeriod {
63 message: "rolling correlation needs period >= 2",
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 prev: None,
74 window: VecDeque::with_capacity(period),
75 moments: ShiftedPairMoments::new(),
76 })
77 }
78
79 pub const fn period(&self) -> usize {
81 self.period
82 }
83}
84
85impl Indicator for RollingCorrelation {
86 type Input = (f64, f64);
87 type Output = f64;
88
89 fn update(&mut self, input: (f64, f64)) -> Option<f64> {
90 let (x, y) = input;
91 if !x.is_finite() || !y.is_finite() {
92 return None;
93 }
94 let Some((px, py)) = self.prev else {
95 self.prev = Some((x, y));
97 return None;
98 };
99 self.prev = Some((x, y));
100 let (rx, ry) = (x - px, y - py);
101 if self.window.len() == self.period {
102 let (ox, oy) = self.window.pop_front().expect("non-empty");
103 self.moments.evict(ox, oy);
104 }
105 self.window.push_back((rx, ry));
106 self.moments.push(rx, ry);
107 if self.moments.needs_reseed(self.period) {
108 self.moments.reseed(self.window.iter().copied());
109 }
110 if self.window.len() < self.period {
111 return None;
112 }
113 let var_x = self.moments.var_a(self.period);
114 let var_y = self.moments.var_b(self.period);
115 let cov = self.moments.cov(self.period);
116 let denom = (var_x * var_y).sqrt();
117 if denom == 0.0 {
118 return Some(0.0);
120 }
121 Some((cov / denom).clamp(-1.0, 1.0))
122 }
123
124 fn reset(&mut self) {
125 self.prev = None;
126 self.window.clear();
127 self.moments.reset();
128 }
129
130 #[inline]
131 fn warmup_period(&self) -> usize {
132 self.period + 1
133 }
134
135 #[inline]
136 fn is_ready(&self) -> bool {
137 self.window.len() == self.period
138 }
139
140 #[inline]
141 fn name(&self) -> &'static str {
142 "RollingCorrelation"
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::traits::BatchExt;
150 use approx::assert_relative_eq;
151
152 #[test]
153 fn rejects_period_below_two() {
154 assert!(RollingCorrelation::new(0).is_err());
155 assert!(RollingCorrelation::new(1).is_err());
156 assert!(RollingCorrelation::new(2).is_ok());
157 }
158
159 #[test]
160 fn accessors_and_metadata() {
161 let rc = RollingCorrelation::new(14).unwrap();
162 assert_eq!(rc.period(), 14);
163 assert_eq!(rc.warmup_period(), 15);
164 assert_eq!(rc.name(), "RollingCorrelation");
165 assert!(!rc.is_ready());
166 }
167
168 #[test]
169 fn warmup_needs_period_plus_one() {
170 let mut rc = RollingCorrelation::new(3).unwrap();
171 assert_eq!(rc.update((1.0, 1.0)), None);
173 assert_eq!(rc.update((2.0, 3.0)), None); assert_eq!(rc.update((3.0, 5.0)), None); assert!(rc.update((4.0, 7.0)).is_some()); assert!(rc.is_ready());
177 }
178
179 #[test]
180 fn comoving_returns_are_plus_one() {
181 let pairs: Vec<(f64, f64)> = (0..20)
183 .map(|i| {
184 let x = (f64::from(i) * 0.5).sin() * 10.0;
185 (x, 2.0 * x + 100.0)
186 })
187 .collect();
188 let last = RollingCorrelation::new(8)
189 .unwrap()
190 .batch(&pairs)
191 .into_iter()
192 .flatten()
193 .last()
194 .unwrap();
195 assert_relative_eq!(last, 1.0, epsilon = 1e-9);
196 }
197
198 #[test]
199 fn opposing_returns_are_minus_one() {
200 let pairs: Vec<(f64, f64)> = (0..20)
201 .map(|i| {
202 let x = (f64::from(i) * 0.5).sin() * 10.0;
203 (x, -1.5 * x + 50.0)
204 })
205 .collect();
206 let last = RollingCorrelation::new(8)
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_return_channel_yields_zero() {
218 let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
220 let last = RollingCorrelation::new(6)
221 .unwrap()
222 .batch(&pairs)
223 .into_iter()
224 .flatten()
225 .last()
226 .unwrap();
227 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
228 }
229
230 #[test]
231 fn output_in_range() {
232 let pairs: Vec<(f64, f64)> = (0..80)
233 .map(|i| {
234 let t = f64::from(i);
235 (100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
236 })
237 .collect();
238 let mut rc = RollingCorrelation::new(20).unwrap();
239 for v in rc.batch(&pairs).into_iter().flatten() {
240 assert!((-1.0..=1.0).contains(&v));
241 }
242 }
243
244 #[test]
245 fn reset_clears_state() {
246 let mut rc = RollingCorrelation::new(4).unwrap();
247 rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
248 assert!(rc.is_ready());
249 rc.reset();
250 assert!(!rc.is_ready());
251 assert_eq!(rc.update((1.0, 1.0)), None);
252 }
253
254 #[test]
255 fn batch_equals_streaming() {
256 let pairs: Vec<(f64, f64)> = (0..60)
257 .map(|i| {
258 let t = f64::from(i);
259 (t.sin(), (t * 0.5).cos())
260 })
261 .collect();
262 let batch = RollingCorrelation::new(14).unwrap().batch(&pairs);
263 let mut rc = RollingCorrelation::new(14).unwrap();
264 let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
265 assert_eq!(batch, streamed);
266 }
267
268 #[test]
269 fn non_finite_input_returns_none() {
270 let mut rc = RollingCorrelation::new(2).unwrap();
271 assert_eq!(rc.update((f64::NAN, 1.0)), None);
272 assert_eq!(rc.update((1.0, f64::INFINITY)), None);
273 assert_eq!(rc.update((1.0, 1.0)), None);
275 assert_eq!(rc.update((2.0, 3.0)), None);
276 assert!(rc.update((3.0, 5.0)).is_some());
277 }
278}