quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
use super::*;
use chrono::{TimeZone, Utc};
use rust_decimal_macros::dec;

fn make_vol_candle(volume: Decimal, idx: i64) -> Candle {
    let ts = Utc
        .with_ymd_and_hms(2024, 1, 1, idx as u32 % 24, 0, 0)
        .single()
        .expect("valid datetime");
    Candle::new(dec!(100), dec!(101), dec!(99), dec!(100), volume, ts).expect("valid candle")
}

fn default_config(lookback: usize) -> VolSignalConfig {
    VolSignalConfig {
        lookback,
        elevated_threshold: dec!(2),
    }
}

#[test]
fn returns_none_before_lookback() {
    let candles: Vec<Candle> = (0..5).map(|i| make_vol_candle(dec!(1000), i)).collect();
    let indicator =
        VolumeSignalIndicator::new(default_config(20)).expect("valid volume signal config");
    let results = indicator
        .compute(&candles)
        .expect("sufficient data for volume signal");
    assert_eq!(results.len(), 5);
    assert!(results.iter().all(|r| r.is_none()));
}

#[test]
fn elevated_volume_classified_correctly() {
    // 21 candles: first 20 normal, last one 3x
    let mut candles: Vec<Candle> = (0..20).map(|i| make_vol_candle(dec!(1000), i)).collect();
    candles.push(make_vol_candle(dec!(3000), 20));

    let indicator =
        VolumeSignalIndicator::new(default_config(20)).expect("valid volume signal config");
    let results = indicator
        .compute(&candles)
        .expect("sufficient data for volume signal");

    // First 20 → None
    assert!(results[..20].iter().all(|r| r.is_none()));
    // Last one → Elevated
    let sig = results[20].as_ref().expect("signal present at index");
    assert_eq!(sig.anomaly, VolumeAnomaly::Elevated);
}

#[test]
fn subdued_volume_classified_correctly() {
    // 21 candles: first 20 normal, last one 0.4x
    let mut candles: Vec<Candle> = (0..20).map(|i| make_vol_candle(dec!(1000), i)).collect();
    candles.push(make_vol_candle(dec!(400), 20));

    let indicator =
        VolumeSignalIndicator::new(default_config(20)).expect("valid volume signal config");
    let results = indicator
        .compute(&candles)
        .expect("sufficient data for volume signal");

    let sig = results[20].as_ref().expect("signal present at index");
    assert_eq!(sig.anomaly, VolumeAnomaly::Subdued);
}

#[test]
fn normal_volume_classified_correctly() {
    // 21 candles: all normal
    let candles: Vec<Candle> = (0..21).map(|i| make_vol_candle(dec!(1000), i)).collect();

    let indicator =
        VolumeSignalIndicator::new(default_config(20)).expect("valid volume signal config");
    let results = indicator
        .compute(&candles)
        .expect("sufficient data for volume signal");

    let sig = results[20].as_ref().expect("signal present at index");
    assert_eq!(sig.anomaly, VolumeAnomaly::Normal);
    // ratio should be exactly 1.0
    assert_eq!(sig.ratio, dec!(1));
}

#[test]
fn invalid_lookback_zero_returns_error() {
    let config = VolSignalConfig {
        lookback: 0,
        elevated_threshold: dec!(2),
    };
    let result = VolumeSignalIndicator::new(config);
    assert!(matches!(
        result,
        Err(IndicatorError::InvalidParameter { .. })
    ));
}

#[test]
fn result_count_equals_candle_count() {
    let candles: Vec<Candle> = (0..30).map(|i| make_vol_candle(dec!(1000), i)).collect();
    let indicator =
        VolumeSignalIndicator::new(default_config(10)).expect("valid volume signal config");
    let results = indicator
        .compute(&candles)
        .expect("sufficient data for volume signal");
    assert_eq!(results.len(), 30);
}