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    /// Reusable scratch buffer to avoid allocating per `update`.
56    scratch: Vec<f64>,
57    /// Reusable buffer for the one-step changes.
58    returns: Vec<f64>,
59}
60
61impl VarianceRatio {
62    /// Construct a new variance-ratio test.
63    ///
64    /// `period` is the look-back window of spreads; `q` is the aggregation
65    /// horizon (number of one-step changes summed per long-horizon change).
66    ///
67    /// # Errors
68    /// Returns [`Error::InvalidPeriod`] if `q < 2` or if `period < q + 2`
69    /// (which would leave fewer than two long-horizon observations).
70    pub fn new(period: usize, q: usize) -> Result<Self> {
71        if q < 2 {
72            return Err(Error::InvalidPeriod {
73                message: "variance ratio needs q >= 2",
74            });
75        }
76        if q > crate::error::MAX_PERIOD {
77            return Err(Error::InvalidPeriod {
78                message: crate::error::PERIOD_ABOVE_MAX,
79            });
80        }
81        if period < q + 2 {
82            return Err(Error::InvalidPeriod {
83                message: "variance ratio needs period >= q + 2",
84            });
85        }
86        Ok(Self {
87            period,
88            q,
89            window: VecDeque::with_capacity(period),
90            scratch: Vec::with_capacity(period),
91            returns: Vec::with_capacity(period),
92        })
93    }
94
95    /// Configured look-back window of spreads.
96    pub const fn period(&self) -> usize {
97        self.period
98    }
99
100    /// Configured aggregation horizon `q`.
101    pub const fn q(&self) -> usize {
102        self.q
103    }
104}
105
106impl Indicator for VarianceRatio {
107    type Input = (f64, f64);
108    type Output = f64;
109
110    #[inline]
111    fn update(&mut self, input: (f64, f64)) -> Option<f64> {
112        let (a, b) = input;
113        if !a.is_finite() || !b.is_finite() {
114            return None;
115        }
116        if self.window.len() == self.period {
117            self.window.pop_front();
118        }
119        self.window.push_back(a - b);
120        if self.window.len() < self.period {
121            return None;
122        }
123        self.scratch.clear();
124        self.scratch.extend(self.window.iter().copied());
125        // One-step changes.
126        let returns = &mut self.returns;
127        returns.clear();
128        returns.extend(self.scratch.windows(2).map(|w| w[1] - w[0]));
129        let m = returns.len() as f64;
130        let mean = returns.iter().sum::<f64>() / m;
131        let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / m;
132        if var_one <= 0.0 {
133            // Flat spread: the random-walk null value.
134            return Some(1.0);
135        }
136        // Overlapping q-step changes; their mean is q·mean by construction.
137        let q_mean = self.q as f64 * mean;
138        // Summed in one pass rather than materialised: the q-step changes are
139        // only ever reduced, never revisited.
140        let mut sum_sq = 0.0;
141        let mut count = 0.0;
142        for window in returns.windows(self.q) {
143            let deviation = window.iter().sum::<f64>() - q_mean;
144            sum_sq += deviation * deviation;
145            count += 1.0;
146        }
147        let var_q = sum_sq / count;
148        Some(var_q / (self.q as f64 * var_one))
149    }
150
151    fn reset(&mut self) {
152        self.window.clear();
153        self.scratch.clear();
154        self.returns.clear();
155    }
156
157    #[inline]
158    fn warmup_period(&self) -> usize {
159        self.period
160    }
161
162    #[inline]
163    fn is_ready(&self) -> bool {
164        self.window.len() == self.period
165    }
166
167    #[inline]
168    fn name(&self) -> &'static str {
169        "VarianceRatio"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177    use approx::assert_relative_eq;
178
179    #[test]
180    fn rejects_bad_parameters() {
181        assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2
182        assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2
183        assert!(VarianceRatio::new(4, 2).is_ok());
184    }
185
186    #[test]
187    fn accessors_and_metadata() {
188        let vr = VarianceRatio::new(60, 4).unwrap();
189        assert_eq!(vr.period(), 60);
190        assert_eq!(vr.q(), 4);
191        assert_eq!(vr.warmup_period(), 60);
192        assert_eq!(vr.name(), "VarianceRatio");
193        assert!(!vr.is_ready());
194    }
195
196    #[test]
197    fn warmup_returns_none() {
198        let mut vr = VarianceRatio::new(4, 2).unwrap();
199        assert_eq!(vr.update((1.0, 0.0)), None);
200        assert_eq!(vr.update((2.0, 0.0)), None);
201        assert_eq!(vr.update((3.0, 0.0)), None);
202        assert!(vr.update((4.0, 0.0)).is_some());
203        assert!(vr.is_ready());
204    }
205
206    #[test]
207    fn alternating_changes_give_zero_ratio() {
208        // Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all
209        // 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion).
210        let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
211        let last = VarianceRatio::new(5, 2)
212            .unwrap()
213            .batch(&pairs)
214            .into_iter()
215            .flatten()
216            .last()
217            .unwrap();
218        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
219    }
220
221    #[test]
222    fn oscillating_spread_is_below_one() {
223        let pairs: Vec<(f64, f64)> = (0..200)
224            .map(|t| {
225                let b = 100.0 + f64::from(t);
226                (b + 2.0 * (f64::from(t) * 2.5).sin(), b)
227            })
228            .collect();
229        let last = VarianceRatio::new(60, 2)
230            .unwrap()
231            .batch(&pairs)
232            .into_iter()
233            .flatten()
234            .last()
235            .unwrap();
236        assert!(last < 1.0, "VR {last}");
237    }
238
239    #[test]
240    fn flat_spread_returns_one() {
241        let pairs: Vec<(f64, f64)> = (0..30)
242            .map(|t| (5.0 + f64::from(t), f64::from(t)))
243            .collect();
244        let last = VarianceRatio::new(10, 3)
245            .unwrap()
246            .batch(&pairs)
247            .into_iter()
248            .flatten()
249            .last()
250            .unwrap();
251        assert_eq!(last, 1.0);
252    }
253
254    #[test]
255    fn output_non_negative() {
256        let pairs: Vec<(f64, f64)> = (0..150)
257            .map(|t| {
258                let b = 50.0 + 0.3 * f64::from(t);
259                (b + (f64::from(t) * 0.5).sin() * 2.0, b)
260            })
261            .collect();
262        let mut vr = VarianceRatio::new(40, 4).unwrap();
263        for v in vr.batch(&pairs).into_iter().flatten() {
264            assert!(v >= 0.0, "VR {v}");
265        }
266    }
267
268    #[test]
269    fn reset_clears_state() {
270        let mut vr = VarianceRatio::new(6, 2).unwrap();
271        for t in 0..12 {
272            vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
273        }
274        assert!(vr.is_ready());
275        vr.reset();
276        assert!(!vr.is_ready());
277        assert_eq!(vr.update((1.0, 0.0)), None);
278    }
279
280    #[test]
281    fn batch_equals_streaming() {
282        let pairs: Vec<(f64, f64)> = (0..100)
283            .map(|t| {
284                let b = 30.0 + 0.7 * f64::from(t);
285                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
286            })
287            .collect();
288        let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs);
289        let mut vr = VarianceRatio::new(32, 3).unwrap();
290        let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
291        assert_eq!(batch, streamed);
292    }
293
294    #[test]
295    fn non_finite_input_returns_none() {
296        let mut vr = VarianceRatio::new(4, 2).unwrap();
297        assert_eq!(vr.update((f64::NAN, 1.0)), None);
298        assert_eq!(vr.update((1.0, f64::INFINITY)), None);
299        // The rejected ticks leave no trace: a fresh window still warms up.
300        assert_eq!(vr.update((1.0, 0.0)), None);
301        assert_eq!(vr.update((2.0, 0.0)), None);
302        assert_eq!(vr.update((3.0, 0.0)), None);
303        assert!(vr.update((4.0, 0.0)).is_some());
304    }
305}