Skip to main content

quant_indicators/
variance_ratio.rs

1//! Lo-MacKinlay Variance Ratio regime classifier.
2//!
3//! Computes the variance ratio VR(q) to classify instruments as
4//! trending (VR > 1.1), mean-reverting (VR < 0.9), or neutral.
5//!
6//! # Algorithm
7//!
8//! 1. Compute 1-period log returns: r_t = ln(P_t) - ln(P_{t-1})
9//! 2. Compute variance of 1-period returns: var1
10//! 3. Compute q-period differences: d_t = ln(P_t) - ln(P_{t-q})
11//! 4. Compute variance of q-period diffs / q: varq = var(d) / q
12//! 5. Return varq / var1
13//!
14//! # References
15//!
16//! Lo & MacKinlay (1988) "Stock Market Prices Do Not Follow Random Walks"
17
18use quant_primitives::Candle;
19use rust_decimal::Decimal;
20
21use crate::error::IndicatorError;
22use crate::indicator::Indicator;
23use crate::series::Series;
24
25/// Variance Ratio regime classifier.
26///
27/// Computes the Lo-MacKinlay Variance Ratio to determine whether an
28/// instrument is trending, mean-reverting, or following a random walk.
29#[derive(Debug, Clone)]
30pub struct VarianceRatio {
31    lag: usize,
32    name: String,
33}
34
35impl VarianceRatio {
36    /// Create a new `VarianceRatio` with the specified lag (q parameter).
37    ///
38    /// # Errors
39    ///
40    /// Returns `InvalidParameter` if lag is less than 2.
41    pub fn new(lag: usize) -> Result<Self, IndicatorError> {
42        if lag < 2 {
43            return Err(IndicatorError::InvalidParameter {
44                message: format!("VarianceRatio lag must be >= 2, got {}", lag),
45            });
46        }
47        Ok(Self {
48            lag,
49            name: format!("VR({})", lag),
50        })
51    }
52
53    /// Compute the variance ratio from candle closing prices.
54    ///
55    /// Returns the VR value:
56    /// - VR > 1.1 → trending
57    /// - VR < 0.9 → mean-reverting
58    /// - 0.9 ≤ VR ≤ 1.1 → neutral / random walk
59    pub fn compute_ratio(&self, candles: &[Candle]) -> Result<Decimal, IndicatorError> {
60        let min_required = self.lag + 2;
61        if candles.len() < min_required {
62            return Err(IndicatorError::InsufficientData {
63                required: min_required,
64                actual: candles.len(),
65            });
66        }
67
68        let log_prices = Self::log_prices(candles);
69        Self::vr_from_log_prices(&log_prices, self.lag)
70    }
71
72    /// Compute rolling variance ratio over a sliding window.
73    ///
74    /// Returns `(index, vr)` pairs where index is the end position of each window.
75    pub fn rolling(
76        &self,
77        candles: &[Candle],
78        window: usize,
79    ) -> Result<Vec<(usize, Decimal)>, IndicatorError> {
80        let min_required = window;
81        if candles.len() < min_required {
82            return Err(IndicatorError::InsufficientData {
83                required: min_required,
84                actual: candles.len(),
85            });
86        }
87
88        let log_prices = Self::log_prices(candles);
89        let mut results = Vec::new();
90
91        for end in window..=log_prices.len() {
92            let slice = &log_prices[end - window..end];
93            match Self::vr_from_log_prices(slice, self.lag) {
94                Ok(vr) => results.push((end - 1, vr)),
95                Err(_) => continue,
96            }
97        }
98
99        if results.is_empty() {
100            return Err(IndicatorError::InsufficientData {
101                required: window,
102                actual: candles.len(),
103            });
104        }
105
106        Ok(results)
107    }
108
109    /// Convert candle close prices to natural log approximation using Decimal.
110    fn log_prices(candles: &[Candle]) -> Vec<Decimal> {
111        candles.iter().map(|c| decimal_ln(c.close())).collect()
112    }
113
114    /// Core VR computation from a log-price series.
115    fn vr_from_log_prices(log_prices: &[Decimal], lag: usize) -> Result<Decimal, IndicatorError> {
116        if log_prices.len() < lag + 2 {
117            return Err(IndicatorError::InsufficientData {
118                required: lag + 2,
119                actual: log_prices.len(),
120            });
121        }
122
123        // 1-period log returns
124        let returns_1: Vec<Decimal> = log_prices.windows(2).map(|w| w[1] - w[0]).collect();
125
126        // Variance of 1-period returns
127        let var1 = variance(&returns_1);
128
129        if var1.is_zero() {
130            return Err(IndicatorError::InsufficientData {
131                required: lag + 2,
132                actual: log_prices.len(),
133            });
134        }
135
136        // q-period differences
137        let diffs_q: Vec<Decimal> = log_prices.windows(lag + 1).map(|w| w[lag] - w[0]).collect();
138
139        // Variance of q-period diffs, divided by q
140        let var_q = variance(&diffs_q);
141        let q_dec = Decimal::from(lag as i64);
142        let var_q_scaled = var_q / q_dec;
143
144        Ok(var_q_scaled / var1)
145    }
146}
147
148impl Indicator for VarianceRatio {
149    fn name(&self) -> &str {
150        &self.name
151    }
152
153    fn warmup_period(&self) -> usize {
154        self.lag + 2
155    }
156
157    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
158        let vr = self.compute_ratio(candles)?;
159        let last = candles.last().ok_or(IndicatorError::InsufficientData {
160            required: 1,
161            actual: 0,
162        })?;
163        Ok(Series::new(vec![(last.timestamp(), vr)]))
164    }
165}
166
167/// Compute population variance of a Decimal slice.
168fn variance(data: &[Decimal]) -> Decimal {
169    if data.is_empty() {
170        return Decimal::ZERO;
171    }
172
173    let n = Decimal::from(data.len() as i64);
174    let mean = data.iter().copied().sum::<Decimal>() / n;
175    let sum_sq: Decimal = data.iter().map(|x| (*x - mean) * (*x - mean)).sum();
176    sum_sq / n
177}
178
179/// Natural logarithm approximation for Decimal using the series expansion.
180///
181/// Uses ln(x) = ln(m * 10^e) = ln(m) + e*ln(10), where ln(m) is computed
182/// via the series expansion of ln((1+y)/(1-y)) = 2*(y + y^3/3 + y^5/5 + ...)
183/// with y = (m-1)/(m+1).
184fn decimal_ln(x: Decimal) -> Decimal {
185    if x <= Decimal::ZERO {
186        return Decimal::ZERO;
187    }
188
189    if x == Decimal::ONE {
190        return Decimal::ZERO;
191    }
192
193    // Use f64 for ln computation, then convert back
194    // This is acceptable because VR is a statistical measure where
195    // f64 precision (15 significant digits) is more than adequate
196    use rust_decimal::prelude::ToPrimitive;
197    let x_f64 = x.to_f64().unwrap_or(1.0);
198    let ln_f64 = x_f64.ln();
199    Decimal::from_f64_retain(ln_f64).unwrap_or(Decimal::ZERO)
200}
201
202#[cfg(test)]
203#[path = "variance_ratio_tests.rs"]
204mod tests;