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/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
15/// period** of the market by correlating a roofing-filtered price with lagged
16/// copies of itself and reading off the spectral peak.
17///
18/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
19///
20/// ```text
21/// Filt = RoofingFilter(price)                                   (detrend + denoise)
22/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] )   for lag = 0..max_period
23/// for each candidate period:
24///   power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
25/// R[period]    = 0.2·power[period] + 0.8·R[period]_{t−1}        (EMA across time)
26/// normalise by a decaying max, then
27/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
28/// ```
29///
30/// The autocorrelation function emphasises whatever cycle is actually present and
31/// suppresses noise; transforming it into a periodogram and taking the
32/// power-weighted centre of gravity gives a smooth, robust estimate of the
33/// dominant cycle length. That cycle is the key input for every *adaptive*
34/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
35/// output is a period in bars within `[min_period, max_period]`.
36///
37/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
38/// O(`max_period²`).
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
44/// use std::f64::consts::TAU;
45///
46/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
47/// let mut last = None;
48/// for i in 0..200 {
49///     last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[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    /// Construct an autocorrelation periodogram searching cycles in
66    /// `[min_period, max_period]`.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`Error::PeriodZero`] if either period is `0`, or
71    /// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
72    /// `max_period <= min_period`.
73    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    /// Configured `(min_period, max_period)`.
94    pub const fn periods(&self) -> (usize, usize) {
95        (self.min_period, self.max_period)
96    }
97
98    /// Current dominant-cycle estimate if available.
99    pub const fn value(&self) -> Option<f64> {
100        self.last
101    }
102
103    /// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
104    /// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
105    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 self.last;
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        // Autocorrelation across lags.
146        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        // Periodogram: spectral power for each candidate period, EMA'd over time.
152        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        // Power-weighted centre of gravity of the strong periods.
174        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    fn warmup_period(&self) -> usize {
205        self.max_period + AVG_LENGTH
206    }
207
208    fn is_ready(&self) -> bool {
209        self.last.is_some()
210    }
211
212    fn name(&self) -> &'static str {
213        "AutocorrelationPeriodogram"
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::traits::BatchExt;
221
222    #[test]
223    fn rejects_invalid_periods() {
224        assert!(matches!(
225            AutocorrelationPeriodogram::new(0, 48),
226            Err(Error::PeriodZero)
227        ));
228        assert!(matches!(
229            AutocorrelationPeriodogram::new(3, 48),
230            Err(Error::InvalidPeriod { .. })
231        ));
232        assert!(matches!(
233            AutocorrelationPeriodogram::new(48, 10),
234            Err(Error::InvalidPeriod { .. })
235        ));
236    }
237
238    #[test]
239    fn accessors_and_metadata() {
240        let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
241        assert_eq!(p.periods(), (10, 48));
242        assert_eq!(p.warmup_period(), 51);
243        assert_eq!(p.name(), "AutocorrelationPeriodogram");
244        assert!(!p.is_ready());
245        assert_eq!(p.value(), None);
246    }
247
248    #[test]
249    fn first_emission_at_warmup_period() {
250        let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
251        let xs: Vec<f64> = (0..40)
252            .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
253            .collect();
254        let out = p.batch(&xs);
255        let warmup = p.warmup_period(); // 23
256        assert_eq!(warmup, 23);
257        for v in out.iter().take(warmup - 1) {
258            assert!(v.is_none());
259        }
260        assert!(out[warmup - 1].is_some());
261    }
262
263    #[test]
264    fn output_within_period_band() {
265        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
266        let xs: Vec<f64> = (0..400)
267            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
268            .collect();
269        for v in p.batch(&xs).into_iter().flatten() {
270            assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
271        }
272    }
273
274    #[test]
275    fn detects_injected_cycle() {
276        // A clean 20-bar sine: the dominant cycle estimate should settle near 20.
277        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
278        let xs: Vec<f64> = (0..600)
279            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
280            .collect();
281        let last = p.batch(&xs).into_iter().flatten().last().unwrap();
282        assert!(
283            (last - 20.0).abs() < 6.0,
284            "expected ~20-bar cycle, got {last}"
285        );
286    }
287
288    #[test]
289    fn ignores_non_finite() {
290        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
291        p.batch(
292            &(0..80)
293                .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
294                .collect::<Vec<_>>(),
295        );
296        let before = p.value();
297        assert_eq!(p.update(f64::NAN), before);
298    }
299
300    #[test]
301    fn reset_clears_state() {
302        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
303        p.batch(
304            &(0..120)
305                .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
306                .collect::<Vec<_>>(),
307        );
308        assert!(p.is_ready());
309        p.reset();
310        assert!(!p.is_ready());
311        assert_eq!(p.value(), None);
312    }
313
314    #[test]
315    fn batch_equals_streaming() {
316        let xs: Vec<f64> = (0..200)
317            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
318            .collect();
319        let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
320        let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
321        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
322        assert_eq!(batch, streamed);
323    }
324
325    #[test]
326    fn flat_input_falls_back_to_min_period() {
327        // Constant input has zero variance, so every lag correlation is
328        // degenerate (denom <= 0), the max power is zero and no period clears
329        // the 0.5 threshold -> the dominant cycle defaults to `min_period`.
330        let flat = [100.0_f64; 200];
331        let last = AutocorrelationPeriodogram::new(10, 48)
332            .unwrap()
333            .batch(&flat)
334            .into_iter()
335            .flatten()
336            .last()
337            .unwrap();
338        assert_eq!(last, 10.0);
339    }
340}