1#![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
14const TABLE_LIMIT: usize = 1 << 16;
17
18#[derive(Debug, Clone)]
59pub struct AutocorrelationPeriodogram {
60 min_period: usize,
61 max_period: usize,
62 roof: RoofingFilter,
63 buffer: VecDeque<f64>,
64 r: Vec<f64>,
65 max_pwr: f64,
66 last: Option<f64>,
67 trig: Option<Box<[(f64, f64)]>>,
70 corr: Vec<f64>,
72}
73
74impl AutocorrelationPeriodogram {
75 pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
85 if min_period == 0 || max_period == 0 {
86 return Err(Error::PeriodZero);
87 }
88 if min_period < AVG_LENGTH + 1 || max_period <= min_period {
89 return Err(Error::InvalidPeriod {
90 message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
91 });
92 }
93 Ok(Self {
94 min_period,
95 max_period,
96 roof: RoofingFilter::new(10, max_period)?,
97 buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
98 r: vec![0.0; max_period + 1],
99 max_pwr: 0.0,
100 last: None,
101 trig: trig_table(min_period, max_period),
102 corr: vec![0.0; max_period + 1],
103 })
104 }
105
106 pub const fn periods(&self) -> (usize, usize) {
108 (self.min_period, self.max_period)
109 }
110
111 pub const fn value(&self) -> Option<f64> {
113 self.last
114 }
115
116 fn correlation(&self, lag: usize) -> f64 {
119 let len = self.buffer.len();
120 let filt = |k: usize| self.buffer[len - 1 - k];
121 let m = AVG_LENGTH as f64;
122 let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
123 for count in 0..AVG_LENGTH {
124 let x = filt(count);
125 let y = filt(lag + count);
126 sx += x;
127 sy += y;
128 sxx += x * x;
129 syy += y * y;
130 sxy += x * y;
131 }
132 let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
133 if denom > 0.0 {
134 (m * sxy - sx * sy) / denom.sqrt()
135 } else {
136 0.0
137 }
138 }
139}
140
141fn trig_term(n: usize, period: usize) -> (f64, f64) {
143 let angle = TAU * n as f64 / period as f64;
144 (angle.cos(), angle.sin())
145}
146
147fn trig_table(min_period: usize, max_period: usize) -> Option<Box<[(f64, f64)]>> {
151 let pairs = (max_period + 1 - min_period) * (max_period + 1 - AVG_LENGTH);
152 (pairs <= TABLE_LIMIT).then(|| {
153 (min_period..=max_period)
154 .flat_map(|period| (AVG_LENGTH..=max_period).map(move |n| trig_term(n, period)))
155 .collect()
156 })
157}
158
159impl Indicator for AutocorrelationPeriodogram {
160 type Input = f64;
161 type Output = f64;
162
163 fn update(&mut self, price: f64) -> Option<f64> {
164 if !price.is_finite() {
165 return None;
166 }
167 let filt = self.roof.update(price)?;
168 if self.buffer.len() == self.max_period + AVG_LENGTH {
169 self.buffer.pop_front();
170 }
171 self.buffer.push_back(filt);
172 if self.buffer.len() < self.max_period + AVG_LENGTH {
173 return None;
174 }
175
176 let mut corr = std::mem::take(&mut self.corr);
178 for (lag, c) in corr.iter_mut().enumerate() {
179 *c = self.correlation(lag);
180 }
181
182 self.max_pwr *= 0.995;
184 let lags = self.max_period + 1 - AVG_LENGTH;
185 for (row, period) in (self.min_period..=self.max_period).enumerate() {
186 let mut cosine = 0.0;
187 let mut sine = 0.0;
188 if let Some(table) = &self.trig {
189 let weights = &table[row * lags..(row + 1) * lags];
190 for (&cn, &(cos, sin)) in corr[AVG_LENGTH..].iter().zip(weights) {
191 cosine += cn * cos;
192 sine += cn * sin;
193 }
194 } else {
195 for (n, &cn) in corr.iter().enumerate().skip(AVG_LENGTH) {
196 let (cos, sin) = trig_term(n, period);
197 cosine += cn * cos;
198 sine += cn * sin;
199 }
200 }
201 let sq_sum = cosine * cosine + sine * sine;
204 self.r[period] = 0.2 * sq_sum * sq_sum + 0.8 * self.r[period];
205 if self.r[period] > self.max_pwr {
206 self.max_pwr = self.r[period];
207 }
208 }
209
210 let mut spx = 0.0;
212 let mut sp = 0.0;
213 for period in self.min_period..=self.max_period {
214 let pwr = if self.max_pwr > 0.0 {
215 self.r[period] / self.max_pwr
216 } else {
217 0.0
218 };
219 if pwr >= 0.5 {
220 spx += period as f64 * pwr;
221 sp += pwr;
222 }
223 }
224 let dominant = if sp > 0.0 {
225 (spx / sp).clamp(self.min_period as f64, self.max_period as f64)
226 } else {
227 self.min_period as f64
228 };
229 self.corr = corr;
230 self.last = Some(dominant);
231 Some(dominant)
232 }
233
234 fn reset(&mut self) {
235 self.roof.reset();
236 self.buffer.clear();
237 self.r.iter_mut().for_each(|x| *x = 0.0);
238 self.max_pwr = 0.0;
239 self.last = None;
240 }
241
242 #[inline]
243 fn warmup_period(&self) -> usize {
244 self.max_period + AVG_LENGTH
245 }
246
247 #[inline]
248 fn is_ready(&self) -> bool {
249 self.last.is_some()
250 }
251
252 #[inline]
253 fn name(&self) -> &'static str {
254 "AutocorrelationPeriodogram"
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::traits::BatchExt;
262 use approx::assert_relative_eq;
263
264 #[test]
265 fn the_trig_table_gives_the_bits_of_the_terms_computed_in_place() {
266 let prices: Vec<f64> = (0..600)
267 .map(|i| {
268 let t = f64::from(i);
269 100.0 + (TAU * t / 23.0).sin() * 5.0 + (t * 0.37).cos()
270 })
271 .collect();
272 let mut table = AutocorrelationPeriodogram::new(10, 48).unwrap();
273 let mut in_place = table.clone();
274 assert!(table.trig.is_some());
275 in_place.trig = None;
276 for &price in &prices {
277 let (a, b) = (table.update(price), in_place.update(price));
278 assert_eq!(a.map(f64::to_bits), b.map(f64::to_bits));
279 }
280 assert!(AutocorrelationPeriodogram::new(10, 300)
282 .unwrap()
283 .trig
284 .is_none());
285 }
286
287 #[test]
288 fn rejects_invalid_periods() {
289 assert!(matches!(
290 AutocorrelationPeriodogram::new(0, 48),
291 Err(Error::PeriodZero)
292 ));
293 assert!(matches!(
294 AutocorrelationPeriodogram::new(3, 48),
295 Err(Error::InvalidPeriod { .. })
296 ));
297 assert!(matches!(
298 AutocorrelationPeriodogram::new(48, 10),
299 Err(Error::InvalidPeriod { .. })
300 ));
301 }
302
303 #[test]
304 fn accessors_and_metadata() {
305 let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
306 assert_eq!(p.periods(), (10, 48));
307 assert_eq!(p.warmup_period(), 51);
308 assert_eq!(p.name(), "AutocorrelationPeriodogram");
309 assert!(!p.is_ready());
310 assert_eq!(p.value(), None);
311 }
312
313 #[test]
314 fn first_emission_at_warmup_period() {
315 let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
316 let xs: Vec<f64> = (0..40)
317 .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
318 .collect();
319 let out = p.batch(&xs);
320 let warmup = p.warmup_period(); assert_eq!(warmup, 23);
322 for v in out.iter().take(warmup - 1) {
323 assert!(v.is_none());
324 }
325 assert!(out[warmup - 1].is_some());
326 }
327
328 #[test]
329 fn output_within_period_band() {
330 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
331 let xs: Vec<f64> = (0..400)
332 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
333 .collect();
334 for v in p.batch(&xs).into_iter().flatten() {
335 assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
336 }
337 }
338
339 #[test]
340 fn detects_injected_cycle() {
341 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
343 let xs: Vec<f64> = (0..600)
344 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
345 .collect();
346 let last = p.batch(&xs).into_iter().flatten().last().unwrap();
347 assert!((last - 20.0).abs() < 6.0, "expected a ~20-bar cycle");
348 }
349
350 #[test]
351 fn ignores_non_finite() {
352 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
353 p.batch(
354 &(0..80)
355 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
356 .collect::<Vec<_>>(),
357 );
358 let before = p.value();
359 assert_eq!(p.update(f64::NAN), None);
360 assert_eq!(p.value(), before);
362 }
363
364 #[test]
365 fn reset_clears_state() {
366 let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
367 p.batch(
368 &(0..120)
369 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
370 .collect::<Vec<_>>(),
371 );
372 assert!(p.is_ready());
373 p.reset();
374 assert!(!p.is_ready());
375 assert_eq!(p.value(), None);
376 }
377
378 #[test]
379 fn batch_equals_streaming() {
380 let xs: Vec<f64> = (0..200)
381 .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
382 .collect();
383 let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
384 let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
385 let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
386 assert_eq!(batch, streamed);
387 }
388
389 #[test]
390 fn flat_input_falls_back_to_min_period() {
391 let flat = [100.0_f64; 200];
395 let last = AutocorrelationPeriodogram::new(10, 48)
396 .unwrap()
397 .batch(&flat)
398 .into_iter()
399 .flatten()
400 .last()
401 .unwrap();
402 assert_eq!(last, 10.0);
403 }
404
405 #[test]
406 fn rejects_zero_max_period_and_min_period_equal_to_max() {
407 assert!(matches!(
408 AutocorrelationPeriodogram::new(10, 0),
409 Err(Error::PeriodZero)
410 ));
411 assert!(matches!(
412 AutocorrelationPeriodogram::new(10, 10),
413 Err(Error::InvalidPeriod { .. })
414 ));
415 assert!(matches!(
417 AutocorrelationPeriodogram::new(4, 10),
418 Err(Error::InvalidPeriod { .. })
419 ));
420 assert!(AutocorrelationPeriodogram::new(4, 11).is_ok());
422 }
423
424 fn noisy_cycle(len: i32) -> Vec<f64> {
425 (0..len)
426 .map(|i| {
427 let t = f64::from(i);
428 100.0 + (TAU * t / 17.0).sin() * 4.0 + (t * 0.91).cos() * 0.7
429 })
430 .collect()
431 }
432
433 #[test]
434 fn reset_replays_identically_to_fresh_instance() {
435 let xs = noisy_cycle(150);
436 let mut used = AutocorrelationPeriodogram::new(8, 30).unwrap();
437 used.batch(&xs);
438 used.reset();
439 let replay = used.batch(&xs);
440 assert_eq!(
441 replay,
442 AutocorrelationPeriodogram::new(8, 30).unwrap().batch(&xs)
443 );
444 }
445
446 #[test]
447 fn batch_nan_into_matches_streaming_bits() {
448 let xs = noisy_cycle(160);
449 let mut nan_out = vec![0.0; xs.len()];
450 AutocorrelationPeriodogram::new(8, 30)
451 .unwrap()
452 .batch_nan_into(&xs, &mut nan_out);
453 let mut streamer = AutocorrelationPeriodogram::new(8, 30).unwrap();
454 let identical = xs
455 .iter()
456 .zip(&nan_out)
457 .all(|(x, v)| streamer.update(*x).unwrap_or(f64::NAN).to_bits() == v.to_bits());
458 assert!(identical);
459 }
460
461 #[test]
468 fn correlation_reference_value() {
469 let mut p = AutocorrelationPeriodogram::new(4, 11).unwrap();
470 p.buffer.extend([3.0, 1.0, 2.0, 1.0, 2.0, 3.0]);
471 assert_relative_eq!(p.correlation(3), -0.5, epsilon = 1e-12);
472 assert_relative_eq!(p.correlation(0), 1.0, epsilon = 1e-12);
473 let mut flat = AutocorrelationPeriodogram::new(4, 11).unwrap();
475 flat.buffer.extend([1.0, 1.0, 1.0, 4.0, 5.0, 7.0]);
476 assert_relative_eq!(flat.correlation(3), 0.0, epsilon = 1e-12);
477 }
478
479 #[test]
484 fn r_is_ema_of_squared_sq_sum() {
485 let (min_period, max_period) = (6, 14);
486 let mut p = AutocorrelationPeriodogram::new(min_period, max_period).unwrap();
487 let mut r_prev = vec![0.0_f64; max_period + 1];
488 let mut steps = 0;
489 for x in noisy_cycle(60) {
490 if p.update(x).is_none() {
491 continue;
492 }
493 steps += 1;
494 for (period, prev) in r_prev.iter_mut().enumerate().skip(min_period) {
495 let (mut cosine, mut sine) = (0.0, 0.0);
496 for (n, corr) in p.corr.iter().enumerate().skip(AVG_LENGTH) {
497 let (cos, sin) = trig_term(n, period);
498 cosine += corr * cos;
499 sine += corr * sin;
500 }
501 let sq_sum = cosine * cosine + sine * sine;
502 let expected = 0.2 * sq_sum * sq_sum + 0.8 * *prev;
503 assert_relative_eq!(p.r[period], expected, epsilon = 1e-12, max_relative = 1e-12);
504 *prev = p.r[period];
505 }
506 }
507 assert_ne!(steps, 0);
509 assert!(p.max_pwr > 0.0);
510 }
511
512 #[test]
513 fn dominant_cycle_is_power_weighted_centre_of_gravity() {
514 let mut p = AutocorrelationPeriodogram::new(8, 30).unwrap();
517 let last = p
518 .batch(&noisy_cycle(200))
519 .into_iter()
520 .flatten()
521 .last()
522 .unwrap();
523 let (mut spx, mut sp) = (0.0, 0.0);
524 for period in 8..=30_u32 {
525 let pwr = p.r[usize::try_from(period).unwrap()] / p.max_pwr;
526 if pwr >= 0.5 {
527 spx += f64::from(period) * pwr;
528 sp += pwr;
529 }
530 }
531 assert_relative_eq!(last, spx / sp, epsilon = 1e-9);
532 }
533}