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 !a.is_finite() || !b.is_finite() {
102            return None;
103        }
104        if self.window.len() == self.period {
105            self.window.pop_front();
106        }
107        self.window.push_back(a - b);
108        if self.window.len() < self.period {
109            return None;
110        }
111        let spreads: Vec<f64> = self.window.iter().copied().collect();
112        // One-step changes.
113        let returns: Vec<f64> = spreads.windows(2).map(|w| w[1] - w[0]).collect();
114        let m = returns.len() as f64;
115        let mean = returns.iter().sum::<f64>() / m;
116        let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / m;
117        if var_one <= 0.0 {
118            // Flat spread: the random-walk null value.
119            return Some(1.0);
120        }
121        // Overlapping q-step changes; their mean is q·mean by construction.
122        let q_mean = self.q as f64 * mean;
123        let long: Vec<f64> = returns.windows(self.q).map(|w| w.iter().sum()).collect();
124        let count = long.len() as f64;
125        let var_q = long
126            .iter()
127            .map(|y| (y - q_mean) * (y - q_mean))
128            .sum::<f64>()
129            / count;
130        Some(var_q / (self.q as f64 * var_one))
131    }
132
133    fn reset(&mut self) {
134        self.window.clear();
135    }
136
137    fn warmup_period(&self) -> usize {
138        self.period
139    }
140
141    fn is_ready(&self) -> bool {
142        self.window.len() == self.period
143    }
144
145    fn name(&self) -> &'static str {
146        "VarianceRatio"
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::traits::BatchExt;
154    use approx::assert_relative_eq;
155
156    #[test]
157    fn rejects_bad_parameters() {
158        assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2
159        assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2
160        assert!(VarianceRatio::new(4, 2).is_ok());
161    }
162
163    #[test]
164    fn accessors_and_metadata() {
165        let vr = VarianceRatio::new(60, 4).unwrap();
166        assert_eq!(vr.period(), 60);
167        assert_eq!(vr.q(), 4);
168        assert_eq!(vr.warmup_period(), 60);
169        assert_eq!(vr.name(), "VarianceRatio");
170        assert!(!vr.is_ready());
171    }
172
173    #[test]
174    fn warmup_returns_none() {
175        let mut vr = VarianceRatio::new(4, 2).unwrap();
176        assert_eq!(vr.update((1.0, 0.0)), None);
177        assert_eq!(vr.update((2.0, 0.0)), None);
178        assert_eq!(vr.update((3.0, 0.0)), None);
179        assert!(vr.update((4.0, 0.0)).is_some());
180        assert!(vr.is_ready());
181    }
182
183    #[test]
184    fn alternating_changes_give_zero_ratio() {
185        // Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all
186        // 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion).
187        let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
188        let last = VarianceRatio::new(5, 2)
189            .unwrap()
190            .batch(&pairs)
191            .into_iter()
192            .flatten()
193            .last()
194            .unwrap();
195        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn oscillating_spread_is_below_one() {
200        let pairs: Vec<(f64, f64)> = (0..200)
201            .map(|t| {
202                let b = 100.0 + f64::from(t);
203                (b + 2.0 * (f64::from(t) * 2.5).sin(), b)
204            })
205            .collect();
206        let last = VarianceRatio::new(60, 2)
207            .unwrap()
208            .batch(&pairs)
209            .into_iter()
210            .flatten()
211            .last()
212            .unwrap();
213        assert!(last < 1.0, "VR {last}");
214    }
215
216    #[test]
217    fn flat_spread_returns_one() {
218        let pairs: Vec<(f64, f64)> = (0..30)
219            .map(|t| (5.0 + f64::from(t), f64::from(t)))
220            .collect();
221        let last = VarianceRatio::new(10, 3)
222            .unwrap()
223            .batch(&pairs)
224            .into_iter()
225            .flatten()
226            .last()
227            .unwrap();
228        assert_eq!(last, 1.0);
229    }
230
231    #[test]
232    fn output_non_negative() {
233        let pairs: Vec<(f64, f64)> = (0..150)
234            .map(|t| {
235                let b = 50.0 + 0.3 * f64::from(t);
236                (b + (f64::from(t) * 0.5).sin() * 2.0, b)
237            })
238            .collect();
239        let mut vr = VarianceRatio::new(40, 4).unwrap();
240        for v in vr.batch(&pairs).into_iter().flatten() {
241            assert!(v >= 0.0, "VR {v}");
242        }
243    }
244
245    #[test]
246    fn reset_clears_state() {
247        let mut vr = VarianceRatio::new(6, 2).unwrap();
248        for t in 0..12 {
249            vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
250        }
251        assert!(vr.is_ready());
252        vr.reset();
253        assert!(!vr.is_ready());
254        assert_eq!(vr.update((1.0, 0.0)), None);
255    }
256
257    #[test]
258    fn batch_equals_streaming() {
259        let pairs: Vec<(f64, f64)> = (0..100)
260            .map(|t| {
261                let b = 30.0 + 0.7 * f64::from(t);
262                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
263            })
264            .collect();
265        let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs);
266        let mut vr = VarianceRatio::new(32, 3).unwrap();
267        let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
268        assert_eq!(batch, streamed);
269    }
270
271    #[test]
272    fn non_finite_input_returns_none() {
273        let mut vr = VarianceRatio::new(4, 2).unwrap();
274        assert_eq!(vr.update((f64::NAN, 1.0)), None);
275        assert_eq!(vr.update((1.0, f64::INFINITY)), None);
276        // The rejected ticks leave no trace: a fresh window still warms up.
277        assert_eq!(vr.update((1.0, 0.0)), None);
278        assert_eq!(vr.update((2.0, 0.0)), None);
279        assert_eq!(vr.update((3.0, 0.0)), None);
280        assert!(vr.update((4.0, 0.0)).is_some());
281    }
282}