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 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        // 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    #[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(); // 23
259        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        // A clean 20-bar sine: the dominant cycle estimate should settle near 20.
280        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        // The rejected input must not have disturbed the state.
302        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        // Constant input has zero variance, so every lag correlation is
333        // degenerate (denom <= 0), the max power is zero and no period clears
334        // the 0.5 threshold -> the dominant cycle defaults to `min_period`.
335        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}