Skip to main content

wickra_core/indicators/
autocorrelation_periodogram.rs

1//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
2#![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
11/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
12const AVG_LENGTH: usize = 3;
13
14/// Largest cosine/sine table kept, in `(period, lag)` pairs (1 MiB). Beyond it
15/// the terms are computed where they are used, as before the table existed.
16const TABLE_LIMIT: usize = 1 << 16;
17
18/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
19/// period** of the market by correlating a roofing-filtered price with lagged
20/// copies of itself and reading off the spectral peak.
21///
22/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
23///
24/// ```text
25/// Filt = RoofingFilter(price)                                   (detrend + denoise)
26/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] )   for lag = 0..max_period
27/// for each candidate period:
28///   power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
29/// R[period]    = 0.2·power[period]² + 0.8·R[period]_{t−1}       (EMA of SqSum²)
30/// normalise by a decaying max, then
31/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
32/// ```
33///
34/// The autocorrelation function emphasises whatever cycle is actually present and
35/// suppresses noise; transforming it into a periodogram and taking the
36/// power-weighted centre of gravity gives a smooth, robust estimate of the
37/// dominant cycle length. That cycle is the key input for every *adaptive*
38/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
39/// output is a period in bars within `[min_period, max_period]`.
40///
41/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
42/// O(`max_period²`); the cosines and sines it weighs the correlations by depend
43/// only on the configuration, so they are computed once, at construction.
44///
45/// # Example
46///
47/// ```
48/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
49/// use std::f64::consts::TAU;
50///
51/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
52/// let mut last = None;
53/// for i in 0..200 {
54///     last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
55/// }
56/// assert!(last.is_some());
57/// ```
58#[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    /// `(cos, sin)` of `2π·n/period` for every candidate period (rows) and lag
68    /// `n` in `AvgLength..=max_period` (columns); `None` above [`TABLE_LIMIT`].
69    trig: Option<Box<[(f64, f64)]>>,
70    /// Scratch for the lagged correlations, reused across updates.
71    corr: Vec<f64>,
72}
73
74impl AutocorrelationPeriodogram {
75    /// Construct an autocorrelation periodogram searching cycles in
76    /// `[min_period, max_period]`.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`Error::PeriodZero`] if either period is `0`, or
81    /// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1`,
82    /// `max_period <= min_period`, or `max_period <= 10` (the roofing
83    /// pre-filter's 10-bar low-pass cutoff must sit below `max_period`).
84    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    /// Configured `(min_period, max_period)`.
107    pub const fn periods(&self) -> (usize, usize) {
108        (self.min_period, self.max_period)
109    }
110
111    /// Current dominant-cycle estimate if available.
112    pub const fn value(&self) -> Option<f64> {
113        self.last
114    }
115
116    /// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
117    /// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
118    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
141/// `(cos, sin)` of `2π·n/period`, the periodogram's weight for lag `n`.
142fn 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
147/// Every [`trig_term`] the periodogram uses, period-major, unless that is more
148/// than [`TABLE_LIMIT`] pairs. The same function on the same arguments, so the
149/// table holds exactly the values computed in place.
150fn 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        // Autocorrelation across lags.
177        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        // Periodogram: spectral power for each candidate period, EMA'd over time.
183        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            // Ehlers smooths the *square* of the summed power (SqSum²), which
202            // sharpens the dominant peak against the side lobes.
203            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        // Power-weighted centre of gravity of the strong periods.
211        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        // A range whose table would pass the limit computes its terms in place.
281        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(); // 23
321        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        // A clean 20-bar sine: the dominant cycle estimate should settle near 20.
342        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        // The rejected input must not have disturbed the state.
361        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        // Constant input has zero variance, so every lag correlation is
392        // degenerate (denom <= 0), the max power is zero and no period clears
393        // the 0.5 threshold -> the dominant cycle defaults to `min_period`.
394        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        // The internal RoofingFilter(10, max_period) also needs `max_period > 10`.
416        assert!(matches!(
417            AutocorrelationPeriodogram::new(4, 10),
418            Err(Error::InvalidPeriod { .. })
419        ));
420        // `min_period = AvgLength + 1` is the smallest accepted value.
421        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    /// Hand-computed Pearson correlation on a crafted buffer (oldest first)
462    /// `[3, 1, 2, 1, 2, 3]`, so `filt(0..6) = 3, 2, 1, 2, 1, 3`.
463    /// Lag 3: `x = (3, 2, 1)`, `y = (2, 1, 3)`; `Σx = Σy = 6`, `Σx² = Σy² = 14`,
464    /// `Σxy = 6 + 2 + 3 = 11`. Numerator `3·11 − 6·6 = −3`; denominator
465    /// `sqrt((3·14 − 36)·(3·14 − 36)) = 6`; correlation `−0.5`.
466    /// Lag 0 is the series against itself: `1`.
467    #[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        // A flat lagged slice has zero variance: denominator 0 -> correlation 0.
474        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    /// The periodogram smooths the *square* of the summed power:
480    /// `R_t = 0.2 · SqSum² + 0.8 · R_{t−1}`, with `SqSum = cos² + sin²` of the
481    /// correlation-weighted sums. Replay that recurrence from the correlations the
482    /// indicator itself computed (left in its scratch buffer) and compare.
483    #[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        // The first emission starts from `R_{t−1} = 0`, i.e. `R = 0.2 · SqSum²`.
508        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        // Feed a real series, then recompute the centre of gravity of the periods
515        // with normalised power >= 0.5 from the indicator's own `r` / `max_pwr`.
516        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}