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 self.window.len() == self.period {
88            self.window.pop_front();
89        }
90        self.window.push_back(value);
91        if self.window.len() < self.period {
92            return None;
93        }
94        // ACF over the current window with a single inner pass. The window is
95        // small relative to a typical input stream so the O(period) per-bar
96        // cost is bounded by the user-chosen `period`; the constant factor
97        // is dominated by two adds and one multiply per element.
98        let n = self.period as f64;
99        let mean = self.window.iter().sum::<f64>() / n;
100        let mut denom = 0.0;
101        let mut numer = 0.0;
102        // The window is a deque; index via slices for cache-friendly access.
103        let (front, back) = self.window.as_slices();
104        let get = |i: usize| -> f64 {
105            if i < front.len() {
106                front[i]
107            } else {
108                back[i - front.len()]
109            }
110        };
111        for i in 0..self.period {
112            let d = get(i) - mean;
113            denom += d * d;
114        }
115        let lag = self.lag;
116        for i in 0..(self.period - lag) {
117            numer += (get(i) - mean) * (get(i + lag) - mean);
118        }
119        if denom == 0.0 {
120            return Some(0.0);
121        }
122        Some(numer / denom)
123    }
124
125    fn reset(&mut self) {
126        self.window.clear();
127    }
128
129    fn warmup_period(&self) -> usize {
130        self.period
131    }
132
133    fn is_ready(&self) -> bool {
134        self.window.len() == self.period
135    }
136
137    fn name(&self) -> &'static str {
138        "Autocorrelation"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_zero_lag() {
150        assert!(Autocorrelation::new(10, 0).is_err());
151    }
152
153    #[test]
154    fn rejects_lag_geq_period() {
155        assert!(Autocorrelation::new(5, 5).is_err());
156        assert!(Autocorrelation::new(5, 10).is_err());
157    }
158
159    #[test]
160    fn accessors_and_metadata() {
161        let a = Autocorrelation::new(14, 2).unwrap();
162        assert_eq!(a.period(), 14);
163        assert_eq!(a.lag(), 2);
164        assert_eq!(a.warmup_period(), 14);
165        assert_eq!(a.name(), "Autocorrelation");
166    }
167
168    #[test]
169    fn constant_series_yields_zero() {
170        let mut a = Autocorrelation::new(10, 1).unwrap();
171        for v in a.batch(&[42.0; 30]).into_iter().flatten() {
172            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
173        }
174    }
175
176    #[test]
177    fn alternating_series_lag_one_is_strongly_negative() {
178        // [−1, 1, −1, 1, …] alternates each step.
179        let prices: Vec<f64> = (0..20)
180            .map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
181            .collect();
182        let mut a = Autocorrelation::new(10, 1).unwrap();
183        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
184        assert!(
185            last < -0.5,
186            "alternating series should be strongly negative, got {last}"
187        );
188    }
189
190    #[test]
191    fn repeating_series_is_strongly_positive_at_period() {
192        // A series that repeats every 4 steps must have ACF(4) ≈ +1.
193        let pattern = [1.0, 2.0, 3.0, 4.0];
194        let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
195        let mut a = Autocorrelation::new(16, 4).unwrap();
196        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
197        assert!(
198            last > 0.5,
199            "period-4 repeat should ACF(4) > 0.5, got {last}"
200        );
201    }
202
203    #[test]
204    fn reset_clears_state() {
205        let mut a = Autocorrelation::new(5, 1).unwrap();
206        a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
207        assert!(a.is_ready());
208        a.reset();
209        assert!(!a.is_ready());
210        assert_eq!(a.update(1.0), None);
211    }
212
213    #[test]
214    fn batch_equals_streaming() {
215        let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
216        let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
217        let mut b = Autocorrelation::new(14, 2).unwrap();
218        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
219        assert_eq!(batch, streamed);
220    }
221}