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 crate::error::IndicatorError;
use crate::rolling_zscore::RollingZScore;

#[test]
fn window_not_filled_returns_none() {
    let mut zs = RollingZScore::new(5).expect("valid window");
    assert!(zs.value().is_none(), "empty window");

    zs.update(dec!(1));
    assert!(zs.value().is_none(), "1 of 5");

    zs.update(dec!(2));
    assert!(zs.value().is_none(), "2 of 5");

    zs.update(dec!(3));
    assert!(zs.value().is_none(), "3 of 5");

    zs.update(dec!(4));
    assert!(zs.value().is_none(), "4 of 5");
}

#[test]
fn all_same_values_returns_none() {
    let mut zs = RollingZScore::new(5).expect("valid window");
    for _ in 0..5 {
        zs.update(dec!(7));
    }
    assert!(
        zs.value().is_none(),
        "stddev=0 when all values identical → None"
    );
}

#[test]
fn known_zscore_1_2_3_4_5_window5() {
    // Values [1, 2, 3, 4, 5], window=5
    // mean = 3, stddev = sqrt(2) ≈ 1.4142135624
    // z-score of 5 = (5 - 3) / sqrt(2) = 2 / 1.4142... ≈ 1.4142135624
    let mut zs = RollingZScore::new(5).expect("valid window");
    for i in 1..=5 {
        zs.update(Decimal::from(i));
    }
    let z = zs.value().expect("window filled, stddev > 0");
    // sqrt(2) ≈ 1.4142135624, allow tolerance of 0.0001
    let expected = dec!(1.4142135624);
    let diff = (z - expected).abs();
    assert!(
        diff < dec!(0.0001),
        "expected z ≈ {expected}, got {z}, diff = {diff}"
    );
}

#[test]
fn negative_and_mixed_values() {
    // Values [-2, -1, 0, 1, 2], window=5
    // mean = 0, stddev = sqrt((4+1+0+1+4)/5) = sqrt(2) ≈ 1.4142
    // z-score of 2 = (2 - 0) / sqrt(2) ≈ 1.4142
    let mut zs = RollingZScore::new(5).expect("valid window");
    for v in [-2, -1, 0, 1, 2] {
        zs.update(Decimal::from(v));
    }
    let z = zs.value().expect("window filled");
    let expected = dec!(1.4142135624);
    let diff = (z - expected).abs();
    assert!(
        diff < dec!(0.0001),
        "expected z ≈ {expected}, got {z}, diff = {diff}"
    );

    // Also test with negative latest: feed [-3], window becomes [-1,0,1,2,-3]
    zs.update(dec!(-3));
    // mean = (-1+0+1+2-3)/5 = -1/5 = -0.2
    // var = ((−0.8)² + (0.2)² + (1.2)² + (2.2)² + (−2.8)²) / 5
    //     = (0.64 + 0.04 + 1.44 + 4.84 + 7.84) / 5
    //     = 14.8 / 5 = 2.96
    // stddev = sqrt(2.96) ≈ 1.72047
    // z = (-3 - (-0.2)) / 1.72047 = -2.8 / 1.72047 ≈ -1.62744
    let z = zs.value().expect("window filled");
    assert!(z < Decimal::ZERO, "z-score should be negative, got {z}");
    let expected_neg = dec!(-1.6274);
    let diff = (z - expected_neg).abs();
    assert!(
        diff < dec!(0.001),
        "expected z ≈ {expected_neg}, got {z}, diff = {diff}"
    );
}

#[test]
fn window_1_returns_error() {
    let err = RollingZScore::new(1).expect_err("window=1 should fail");
    match err {
        IndicatorError::InvalidParameter { message } => {
            assert!(
                message.contains("must be > 1"),
                "error should mention 'must be > 1', got: {message}"
            );
        }
        other => panic!("expected InvalidParameter, got: {other:?}"),
    }
}

#[test]
fn window_0_returns_error() {
    let err = RollingZScore::new(0).expect_err("window=0 should fail");
    match err {
        IndicatorError::InvalidParameter { message } => {
            assert!(
                message.contains("must be > 1"),
                "error should mention 'must be > 1', got: {message}"
            );
        }
        other => panic!("expected InvalidParameter, got: {other:?}"),
    }
}

#[test]
fn rolling_window_slides_correctly() {
    // Feed 10 values with window=5, verify z-score updates as window slides
    let mut zs = RollingZScore::new(5).expect("valid window");
    let data = [
        dec!(10),
        dec!(12),
        dec!(11),
        dec!(13),
        dec!(15),
        dec!(14),
        dec!(16),
        dec!(18),
        dec!(17),
        dec!(19),
    ];

    // Fill window (first 5)
    for v in &data[..5] {
        zs.update(*v);
    }

    let z1 = zs.value().expect("window filled after 5");
    // Window: [10, 12, 11, 13, 15], latest=15
    // mean = 12.2, z should be positive (15 > 12.2)
    assert!(z1 > Decimal::ZERO, "15 > mean → positive z, got {z1}");

    // Feed 6th value, window slides to [12, 11, 13, 15, 14]
    zs.update(data[5]); // 14
    let z2 = zs.value().expect("window still filled");
    // Window: [12, 11, 13, 15, 14], latest=14, mean=13
    // z should be positive but different from z1
    assert!(z2 > Decimal::ZERO, "14 > mean → positive z, got {z2}");
    assert!(z1 != z2, "z-score should change as window slides");

    // Feed remaining values
    for v in &data[6..] {
        zs.update(*v);
    }
    let z_final = zs.value().expect("window filled");
    // Window: [16, 18, 17, 19, ?] → [16, 18, 17, 19] — wait, let me count
    // After all 10: window = [16, 18, 17, 19] is only 4... no, window=5
    // data[5..10] = [14, 16, 18, 17, 19]
    // Final window: [14, 16, 18, 17, 19]? No — after data[5]=14, window=[12,11,13,15,14]
    // data[6]=16 → [11,13,15,14,16]
    // data[7]=18 → [13,15,14,16,18]
    // data[8]=17 → [15,14,16,18,17]
    // data[9]=19 → [14,16,18,17,19]
    // mean = (14+16+18+17+19)/5 = 84/5 = 16.8
    // latest = 19, z = (19-16.8)/stddev > 0
    assert!(
        z_final > Decimal::ZERO,
        "19 > mean(16.8) → positive z, got {z_final}"
    );
}

#[test]
fn new_with_valid_window_succeeds() {
    let zs = RollingZScore::new(2);
    assert!(zs.is_ok(), "window=2 should succeed");

    let zs = RollingZScore::new(100);
    assert!(zs.is_ok(), "window=100 should succeed");
}