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 {
    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_lookback_zero() {
    let err = EfficiencyRatio::new(0).unwrap_err();
    assert!(matches!(err, IndicatorError::InvalidParameter { .. }));
}

#[test]
fn new_accepts_lookback_one() {
    assert!(EfficiencyRatio::new(1).is_ok());
}

#[test]
fn name_includes_lookback() {
    let er = EfficiencyRatio::new(10).expect("valid ER lookback");
    assert_eq!(er.name(), "ER(10)");
}

#[test]
fn warmup_period_is_lookback_plus_one() {
    let er = EfficiencyRatio::new(10).expect("valid ER lookback");
    assert_eq!(er.warmup_period(), 11);
}

#[test]
fn compute_ratio_insufficient_data() {
    let er = EfficiencyRatio::new(10).expect("valid ER lookback");
    let candles: Vec<Candle> = (0..5).map(|i| make_candle(dec!(100), i)).collect();
    let err = er.compute_ratio(&candles).unwrap_err();
    assert!(matches!(
        err,
        IndicatorError::InsufficientData {
            required: 11,
            actual: 5
        }
    ));
}

#[test]
fn perfectly_trending_returns_one() {
    // Monotonically increasing: net = path, ER = 1.0
    let candles: Vec<Candle> = (0..6)
        .map(|i| make_candle(dec!(100) + Decimal::from(i) * dec!(2), i))
        .collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let ratio = er
        .compute_ratio(&candles)
        .expect("sufficient data for ER ratio");
    assert_eq!(ratio, Decimal::ONE);
}

#[test]
fn perfectly_trending_down_returns_one() {
    // Monotonically decreasing: net = path, ER = 1.0
    let candles: Vec<Candle> = (0..6)
        .map(|i| make_candle(dec!(110) - Decimal::from(i) * dec!(2), i))
        .collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let ratio = er
        .compute_ratio(&candles)
        .expect("sufficient data for ER ratio");
    assert_eq!(ratio, Decimal::ONE);
}

#[test]
fn constant_price_returns_zero() {
    let candles: Vec<Candle> = (0..10).map(|i| make_candle(dec!(100), i)).collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let ratio = er
        .compute_ratio(&candles)
        .expect("sufficient data for ER ratio");
    assert_eq!(ratio, Decimal::ZERO);
}

#[test]
fn oscillating_returns_low_er() {
    // 100, 105, 100, 105, 100, 105 → net=5, path=25, ER=0.2
    let prices = [
        dec!(100),
        dec!(105),
        dec!(100),
        dec!(105),
        dec!(100),
        dec!(105),
    ];
    let candles: Vec<Candle> = prices
        .iter()
        .enumerate()
        .map(|(i, &p)| make_candle(p, i as i64))
        .collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let ratio = er
        .compute_ratio(&candles)
        .expect("sufficient data for ER ratio");
    assert_eq!(ratio, dec!(0.2));
}

#[test]
fn er_bounded_zero_to_one() {
    // Various price patterns — ER should always be in [0, 1]
    let patterns: Vec<Vec<Decimal>> = vec![
        vec![
            dec!(100),
            dec!(90),
            dec!(110),
            dec!(95),
            dec!(105),
            dec!(100),
        ],
        vec![dec!(50), dec!(51), dec!(49), dec!(52), dec!(48), dec!(53)],
        vec![
            dec!(200),
            dec!(200),
            dec!(200),
            dec!(200),
            dec!(200),
            dec!(200),
        ],
    ];
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    for prices in &patterns {
        let candles: Vec<Candle> = prices
            .iter()
            .enumerate()
            .map(|(i, &p)| make_candle(p, i as i64))
            .collect();
        let ratio = er
            .compute_ratio(&candles)
            .expect("sufficient data for ER ratio");
        assert!(
            ratio >= Decimal::ZERO && ratio <= Decimal::ONE,
            "ER {} outside [0, 1] for prices {:?}",
            ratio,
            prices
        );
    }
}

#[test]
fn compute_as_indicator_returns_series() {
    let candles: Vec<Candle> = (0..20)
        .map(|i| make_candle(dec!(100) + Decimal::from(i), i))
        .collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let series = er.compute(&candles).expect("sufficient data for ER");
    // 20 candles - 5 lookback = 15 output values
    assert_eq!(series.len(), 15);

    // All values should be in [0, 1]
    for val in series.decimal_values() {
        assert!(val >= Decimal::ZERO && val <= Decimal::ONE);
    }
}

#[test]
fn compute_as_indicator_insufficient_data() {
    let candles: Vec<Candle> = (0..3).map(|i| make_candle(dec!(100), i)).collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    let err = er.compute(&candles).unwrap_err();
    assert!(matches!(err, IndicatorError::InsufficientData { .. }));
}

#[test]
fn lookback_one_two_candles() {
    // With lookback=1, net = |c[1] - c[0]|, path = |c[1] - c[0]|, ER = 1.0 always
    let candles = vec![make_candle(dec!(100), 0), make_candle(dec!(105), 1)];
    let er = EfficiencyRatio::new(1).expect("valid ER lookback");
    let ratio = er
        .compute_ratio(&candles)
        .expect("sufficient data for ER ratio");
    assert_eq!(ratio, Decimal::ONE);
}

#[test]
fn exact_boundary_candle_count() {
    // Exactly lookback+1 candles — should work
    let candles: Vec<Candle> = (0..6)
        .map(|i| make_candle(dec!(100) + Decimal::from(i), i))
        .collect();
    let er = EfficiencyRatio::new(5).expect("valid ER lookback");
    assert!(er.compute_ratio(&candles).is_ok());

    // One less — should fail
    let candles: Vec<Candle> = (0..5)
        .map(|i| make_candle(dec!(100) + Decimal::from(i), i))
        .collect();
    assert!(er.compute_ratio(&candles).is_err());
}