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    fn warmup_period(&self) -> usize {
133        self.period
134    }
135
136    fn is_ready(&self) -> bool {
137        self.window.len() == self.period
138    }
139
140    fn name(&self) -> &'static str {
141        "Autocorrelation"
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::traits::BatchExt;
149    use approx::assert_relative_eq;
150
151    #[test]
152    fn rejects_zero_lag() {
153        assert!(Autocorrelation::new(10, 0).is_err());
154    }
155
156    #[test]
157    fn rejects_lag_geq_period() {
158        assert!(Autocorrelation::new(5, 5).is_err());
159        assert!(Autocorrelation::new(5, 10).is_err());
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let a = Autocorrelation::new(14, 2).unwrap();
165        assert_eq!(a.period(), 14);
166        assert_eq!(a.lag(), 2);
167        assert_eq!(a.warmup_period(), 14);
168        assert_eq!(a.name(), "Autocorrelation");
169    }
170
171    #[test]
172    fn constant_series_yields_zero() {
173        let mut a = Autocorrelation::new(10, 1).unwrap();
174        for v in a.batch(&[42.0; 30]).into_iter().flatten() {
175            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
176        }
177    }
178
179    #[test]
180    fn alternating_series_lag_one_is_strongly_negative() {
181        // [−1, 1, −1, 1, …] alternates each step.
182        let prices: Vec<f64> = (0..20)
183            .map(|i| if i % 2 == 0 { -1.0 } else { 1.0 })
184            .collect();
185        let mut a = Autocorrelation::new(10, 1).unwrap();
186        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
187        assert!(
188            last < -0.5,
189            "alternating series should be strongly negative, got {last}"
190        );
191    }
192
193    #[test]
194    fn repeating_series_is_strongly_positive_at_period() {
195        // A series that repeats every 4 steps must have ACF(4) ≈ +1.
196        let pattern = [1.0, 2.0, 3.0, 4.0];
197        let prices: Vec<f64> = (0..32).map(|i| pattern[i % 4]).collect();
198        let mut a = Autocorrelation::new(16, 4).unwrap();
199        let last = a.batch(&prices).into_iter().flatten().last().unwrap();
200        assert!(
201            last > 0.5,
202            "period-4 repeat should ACF(4) > 0.5, got {last}"
203        );
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut a = Autocorrelation::new(5, 1).unwrap();
209        a.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
210        assert!(a.is_ready());
211        a.reset();
212        assert!(!a.is_ready());
213        assert_eq!(a.update(1.0), None);
214    }
215
216    #[test]
217    fn batch_equals_streaming() {
218        let prices: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin()).collect();
219        let batch = Autocorrelation::new(14, 2).unwrap().batch(&prices);
220        let mut b = Autocorrelation::new(14, 2).unwrap();
221        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
222        assert_eq!(batch, streamed);
223    }
224}