quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! 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;