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