quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
use super::*;
use chrono::{TimeZone, Utc};
use rust_decimal_macros::dec;

#[test]
fn create_valid_candle() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    assert_eq!(candle.open(), dec!(100));
    assert_eq!(candle.high(), dec!(110));
    assert_eq!(candle.low(), dec!(90));
    assert_eq!(candle.close(), dec!(105));
    assert_eq!(candle.volume(), dec!(1000));
}

#[test]
fn high_below_low_fails() {
    let ts = Utc::now();
    let result = Candle::new(dec!(100), dec!(80), dec!(90), dec!(85), dec!(1000), ts);
    assert!(matches!(result, Err(CandleError::HighBelowLow { .. })));
}

#[test]
fn high_equals_low_succeeds() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(100), dec!(100), dec!(100), dec!(500), ts)
        .expect("valid candle");
    assert_eq!(candle.high(), candle.low());
}

#[test]
fn zero_volume_succeeds() {
    let ts = Utc::now();
    let candle =
        Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(0), ts).expect("valid candle");
    assert_eq!(candle.volume(), dec!(0));
}

#[test]
fn high_below_low_error_contains_values() {
    let ts = Utc::now();
    let result = Candle::new(dec!(100), dec!(80), dec!(90), dec!(85), dec!(1000), ts);
    match result {
        Err(CandleError::HighBelowLow { high, low }) => {
            assert_eq!(high, dec!(80));
            assert_eq!(low, dec!(90));
        }
        _ => panic!("expected HighBelowLow error"),
    }
}

#[test]
fn timestamp_is_preserved() {
    let ts = Utc
        .with_ymd_and_hms(2026, 1, 15, 12, 0, 0)
        .single()
        .expect("valid datetime");
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(500), ts)
        .expect("valid candle");
    assert_eq!(candle.timestamp(), ts);
}

#[test]
fn serde_roundtrip() {
    let ts = Utc
        .with_ymd_and_hms(2026, 3, 1, 0, 0, 0)
        .single()
        .expect("valid datetime");
    let original = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    let json = serde_json::to_string(&original).expect("valid json");
    let parsed: Candle = serde_json::from_str(&json).expect("valid json");
    assert_eq!(parsed, original);
}

#[test]
fn candle_error_display_high_below_low() {
    let err = CandleError::HighBelowLow {
        high: dec!(80),
        low: dec!(90),
    };
    let msg = err.to_string();
    assert!(msg.contains("80"), "should contain high value");
    assert!(msg.contains("90"), "should contain low value");
}

#[test]
fn candle_error_display_empty_aggregation() {
    let err = CandleError::EmptyAggregation;
    assert!(err.to_string().contains("empty"));
}

#[test]
fn candle_range_empty_slice_returns_none() {
    assert_eq!(CandleRange::from_candles(&[]), None);
}

#[test]
fn candle_range_single_candle() {
    let ts = Utc
        .with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
        .single()
        .expect("valid datetime");
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    let range = CandleRange::from_candles(&[candle]).expect("non-empty slice");
    assert_eq!(range.earliest, ts);
    assert_eq!(range.latest, ts);
}

#[test]
fn candle_range_multiple_candles() {
    use chrono::Duration;
    let ts1 = Utc
        .with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
        .single()
        .expect("valid datetime");
    let ts2 = ts1 + Duration::days(5);
    let c1 =
        Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts1).expect("valid");
    let c2 =
        Candle::new(dec!(200), dec!(210), dec!(190), dec!(205), dec!(2000), ts2).expect("valid");
    let range = CandleRange::from_candles(&[c1, c2]).expect("non-empty slice");
    assert_eq!(range.earliest, ts1);
    assert_eq!(range.latest, ts2);
}

// ── VolumeSource tests ─────────────────────────────────────────────────

#[test]
fn new_candle_defaults_to_unknown_volume_source() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    assert_eq!(candle.volume_source(), VolumeSource::Unknown);
}

#[test]
fn with_volume_source_sets_trade_size() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle")
        .with_volume_source(VolumeSource::TradeSize);
    assert_eq!(candle.volume_source(), VolumeSource::TradeSize);
}

#[test]
fn with_volume_source_sets_tick_count() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(5), ts)
        .expect("valid candle")
        .with_volume_source(VolumeSource::TickCount);
    assert_eq!(candle.volume_source(), VolumeSource::TickCount);
}

#[test]
fn with_volume_source_sets_exchange_reported() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(50000), ts)
        .expect("valid candle")
        .with_volume_source(VolumeSource::ExchangeReported);
    assert_eq!(candle.volume_source(), VolumeSource::ExchangeReported);
}

#[test]
fn volume_source_as_str_roundtrip() {
    for vs in [
        VolumeSource::TradeSize,
        VolumeSource::TickCount,
        VolumeSource::ExchangeReported,
        VolumeSource::Unknown,
    ] {
        assert_eq!(VolumeSource::from_str_value(vs.as_str()), vs);
    }
}

#[test]
fn volume_source_from_unknown_string_defaults_to_unknown() {
    assert_eq!(
        VolumeSource::from_str_value("garbage"),
        VolumeSource::Unknown
    );
    assert_eq!(VolumeSource::from_str_value(""), VolumeSource::Unknown);
}

#[test]
fn volume_source_display() {
    assert_eq!(VolumeSource::TradeSize.to_string(), "trade_size");
    assert_eq!(VolumeSource::TickCount.to_string(), "tick_count");
    assert_eq!(
        VolumeSource::ExchangeReported.to_string(),
        "exchange_reported"
    );
    assert_eq!(VolumeSource::Unknown.to_string(), "unknown");
}

#[test]
fn serde_roundtrip_with_volume_source() {
    let ts = Utc
        .with_ymd_and_hms(2026, 3, 17, 0, 0, 0)
        .single()
        .expect("valid datetime");
    let original = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle")
        .with_volume_source(VolumeSource::TradeSize);
    let json = serde_json::to_string(&original).expect("valid json");
    let parsed: Candle = serde_json::from_str(&json).expect("valid json");
    assert_eq!(parsed.volume_source(), VolumeSource::TradeSize);
    assert_eq!(parsed, original);
}

#[test]
fn serde_legacy_json_without_volume_source_defaults_to_unknown() {
    let json = r#"{"open":"100","high":"110","low":"90","close":"105","volume":"1000","timestamp":"2026-03-17T00:00:00Z"}"#;
    let parsed: Candle = serde_json::from_str(json).expect("valid json");
    assert_eq!(parsed.volume_source(), VolumeSource::Unknown);
}

#[test]
fn volume_source_default_is_unknown() {
    assert_eq!(VolumeSource::default(), VolumeSource::Unknown);
}

// ── CandleQuality tests ─────────────────────────────────────────────────

#[test]
fn candle_quality_default_is_complete() {
    assert_eq!(CandleQuality::default(), CandleQuality::Complete);
}

#[test]
fn candle_quality_complete_serde_roundtrip() {
    let q = CandleQuality::Complete;
    let json = serde_json::to_string(&q).expect("serialize");
    let parsed: CandleQuality = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed, q);
}

#[test]
fn candle_quality_partial_serde_roundtrip() {
    let q = CandleQuality::Partial {
        reason: "provider timeout".to_string(),
    };
    let json = serde_json::to_string(&q).expect("serialize");
    let parsed: CandleQuality = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(parsed, q);
    if let CandleQuality::Partial { reason } = &parsed {
        assert_eq!(reason, "provider timeout");
    } else {
        panic!("expected Partial variant");
    }
}

#[test]
fn candle_quality_as_str_complete() {
    assert_eq!(CandleQuality::Complete.as_str(), "complete");
}

#[test]
fn candle_quality_as_str_partial() {
    let q = CandleQuality::Partial {
        reason: "gap fill".to_string(),
    };
    assert_eq!(q.as_str(), "partial");
}

#[test]
fn candle_quality_from_str_value_complete() {
    assert_eq!(
        CandleQuality::from_str_value("complete"),
        CandleQuality::Complete
    );
}

#[test]
fn candle_quality_from_str_value_partial() {
    assert_eq!(
        CandleQuality::from_str_value("partial"),
        CandleQuality::Partial {
            reason: String::new()
        }
    );
}

#[test]
fn candle_quality_from_str_value_unknown_defaults_to_complete() {
    assert_eq!(
        CandleQuality::from_str_value("garbage"),
        CandleQuality::Complete
    );
    assert_eq!(CandleQuality::from_str_value(""), CandleQuality::Complete);
}

#[test]
fn candle_quality_display() {
    assert_eq!(CandleQuality::Complete.to_string(), "complete");
    assert_eq!(
        CandleQuality::Partial {
            reason: "x".to_string()
        }
        .to_string(),
        "partial"
    );
}

#[test]
fn new_candle_defaults_to_complete_quality() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    assert_eq!(candle.quality(), &CandleQuality::Complete);
}

#[test]
fn with_quality_sets_partial() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle")
        .with_quality(CandleQuality::Partial {
            reason: "window invalidation".to_string(),
        });
    assert_eq!(
        candle.quality(),
        &CandleQuality::Partial {
            reason: "window invalidation".to_string()
        }
    );
}

#[test]
fn candle_quality_accessor_returns_ref() {
    let ts = Utc::now();
    let candle = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle");
    let quality_ref: &CandleQuality = candle.quality();
    assert_eq!(quality_ref, &CandleQuality::Complete);
}

#[test]
fn serde_legacy_json_without_quality_defaults_to_complete() {
    let json = r#"{"open":"100","high":"110","low":"90","close":"105","volume":"1000","timestamp":"2026-03-17T00:00:00Z"}"#;
    let parsed: Candle = serde_json::from_str(json).expect("valid json");
    assert_eq!(parsed.quality(), &CandleQuality::Complete);
}

#[test]
fn serde_roundtrip_with_quality() {
    let ts = Utc
        .with_ymd_and_hms(2026, 3, 21, 0, 0, 0)
        .single()
        .expect("valid datetime");
    let original = Candle::new(dec!(100), dec!(110), dec!(90), dec!(105), dec!(1000), ts)
        .expect("valid candle")
        .with_quality(CandleQuality::Partial {
            reason: "provider timeout".to_string(),
        });
    let json = serde_json::to_string(&original).expect("valid json");
    let parsed: Candle = serde_json::from_str(&json).expect("valid json");
    assert_eq!(
        parsed.quality(),
        &CandleQuality::Partial {
            reason: "provider timeout".to_string()
        }
    );
    assert_eq!(parsed, original);
}