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 across time)
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` or
82    /// `max_period <= min_period`.
83    pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
84        if min_period == 0 || max_period == 0 {
85            return Err(Error::PeriodZero);
86        }
87        if min_period < AVG_LENGTH + 1 || max_period <= min_period {
88            return Err(Error::InvalidPeriod {
89                message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
90            });
91        }
92        Ok(Self {
93            min_period,
94            max_period,
95            roof: RoofingFilter::new(10, max_period)?,
96            buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
97            r: vec![0.0; max_period + 1],
98            max_pwr: 0.0,
99            last: None,
100            trig: trig_table(min_period, max_period),
101            corr: vec![0.0; max_period + 1],
102        })
103    }
104
105    /// Configured `(min_period, max_period)`.
106    pub const fn periods(&self) -> (usize, usize) {
107        (self.min_period, self.max_period)
108    }
109
110    /// Current dominant-cycle estimate if available.
111    pub const fn value(&self) -> Option<f64> {
112        self.last
113    }
114
115    /// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
116    /// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
117    fn correlation(&self, lag: usize) -> f64 {
118        let len = self.buffer.len();
119        let filt = |k: usize| self.buffer[len - 1 - k];
120        let m = AVG_LENGTH as f64;
121        let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
122        for count in 0..AVG_LENGTH {
123            let x = filt(count);
124            let y = filt(lag + count);
125            sx += x;
126            sy += y;
127            sxx += x * x;
128            syy += y * y;
129            sxy += x * y;
130        }
131        let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
132        if denom > 0.0 {
133            (m * sxy - sx * sy) / denom.sqrt()
134        } else {
135            0.0
136        }
137    }
138}
139
140/// `(cos, sin)` of `2π·n/period`, the periodogram's weight for lag `n`.
141fn trig_term(n: usize, period: usize) -> (f64, f64) {
142    let angle = TAU * n as f64 / period as f64;
143    (angle.cos(), angle.sin())
144}
145
146/// Every [`trig_term`] the periodogram uses, period-major, unless that is more
147/// than [`TABLE_LIMIT`] pairs. The same function on the same arguments, so the
148/// table holds exactly the values computed in place.
149fn trig_table(min_period: usize, max_period: usize) -> Option<Box<[(f64, f64)]>> {
150    let pairs = (max_period + 1 - min_period) * (max_period + 1 - AVG_LENGTH);
151    (pairs <= TABLE_LIMIT).then(|| {
152        (min_period..=max_period)
153            .flat_map(|period| (AVG_LENGTH..=max_period).map(move |n| trig_term(n, period)))
154            .collect()
155    })
156}
157
158impl Indicator for AutocorrelationPeriodogram {
159    type Input = f64;
160    type Output = f64;
161
162    fn update(&mut self, price: f64) -> Option<f64> {
163        if !price.is_finite() {
164            return None;
165        }
166        let filt = self.roof.update(price)?;
167        if self.buffer.len() == self.max_period + AVG_LENGTH {
168            self.buffer.pop_front();
169        }
170        self.buffer.push_back(filt);
171        if self.buffer.len() < self.max_period + AVG_LENGTH {
172            return None;
173        }
174
175        // Autocorrelation across lags.
176        let mut corr = std::mem::take(&mut self.corr);
177        for (lag, c) in corr.iter_mut().enumerate() {
178            *c = self.correlation(lag);
179        }
180
181        // Periodogram: spectral power for each candidate period, EMA'd over time.
182        self.max_pwr *= 0.995;
183        let lags = self.max_period + 1 - AVG_LENGTH;
184        for (row, period) in (self.min_period..=self.max_period).enumerate() {
185            let mut cosine = 0.0;
186            let mut sine = 0.0;
187            if let Some(table) = &self.trig {
188                let weights = &table[row * lags..(row + 1) * lags];
189                for (&cn, &(cos, sin)) in corr[AVG_LENGTH..].iter().zip(weights) {
190                    cosine += cn * cos;
191                    sine += cn * sin;
192                }
193            } else {
194                for (n, &cn) in corr.iter().enumerate().skip(AVG_LENGTH) {
195                    let (cos, sin) = trig_term(n, period);
196                    cosine += cn * cos;
197                    sine += cn * sin;
198                }
199            }
200            let power = cosine * cosine + sine * sine;
201            self.r[period] = 0.2 * power + 0.8 * self.r[period];
202            if self.r[period] > self.max_pwr {
203                self.max_pwr = self.r[period];
204            }
205        }
206
207        // Power-weighted centre of gravity of the strong periods.
208        let mut spx = 0.0;
209        let mut sp = 0.0;
210        for period in self.min_period..=self.max_period {
211            let pwr = if self.max_pwr > 0.0 {
212                self.r[period] / self.max_pwr
213            } else {
214                0.0
215            };
216            if pwr >= 0.5 {
217                spx += period as f64 * pwr;
218                sp += pwr;
219            }
220        }
221        let dominant = if sp > 0.0 {
222            (spx / sp).clamp(self.min_period as f64, self.max_period as f64)
223        } else {
224            self.min_period as f64
225        };
226        self.corr = corr;
227        self.last = Some(dominant);
228        Some(dominant)
229    }
230
231    fn reset(&mut self) {
232        self.roof.reset();
233        self.buffer.clear();
234        self.r.iter_mut().for_each(|x| *x = 0.0);
235        self.max_pwr = 0.0;
236        self.last = None;
237    }
238
239    #[inline]
240    fn warmup_period(&self) -> usize {
241        self.max_period + AVG_LENGTH
242    }
243
244    #[inline]
245    fn is_ready(&self) -> bool {
246        self.last.is_some()
247    }
248
249    #[inline]
250    fn name(&self) -> &'static str {
251        "AutocorrelationPeriodogram"
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::traits::BatchExt;
259
260    #[test]
261    fn the_trig_table_gives_the_bits_of_the_terms_computed_in_place() {
262        let prices: Vec<f64> = (0..600)
263            .map(|i| {
264                let t = f64::from(i);
265                100.0 + (TAU * t / 23.0).sin() * 5.0 + (t * 0.37).cos()
266            })
267            .collect();
268        let mut table = AutocorrelationPeriodogram::new(10, 48).unwrap();
269        let mut in_place = table.clone();
270        assert!(table.trig.is_some());
271        in_place.trig = None;
272        for &price in &prices {
273            let (a, b) = (table.update(price), in_place.update(price));
274            assert_eq!(a.map(f64::to_bits), b.map(f64::to_bits));
275        }
276        // A range whose table would pass the limit computes its terms in place.
277        assert!(AutocorrelationPeriodogram::new(10, 300)
278            .unwrap()
279            .trig
280            .is_none());
281    }
282
283    #[test]
284    fn rejects_invalid_periods() {
285        assert!(matches!(
286            AutocorrelationPeriodogram::new(0, 48),
287            Err(Error::PeriodZero)
288        ));
289        assert!(matches!(
290            AutocorrelationPeriodogram::new(3, 48),
291            Err(Error::InvalidPeriod { .. })
292        ));
293        assert!(matches!(
294            AutocorrelationPeriodogram::new(48, 10),
295            Err(Error::InvalidPeriod { .. })
296        ));
297    }
298
299    #[test]
300    fn accessors_and_metadata() {
301        let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
302        assert_eq!(p.periods(), (10, 48));
303        assert_eq!(p.warmup_period(), 51);
304        assert_eq!(p.name(), "AutocorrelationPeriodogram");
305        assert!(!p.is_ready());
306        assert_eq!(p.value(), None);
307    }
308
309    #[test]
310    fn first_emission_at_warmup_period() {
311        let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
312        let xs: Vec<f64> = (0..40)
313            .map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
314            .collect();
315        let out = p.batch(&xs);
316        let warmup = p.warmup_period(); // 23
317        assert_eq!(warmup, 23);
318        for v in out.iter().take(warmup - 1) {
319            assert!(v.is_none());
320        }
321        assert!(out[warmup - 1].is_some());
322    }
323
324    #[test]
325    fn output_within_period_band() {
326        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
327        let xs: Vec<f64> = (0..400)
328            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
329            .collect();
330        for v in p.batch(&xs).into_iter().flatten() {
331            assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
332        }
333    }
334
335    #[test]
336    fn detects_injected_cycle() {
337        // A clean 20-bar sine: the dominant cycle estimate should settle near 20.
338        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
339        let xs: Vec<f64> = (0..600)
340            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
341            .collect();
342        let last = p.batch(&xs).into_iter().flatten().last().unwrap();
343        assert!(
344            (last - 20.0).abs() < 6.0,
345            "expected ~20-bar cycle, got {last}"
346        );
347    }
348
349    #[test]
350    fn ignores_non_finite() {
351        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
352        p.batch(
353            &(0..80)
354                .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
355                .collect::<Vec<_>>(),
356        );
357        let before = p.value();
358        assert_eq!(p.update(f64::NAN), None);
359        // The rejected input must not have disturbed the state.
360        assert_eq!(p.value(), before);
361    }
362
363    #[test]
364    fn reset_clears_state() {
365        let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
366        p.batch(
367            &(0..120)
368                .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
369                .collect::<Vec<_>>(),
370        );
371        assert!(p.is_ready());
372        p.reset();
373        assert!(!p.is_ready());
374        assert_eq!(p.value(), None);
375    }
376
377    #[test]
378    fn batch_equals_streaming() {
379        let xs: Vec<f64> = (0..200)
380            .map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
381            .collect();
382        let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
383        let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
384        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
385        assert_eq!(batch, streamed);
386    }
387
388    #[test]
389    fn flat_input_falls_back_to_min_period() {
390        // Constant input has zero variance, so every lag correlation is
391        // degenerate (denom <= 0), the max power is zero and no period clears
392        // the 0.5 threshold -> the dominant cycle defaults to `min_period`.
393        let flat = [100.0_f64; 200];
394        let last = AutocorrelationPeriodogram::new(10, 48)
395            .unwrap()
396            .batch(&flat)
397            .into_iter()
398            .flatten()
399            .last()
400            .unwrap();
401        assert_eq!(last, 10.0);
402    }
403}