Skip to main content

wickra_core/indicators/
autocorrelation.rs

1//! Rolling lag-`k` autocorrelation.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling lag-`lag` autocorrelation of the last `period` inputs.
9///
10/// Over the trailing window the Pearson correlation between the series and
11/// itself shifted by `lag` is computed:
12///
13/// ```text
14/// y_i  for i = 0..period − 1
15/// ACF(lag) = Σ ( (y_i − ȳ) · (y_{i + lag} − ȳ) ) / Σ ( y_i − ȳ )²
16/// ```
17///
18/// `+1` means a perfectly repeating pattern at the given lag; `−1` means a
19/// perfect alternation. Values near `0` mean the series at `t` and `t −
20/// lag` carry no linear relationship — a clean white-noise proxy. The
21/// classic application is detecting periodicity (a peak in `|ACF(lag)|`
22/// flags a cycle of that length) or testing whether returns are
23/// uncorrelated (a key efficient-markets diagnostic).
24///
25/// `period` must be strictly greater than `lag` so that at least two
26/// `(y, y_lagged)` pairs exist. A flat window has zero variance; the
27/// indicator returns `0` rather than dividing by zero.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Autocorrelation, Indicator};
33///
34/// let mut indicator = Autocorrelation::new(20, 1).unwrap();
35/// let mut last = None;
36/// for i in 0..40 {
37///     last = indicator.update(f64::from(i));
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct Autocorrelation {
43    period: usize,
44    lag: usize,
45    window: VecDeque<f64>,
46}
47
48impl Autocorrelation {
49    /// Construct a new rolling lag-`lag` autocorrelation over `period` inputs.
50    ///
51    /// # Errors
52    /// Returns [`Error::InvalidPeriod`] if `lag == 0` or `lag >= period`.
53    pub fn new(period: usize, lag: usize) -> Result<Self> {
54        if lag == 0 {
55            return Err(Error::InvalidPeriod {
56                message: "autocorrelation lag must be >= 1",
57            });
58        }
59        if period <= lag {
60            return Err(Error::InvalidPeriod {
61                message: "autocorrelation needs period > lag",
62            });
63        }
64        Ok(Self {
65            period,
66            lag,
67            window: VecDeque::with_capacity(period),
68        })
69    }
70
71    /// Configured window period.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75
76    /// Configured lag.
77    pub const fn lag(&self) -> usize {
78        self.lag
79    }
80}
81
82impl Indicator for Autocorrelation {
83    type Input = f64;
84    type Output = f64;
85
86    fn update(&mut self, value: f64) -> Option<f64> {
87        if !value.is_finite() {
88            return None;
89        }
90        if self.window.len() == self.period {
91            self.window.pop_front();
92        }
93        self.window.push_back(value);
94        if self.window.len() < self.period {
95            return None;
96        }
97        // ACF over the current window with a single inner pass. The window is
98        // small relative to a typical input stream so the O(period) per-bar
99        // cost is bounded by the user-chosen `period`; the constant factor
100        // is dominated by two adds and one multiply per element.
101        let n = self.period as f64;
102        let mean = self.window.iter().sum::<f64>() / n;
103        let mut denom = 0.0;
104        let mut numer = 0.0;
105        // The window is a deque; index via slices for cache-friendly access.
106        let (front, back) = self.window.as_slices();
107        let get = |i: usize| -> f64 {
108            if i < front.len() {
109                front[i]
110            } else {
111                back[i - front.len()]
112            }
113        };
114        for i in 0..self.period {
115            let d = get(i) - mean;
116            denom += d * d;
117        }
118        let lag = self.lag;
119        for i in 0..(self.period - lag) {
120            numer += (get(i) - mean) * (get(i + lag) - mean);
121        }
122        if denom == 0.0 {
123            return Some(0.0);
124        }
125        Some(numer / denom)
126    }
127
128    fn reset(&mut self) {
129        self.window.clear();
130    }
131
132    #[inline]
133    fn warmup_period(&self) -> usize {
134        self.period
135    }
136
137    #[inline]
138    fn is_ready(&self) -> bool {
139        self.window.len() == self.period
140    }
141
142    #[inline]
143    fn name(&self) -> &'static str {
144        "Autocorrelation"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn rejects_zero_lag() {
156        assert!(Autocorrelation::new(10, 0).is_err());
157    }
158
159    #[test]
160    fn rejects_lag_geq_period() {
161        assert!(Autocorrelation::new(5, 5).is_err());
162        assert!(Autocorrelation::new(5, 10).is_err());
163    }
164
165    #[test]
166    fn accessors_and_metadata() {
167        let a = Autocorrelation::new(14, 2).unwrap();
168        assert_eq!(a.period(), 14);
169        assert_eq!(a.lag(), 2);
170        assert_eq!(a.warmup_period(), 14);
171        assert_eq!(a.name(), "Autocorrelation");
172    }
173
174    #[test]
175    fn constant_series_yields_zero() {
176        let mut a = Autocorrelation::new(10, 1).unwrap();
177        for v in a.batch(&[42.0; 30]).into_iter().flatten() {
178            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
179        }
180    }
181
182    #[test]
183    fn alternating_series_lag_one_is_strongly_negative() {
184        // [−1, 1, −1, 1, …] alternates each step.
185        let prices: Vec<f64> = (0..20)
186            .map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
187            .collect();
188        let mut a = Autocorrelation::new(10, 1).unwrap();
189        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
190        assert!(
191            last < -0.5,
192            "alternating series should be strongly negative, got {last}"
193        );
194    }
195
196    #[test]
197    fn repeating_series_is_strongly_positive_at_period() {
198        // A series that repeats every 4 steps must have ACF(4) ≈ +1.
199        let pattern = [1.0, 2.0, 3.0, 4.0];
200        let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
201        let mut a = Autocorrelation::new(16, 4).unwrap();
202        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
203        assert!(
204            last > 0.5,
205            "period-4 repeat should ACF(4) > 0.5, got {last}"
206        );
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut a = Autocorrelation::new(5, 1).unwrap();
212        a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
213        assert!(a.is_ready());
214        a.reset();
215        assert!(!a.is_ready());
216        assert_eq!(a.update(1.0), None);
217    }
218
219    #[test]
220    fn batch_equals_streaming() {
221        let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
222        let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
223        let mut b = Autocorrelation::new(14, 2).unwrap();
224        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
225        assert_eq!(batch, streamed);
226    }
227}