Skip to main content

wickra_core/indicators/
variance_ratio.rs

1//! Lo–MacKinlay variance-ratio test on the spread of two series.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Lo–MacKinlay variance ratio of the spread `a − b` at horizon `q`.
9///
10/// Each `update` takes one `(a, b)` price pair and forms the spread
11/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
12/// compares the variance of `q`-step changes against `q` times the variance of
13/// one-step changes:
14///
15/// ```text
16/// rₜ   = sₜ − sₜ₋₁                                  (one-step changes)
17/// VR(q) = Var(Σ of q consecutive r) / (q · Var(r))
18/// ```
19///
20/// Under a random walk the variance of returns grows linearly with the horizon,
21/// so `VR(q) = 1`. Departures reveal autocorrelation structure:
22///
23/// * `VR(q) < 1` — **mean reversion** (negatively autocorrelated changes): the
24///   spread's moves partly cancel, the regime pairs traders exploit.
25/// * `VR(q) ≈ 1` — a **random walk**: no exploitable structure.
26/// * `VR(q) > 1` — **momentum / trending** (positively autocorrelated changes).
27///
28/// The estimator uses overlapping `q`-step windows. When the one-step changes
29/// have zero variance (a flat spread) the ratio is undefined and the indicator
30/// returns the null value `1`. The output is always `≥ 0`.
31///
32/// Each `update` is `O(period)`, bounded by the fixed window.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, VarianceRatio};
38///
39/// let mut vr = VarianceRatio::new(60, 2).unwrap();
40/// let mut last = None;
41/// for t in 0..200 {
42///     let b = 100.0 + f64::from(t);
43///     // A fast, choppy spread mean-reverts (negatively autocorrelated
44///     // changes) ⇒ VR(2) < 1.
45///     let a = b + 2.0 * (f64::from(t) * 2.5).sin();
46///     last = vr.update((a, b));
47/// }
48/// assert!(last.unwrap() < 1.0);
49/// ```
50#[derive(Debug, Clone)]
51pub struct VarianceRatio {
52    period: usize,
53    q: usize,
54    window: VecDeque<f64>,
55}
56
57impl VarianceRatio {
58    /// Construct a new variance-ratio test.
59    ///
60    /// `period` is the look-back window of spreads; `q` is the aggregation
61    /// horizon (number of one-step changes summed per long-horizon change).
62    ///
63    /// # Errors
64    /// Returns [`Error::InvalidPeriod`] if `q < 2` or if `period < q + 2`
65    /// (which would leave fewer than two long-horizon observations).
66    pub fn new(period: usize, q: usize) -> Result<Self> {
67        if q < 2 {
68            return Err(Error::InvalidPeriod {
69                message: "variance ratio needs q >= 2",
70            });
71        }
72        if period < q + 2 {
73            return Err(Error::InvalidPeriod {
74                message: "variance ratio needs period >= q + 2",
75            });
76        }
77        Ok(Self {
78            period,
79            q,
80            window: VecDeque::with_capacity(period),
81        })
82    }
83
84    /// Configured look-back window of spreads.
85    pub const fn period(&self) -> usize {
86        self.period
87    }
88
89    /// Configured aggregation horizon `q`.
90    pub const fn q(&self) -> usize {
91        self.q
92    }
93}
94
95impl Indicator for VarianceRatio {
96    type Input = (f64, f64);
97    type Output = f64;
98
99    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
100        let (a, b) = input;
101        if self.window.len() == self.period {
102            self.window.pop_front();
103        }
104        self.window.push_back(a - b);
105        if self.window.len() < self.period {
106            return None;
107        }
108        let spreads: Vec<f64> = self.window.iter().copied().collect();
109        // One-step changes.
110        let returns: Vec<f64> = spreads.windows(2).map(|w| w[1] - w[0]).collect();
111        let m = returns.len() as f64;
112        let mean = returns.iter().sum::<f64>() / m;
113        let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / m;
114        if var_one <= 0.0 {
115            // Flat spread: the random-walk null value.
116            return Some(1.0);
117        }
118        // Overlapping q-step changes; their mean is q·mean by construction.
119        let q_mean = self.q as f64 * mean;
120        let long: Vec<f64> = returns.windows(self.q).map(|w| w.iter().sum()).collect();
121        let count = long.len() as f64;
122        let var_q = long
123            .iter()
124            .map(|y| (y - q_mean) * (y - q_mean))
125            .sum::<f64>()
126            / count;
127        Some(var_q / (self.q as f64 * var_one))
128    }
129
130    fn reset(&mut self) {
131        self.window.clear();
132    }
133
134    fn warmup_period(&self) -> usize {
135        self.period
136    }
137
138    fn is_ready(&self) -> bool {
139        self.window.len() == self.period
140    }
141
142    fn name(&self) -> &'static str {
143        "VarianceRatio"
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::traits::BatchExt;
151    use approx::assert_relative_eq;
152
153    #[test]
154    fn rejects_bad_parameters() {
155        assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2
156        assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2
157        assert!(VarianceRatio::new(4, 2).is_ok());
158    }
159
160    #[test]
161    fn accessors_and_metadata() {
162        let vr = VarianceRatio::new(60, 4).unwrap();
163        assert_eq!(vr.period(), 60);
164        assert_eq!(vr.q(), 4);
165        assert_eq!(vr.warmup_period(), 60);
166        assert_eq!(vr.name(), "VarianceRatio");
167        assert!(!vr.is_ready());
168    }
169
170    #[test]
171    fn warmup_returns_none() {
172        let mut vr = VarianceRatio::new(4, 2).unwrap();
173        assert_eq!(vr.update((1.0, 0.0)), None);
174        assert_eq!(vr.update((2.0, 0.0)), None);
175        assert_eq!(vr.update((3.0, 0.0)), None);
176        assert!(vr.update((4.0, 0.0)).is_some());
177        assert!(vr.is_ready());
178    }
179
180    #[test]
181    fn alternating_changes_give_zero_ratio() {
182        // Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all
183        // 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion).
184        let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
185        let last = VarianceRatio::new(5, 2)
186            .unwrap()
187            .batch(&pairs)
188            .into_iter()
189            .flatten()
190            .last()
191            .unwrap();
192        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
193    }
194
195    #[test]
196    fn oscillating_spread_is_below_one() {
197        let pairs: Vec<(f64, f64)> = (0..200)
198            .map(|t| {
199                let b = 100.0 + f64::from(t);
200                (b + 2.0 * (f64::from(t) * 2.5).sin(), b)
201            })
202            .collect();
203        let last = VarianceRatio::new(60, 2)
204            .unwrap()
205            .batch(&pairs)
206            .into_iter()
207            .flatten()
208            .last()
209            .unwrap();
210        assert!(last < 1.0, "VR {last}");
211    }
212
213    #[test]
214    fn flat_spread_returns_one() {
215        let pairs: Vec<(f64, f64)> = (0..30)
216            .map(|t| (5.0 + f64::from(t), f64::from(t)))
217            .collect();
218        let last = VarianceRatio::new(10, 3)
219            .unwrap()
220            .batch(&pairs)
221            .into_iter()
222            .flatten()
223            .last()
224            .unwrap();
225        assert_eq!(last, 1.0);
226    }
227
228    #[test]
229    fn output_non_negative() {
230        let pairs: Vec<(f64, f64)> = (0..150)
231            .map(|t| {
232                let b = 50.0 + 0.3 * f64::from(t);
233                (b + (f64::from(t) * 0.5).sin() * 2.0, b)
234            })
235            .collect();
236        let mut vr = VarianceRatio::new(40, 4).unwrap();
237        for v in vr.batch(&pairs).into_iter().flatten() {
238            assert!(v >= 0.0, "VR {v}");
239        }
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let mut vr = VarianceRatio::new(6, 2).unwrap();
245        for t in 0..12 {
246            vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
247        }
248        assert!(vr.is_ready());
249        vr.reset();
250        assert!(!vr.is_ready());
251        assert_eq!(vr.update((1.0, 0.0)), None);
252    }
253
254    #[test]
255    fn batch_equals_streaming() {
256        let pairs: Vec<(f64, f64)> = (0..100)
257            .map(|t| {
258                let b = 30.0 + 0.7 * f64::from(t);
259                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
260            })
261            .collect();
262        let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs);
263        let mut vr = VarianceRatio::new(32, 3).unwrap();
264        let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
265        assert_eq!(batch, streamed);
266    }
267}