wickra_core/indicators/
autocorrelation_periodogram.rs1#![allow(clippy::doc_markdown)]
3
4use std::collections::VecDeque;
5use std::f64::consts::TAU;
6
7use crate::error::{Error, Result};
8use crate::indicators::roofing_filter::RoofingFilter;
9use crate::traits::Indicator;
10
11const AVG_LENGTH: usize = 3;
13
14#[derive(Debug, Clone)]
54pub struct AutocorrelationPeriodogram {
55 min_period: usize,
56 max_period: usize,
57 roof: RoofingFilter,
58 buffer: VecDeque<f64>,
59 r: Vec<f64>,
60 max_pwr: f64,
61 last: Option<f64>,
62}
63
64impl AutocorrelationPeriodogram {
65 pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
74 if min_period == 0 || max_period == 0 {
75 return Err(Error::PeriodZero);
76 }
77 if min_period < AVG_LENGTH + 1 || max_period <= min_period {
78 return Err(Error::InvalidPeriod {
79 message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
80 });
81 }
82 Ok(Self {
83 min_period,
84 max_period,
85 roof: RoofingFilter::new(10, max_period)?,
86 buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
87 r: vec![0.0; max_period + 1],
88 max_pwr: 0.0,
89 last: None,
90 })
91 }
92
93 pub const fn periods(&self) -> (usize, usize) {
95 (self.min_period, self.max_period)
96 }
97
98 pub const fn value(&self) -> Option<f64> {
100 self.last
101 }
102
103 fn correlation(&self, lag: usize) -> f64 {
106 let len = self.buffer.len();
107 let filt = |k: usize| self.buffer[len - 1 - k];
108 let m = AVG_LENGTH as f64;
109 let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
110 for count in 0..AVG_LENGTH {
111 let x = filt(count);
112 let y = filt(lag + count);
113 sx += x;
114 sy += y;
115 sxx += x * x;
116 syy += y * y;
117 sxy += x * y;
118 }
119 let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
120 if denom > 0.0 {
121 (m * sxy - sx * sy) / denom.sqrt()
122 } else {
123 0.0
124 }
125 }
126}
127
128impl Indicator for AutocorrelationPeriodogram {
129 type Input = f64;
130 type Output = f64;
131
132 fn update(&mut self, price: f64) -> Option<f64> {
133 if !price.is_finite() {
134 return None;
135 }
136 let filt = self.roof.update(price)?;
137 if self.buffer.len() == self.max_period + AVG_LENGTH {
138 self.buffer.pop_front();
139 }
140 self.buffer.push_back(filt);
141 if self.buffer.len() < self.max_period + AVG_LENGTH {
142 return None;
143 }
144
145 let mut corr = vec![0.0; self.max_period + 1];
147 for (lag, c) in corr.iter_mut().enumerate() {
148 *c = self.correlation(lag);
149 }
150
151 self.max_pwr *= 0.995;
153 for period in self.min_period..=self.max_period {
154 let mut cosine = 0.0;
155 let mut sine = 0.0;
156 for (n, &cn) in corr
157 .iter()
158 .enumerate()
159 .take(self.max_period + 1)
160 .skip(AVG_LENGTH)
161 {
162 let angle = TAU * n as f64 / period as f64;
163 cosine += cn * angle.cos();
164 sine += cn * angle.sin();
165 }
166 let power = cosine * cosine + sine * sine;
167 self.r[period] = 0.2 * power + 0.8 * self.r[period];
168 if self.r[period] > self.max_pwr {
169 self.max_pwr = self.r[period];
170 }
171 }
172
173 let mut spx = 0.0;
175 let mut sp = 0.0;
176 for period in self.min_period..=self.max_period {
177 let pwr = if self.max_pwr > 0.0 {
178 self.r[period] / self.max_pwr
179 } else {
180 0.0
181 };
182 if pwr >= 0.5 {
183 spx += period as f64 * pwr;
184 sp += pwr;
185 }
186 }
187 let dominant = if sp > 0.0 {
188 (spx / sp).clamp(self.min_period as f64, self.max_period as f64)
189 } else {
190 self.min_period as f64
191 };
192 self.last = Some(dominant);
193 Some(dominant)
194 }
195
196 fn reset(&mut self) {
197 self.roof.reset();
198 self.buffer.clear();
199 self.r.iter_mut().for_each(|x| *x = 0.0);
200 self.max_pwr = 0.0;
201 self.last = None;
202 }
203
204 #[inline]
205 fn warmup_period(&self) -> usize {
206 self.max_period + AVG_LENGTH
207 }
208
209 #[inline]
210 fn is_ready(&self) -> bool {
211 self.last.is_some()
212 }
213
214 #[inline]
215 fn name(&self) -> &'static str {
216 "AutocorrelationPeriodogram"
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::traits::BatchExt;
224
225 #[test]
226 fn rejects_invalid_periods() {
227 assert!(matches!(
228 AutocorrelationPeriodogram::new(0, 48),
229 Err(Error::PeriodZero)
230 ));
231 assert!(matches!(
232 AutocorrelationPeriodogram::new(3, 48),
233 Err(Error::InvalidPeriod { .. })
234 ));
235 assert!(matches!(
236 AutocorrelationPeriodogram::new(48, 10),
237 Err(Error::InvalidPeriod { .. })
238 ));
239 }
240
241 #[test]
242 fn accessors_and_metadata() {
243 let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
244 assert_eq!(p.periods(), (10, 48));
245 assert_eq!(p.warmup_period(), 51);
246 assert_eq!(p.name(), "AutocorrelationPeriodogram");
247 assert!(!p.is_ready());
248 assert_eq!(p.value(), None);
249 }
250
251 #[test]
252 fn first_emission_at_warmup_period() {
253 let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
254 let xs: Vec<f64> = (0..40)
255 .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
256 .collect();
257 let out = p.batch(&xs);
258 let warmup = p.warmup_period(); assert_eq!(warmup, 23);
260 for v in out.iter().take(warmup - 1) {
261 assert!(v.is_none());
262 }
263 assert!(out[warmup - 1].is_some());
264 }
265
266 #[test]
267 fn output_within_period_band() {
268 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
269 let xs: Vec<f64> = (0..400)
270 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
271 .collect();
272 for v in p.batch(&xs).into_iter().flatten() {
273 assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
274 }
275 }
276
277 #[test]
278 fn detects_injected_cycle() {
279 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
281 let xs: Vec<f64> = (0..600)
282 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
283 .collect();
284 let last = p.batch(&xs).into_iter().flatten().last().unwrap();
285 assert!(
286 (last - 20.0).abs() < 6.0,
287 "expected ~20-bar cycle, got {last}"
288 );
289 }
290
291 #[test]
292 fn ignores_non_finite() {
293 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
294 p.batch(
295 &(0..80)
296 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
297 .collect::<Vec<_>>(),
298 );
299 let before = p.value();
300 assert_eq!(p.update(f64::NAN), None);
301 assert_eq!(p.value(), before);
303 }
304
305 #[test]
306 fn reset_clears_state() {
307 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
308 p.batch(
309 &(0..120)
310 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
311 .collect::<Vec<_>>(),
312 );
313 assert!(p.is_ready());
314 p.reset();
315 assert!(!p.is_ready());
316 assert_eq!(p.value(), None);
317 }
318
319 #[test]
320 fn batch_equals_streaming() {
321 let xs: Vec<f64> = (0..200)
322 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
323 .collect();
324 let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
325 let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
326 let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
327 assert_eq!(batch, streamed);
328 }
329
330 #[test]
331 fn flat_input_falls_back_to_min_period() {
332 let flat = [100.0_f64; 200];
336 let last = AutocorrelationPeriodogram::new(10, 48)
337 .unwrap()
338 .batch(&flat)
339 .into_iter()
340 .flatten()
341 .last()
342 .unwrap();
343 assert_eq!(last, 10.0);
344 }
345}