quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use super::*;
use crate::IndicatorError;

fn make_candle(close: Decimal, idx: i64) -> Candle {
    use quant_primitives::Candle;
    Candle::new(
        close,
        close + dec!(1),
        close - dec!(1),
        close,
        dec!(1000),
        chrono::DateTime::from_timestamp(1_700_000_000 + idx * 86400, 0).expect("valid timestamp"),
    )
    .expect("valid candle")
}

#[test]
fn new_rejects_lag_zero() {
    let err = VarianceRatio::new(0).unwrap_err();
    assert!(matches!(err, IndicatorError::InvalidParameter { .. }));
}

#[test]
fn new_rejects_lag_one() {
    let err = VarianceRatio::new(1).unwrap_err();
    assert!(matches!(err, IndicatorError::InvalidParameter { .. }));
}

#[test]
fn new_accepts_lag_two() {
    assert!(VarianceRatio::new(2).is_ok());
}

#[test]
fn compute_ratio_insufficient_data() {
    let vr = VarianceRatio::new(20).expect("valid VR lag");
    let candles: Vec<Candle> = (0..10).map(|i| make_candle(dec!(100), i)).collect();
    let err = vr.compute_ratio(&candles).unwrap_err();
    assert!(matches!(
        err,
        IndicatorError::InsufficientData {
            required: 22,
            actual: 10
        }
    ));
}

#[test]
fn compute_ratio_constant_prices_returns_error() {
    // Constant prices → zero variance → should error, not divide by zero
    let vr = VarianceRatio::new(2).expect("valid VR lag");
    let candles: Vec<Candle> = (0..50).map(|i| make_candle(dec!(100), i)).collect();
    let err = vr.compute_ratio(&candles).unwrap_err();
    assert!(matches!(err, IndicatorError::InsufficientData { .. }));
}

#[test]
fn trending_series_produces_vr_above_one() {
    // Monotonically increasing prices: VR should be > 1
    let vr = VarianceRatio::new(5).expect("valid VR lag");
    let candles: Vec<Candle> = (0..50)
        .map(|i| make_candle(dec!(100) + Decimal::from(i) * dec!(2), i))
        .collect();
    let ratio = vr
        .compute_ratio(&candles)
        .expect("sufficient data for VR ratio");
    assert!(
        ratio > Decimal::ONE,
        "Trending VR should be > 1, got {}",
        ratio
    );
}

#[test]
fn mean_reverting_series_produces_vr_below_one() {
    // Oscillating prices: VR should be < 1
    let vr = VarianceRatio::new(2).expect("valid VR lag");
    let candles: Vec<Candle> = (0..50)
        .map(|i| {
            let price = if i % 2 == 0 { dec!(103) } else { dec!(97) };
            make_candle(price, i)
        })
        .collect();
    let ratio = vr
        .compute_ratio(&candles)
        .expect("sufficient data for VR ratio");
    assert!(
        ratio < Decimal::ONE,
        "Mean-reverting VR should be < 1, got {}",
        ratio
    );
}

#[test]
fn vr_is_always_non_negative() {
    // VR = varq / var1, both variances are non-negative, so VR >= 0
    let vr = VarianceRatio::new(3).expect("valid VR lag");
    let candles: Vec<Candle> = (0..30)
        .map(|i| {
            let price = if i % 2 == 0 { dec!(105) } else { dec!(95) };
            make_candle(price, i)
        })
        .collect();
    let ratio = vr
        .compute_ratio(&candles)
        .expect("sufficient data for VR ratio");
    assert!(
        !ratio.is_sign_negative(),
        "VR must be non-negative, got {}",
        ratio
    );
}

#[test]
fn rolling_produces_correct_count() {
    let vr = VarianceRatio::new(3).expect("valid VR lag");
    // 30 candles, window=10 → rolling windows from [0..10], [1..11], ..., [20..30] = 21 windows
    // Some may be skipped if zero variance, but with trending data none should be skipped
    let candles: Vec<Candle> = (0..30)
        .map(|i| make_candle(dec!(100) + Decimal::from(i) * dec!(1), i))
        .collect();
    let series = vr
        .rolling(&candles, 10)
        .expect("sufficient data for VR rolling");
    assert_eq!(
        series.len(),
        21,
        "Expected 21 rolling windows, got {}",
        series.len()
    );
}

#[test]
fn rolling_insufficient_candles() {
    let vr = VarianceRatio::new(5).expect("valid VR lag");
    let candles: Vec<Candle> = (0..8)
        .map(|i| make_candle(dec!(100) + Decimal::from(i), i))
        .collect();
    let err = vr.rolling(&candles, 20).unwrap_err();
    assert!(matches!(err, IndicatorError::InsufficientData { .. }));
}

#[test]
fn rolling_indices_are_monotonically_increasing() {
    let vr = VarianceRatio::new(3).expect("valid VR lag");
    let candles: Vec<Candle> = (0..40)
        .map(|i| make_candle(dec!(100) + Decimal::from(i) * dec!(2), i))
        .collect();
    let series = vr
        .rolling(&candles, 10)
        .expect("sufficient data for VR rolling");
    for window in series.windows(2) {
        assert!(
            window[1].0 > window[0].0,
            "Indices must be monotonically increasing"
        );
    }
}

#[test]
fn variance_of_empty_is_zero() {
    assert_eq!(variance(&[]), Decimal::ZERO);
}

#[test]
fn variance_of_constant_is_zero() {
    let data = vec![dec!(5), dec!(5), dec!(5), dec!(5)];
    assert_eq!(variance(&data), Decimal::ZERO);
}

#[test]
fn variance_known_values() {
    // [1, 2, 3, 4, 5] → mean=3, var = (4+1+0+1+4)/5 = 2.0
    let data: Vec<Decimal> = (1..=5).map(Decimal::from).collect();
    assert_eq!(variance(&data), dec!(2));
}

#[test]
fn decimal_ln_of_one_is_zero() {
    assert_eq!(decimal_ln(Decimal::ONE), Decimal::ZERO);
}

#[test]
fn decimal_ln_of_zero_is_zero() {
    assert_eq!(decimal_ln(Decimal::ZERO), Decimal::ZERO);
}

#[test]
fn decimal_ln_of_negative_is_zero() {
    assert_eq!(decimal_ln(dec!(-5)), Decimal::ZERO);
}

#[test]
fn decimal_ln_positive_value_reasonable() {
    // ln(100) ≈ 4.6052
    let result = decimal_ln(dec!(100));
    let diff = (result - dec!(4.6052)).abs();
    assert!(
        diff < dec!(0.01),
        "ln(100) should be ~4.6052, got {}",
        result
    );
}