quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
use super::*;

// === Underlying tests ===

#[test]
fn underlying_stores_canonical_uppercase() {
    let u = Underlying::new("btc", AssetClass::Crypto);
    assert_eq!(u.canonical(), "BTC");
}

#[test]
fn underlying_stores_asset_class() {
    let u = Underlying::new("TSLA", AssetClass::Equity);
    assert_eq!(u.asset_class(), AssetClass::Equity);
}

// === Ticker tests ===

#[test]
fn ticker_stores_underlying() {
    let u = Underlying::new("BTC", AssetClass::Crypto);
    let t = Ticker::new(u.clone(), "USDT", AssetType::Spot, VenueType::Cex);
    assert_eq!(t.underlying().canonical(), "BTC");
}

#[test]
fn ticker_normalizes_quote_uppercase() {
    let u = Underlying::new("BTC", AssetClass::Crypto);
    let t = Ticker::new(u, "usdt", AssetType::Spot, VenueType::Cex);
    assert_eq!(t.quote(), "USDT");
}

#[test]
fn ticker_stores_asset_type_spot() {
    let u = Underlying::new("BTC", AssetClass::Crypto);
    let t = Ticker::new(u, "USDT", AssetType::Spot, VenueType::Cex);
    assert_eq!(t.asset_type(), AssetType::Spot);
}

#[test]
fn ticker_stores_asset_type_perp() {
    let u = Underlying::new("SOL", AssetClass::Crypto);
    let t = Ticker::new(
        u,
        "USDC",
        AssetType::Derivative(Derivative::Perp),
        VenueType::Dex,
    );
    assert_eq!(t.asset_type(), AssetType::Derivative(Derivative::Perp));
}

#[test]
fn ticker_stores_venue_type() {
    let u = Underlying::new("TSLA", AssetClass::Equity);
    let t = Ticker::new(u, "USD", AssetType::Spot, VenueType::TradFi);
    assert_eq!(t.venue_type(), VenueType::TradFi);
}

#[test]
fn ticker_canonical_format() {
    let u = Underlying::new("SOL", AssetClass::Crypto);
    let t = Ticker::new(u, "USDC", AssetType::Spot, VenueType::Dex);
    assert_eq!(t.canonical(), "SOL/USDC");
}

#[test]
fn same_underlying_across_venues() {
    let u = Underlying::new("TSLA", AssetClass::Equity);
    let tradfi = Ticker::new(u.clone(), "USD", AssetType::Spot, VenueType::TradFi);
    let dex = Ticker::new(u, "USDC", AssetType::Spot, VenueType::Dex);
    assert_eq!(
        tradfi.underlying().canonical(),
        dex.underlying().canonical()
    );
}

// === FromStr tests ===

#[test]
fn from_str_valid_ticker() {
    let t: Ticker = "BTC/USD".parse().expect("valid ticker");
    assert_eq!(t.underlying().canonical(), "BTC");
    assert_eq!(t.quote(), "USD");
    assert_eq!(t.asset_type(), AssetType::Spot);
    assert_eq!(t.venue_type(), VenueType::Cex);
}

#[test]
fn from_str_normalizes_case() {
    let t: Ticker = "eth/usdt".parse().expect("valid ticker");
    assert_eq!(t.underlying().canonical(), "ETH");
    assert_eq!(t.quote(), "USDT");
}

#[test]
fn from_str_rejects_no_separator() {
    let result = "BTCUSD".parse::<Ticker>();
    assert!(matches!(result, Err(TickerError::InvalidFormat(s)) if s == "BTCUSD"));
}

#[test]
fn from_str_rejects_too_many_parts() {
    let result = "BTC/USD/EXTRA".parse::<Ticker>();
    assert!(matches!(result, Err(TickerError::InvalidFormat(_))));
}

#[test]
fn from_str_rejects_empty() {
    let result = "".parse::<Ticker>();
    assert!(matches!(result, Err(TickerError::InvalidFormat(_))));
}

#[test]
fn from_str_rejects_empty_base() {
    let result = "/USD".parse::<Ticker>();
    assert!(matches!(result, Err(TickerError::InvalidFormat(_))));
}

#[test]
fn from_str_rejects_empty_quote() {
    let result = "BTC/".parse::<Ticker>();
    assert!(matches!(result, Err(TickerError::InvalidFormat(_))));
}

#[test]
fn display_matches_canonical() {
    let u = Underlying::new("BTC", AssetClass::Crypto);
    let t = Ticker::new(u, "USD", AssetType::Spot, VenueType::Cex);
    assert_eq!(format!("{t}"), "BTC/USD");
    assert_eq!(t.to_string(), t.canonical());
}

#[test]
fn canonical_roundtrip_through_parse() {
    let u = Underlying::new("SOL", AssetClass::Crypto);
    let original = Ticker::new(u, "USDC", AssetType::Spot, VenueType::Cex);
    let canonical = original.canonical();
    let parsed: Ticker = canonical.parse().expect("valid ticker");
    assert_eq!(parsed.underlying().canonical(), "SOL");
    assert_eq!(parsed.quote(), "USDC");
}

// === Issue #2223: Asset class inference anti-regression tests ===

#[test]
fn from_str_infers_crypto_from_crypto_quote() {
    // Crypto quotes (USDT, USDC, etc.) → Crypto
    let t: Ticker = "BTC/USDT".parse().expect("valid ticker");
    assert_eq!(t.underlying().asset_class(), AssetClass::Crypto);
    assert_eq!(t.venue_type(), VenueType::Cex);
}

#[test]
fn from_str_infers_crypto_from_crypto_base_with_usd() {
    // Crypto base + USD quote → Crypto (e.g., BTC/USD on Kraken)
    let t: Ticker = "BTC/USD".parse().expect("valid ticker");
    assert_eq!(t.underlying().asset_class(), AssetClass::Crypto);
    assert_eq!(t.venue_type(), VenueType::Cex);

    let t2: Ticker = "ETH/USD".parse().expect("valid ticker");
    assert_eq!(t2.underlying().asset_class(), AssetClass::Crypto);

    let t3: Ticker = "SOL/USD".parse().expect("valid ticker");
    assert_eq!(t3.underlying().asset_class(), AssetClass::Crypto);
}

#[test]
fn from_str_infers_equity_from_non_crypto_base_with_usd() {
    // Non-crypto base + USD quote → Equity (e.g., AAPL/USD)
    let t: Ticker = "AAPL/USD".parse().expect("valid ticker");
    assert_eq!(t.underlying().asset_class(), AssetClass::Equity);
    assert_eq!(t.venue_type(), VenueType::TradFi);

    let t2: Ticker = "TSLA/USD".parse().expect("valid ticker");
    assert_eq!(t2.underlying().asset_class(), AssetClass::Equity);

    let t3: Ticker = "MSFT/USD".parse().expect("valid ticker");
    assert_eq!(t3.underlying().asset_class(), AssetClass::Equity);
}

// === AssetClass tests ===

#[test]
fn asset_class_from_str_all_variants() {
    assert_eq!(
        "equity".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Equity
    );
    assert_eq!(
        "crypto".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Crypto
    );
    assert_eq!(
        "commodity"
            .parse::<AssetClass>()
            .expect("valid asset class"),
        AssetClass::Commodity
    );
    assert_eq!(
        "forex".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Forex
    );
}

#[test]
fn asset_class_from_str_aliases() {
    assert_eq!(
        "stock".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Equity
    );
    assert_eq!(
        "cryptocurrency"
            .parse::<AssetClass>()
            .expect("valid asset class"),
        AssetClass::Crypto
    );
    assert_eq!(
        "fx".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Forex
    );
}

#[test]
fn asset_class_from_str_case_insensitive() {
    assert_eq!(
        "EQUITY".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Equity
    );
    assert_eq!(
        "Crypto".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Crypto
    );
    assert_eq!(
        "FOREX".parse::<AssetClass>().expect("valid asset class"),
        AssetClass::Forex
    );
}

#[test]
fn asset_class_from_str_rejects_unknown() {
    let result = "bonds".parse::<AssetClass>();
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(
        err.to_string().contains("bonds"),
        "error should contain the invalid input"
    );
}

#[test]
fn asset_class_serde_roundtrip() {
    for variant in [
        AssetClass::Equity,
        AssetClass::Crypto,
        AssetClass::Commodity,
        AssetClass::Forex,
    ] {
        let json = serde_json::to_string(&variant).expect("valid json");
        let parsed: AssetClass = serde_json::from_str(&json).expect("valid json");
        assert_eq!(parsed, variant);
    }
}

// === Derivative tests ===

#[test]
fn derivative_variants_are_distinct() {
    assert_ne!(Derivative::Perp, Derivative::Cfd);
}

#[test]
fn derivative_serde_roundtrip() {
    for variant in [Derivative::Perp, Derivative::Cfd] {
        let json = serde_json::to_string(&variant).expect("valid json");
        let parsed: Derivative = serde_json::from_str(&json).expect("valid json");
        assert_eq!(parsed, variant);
    }
}

#[test]
fn derivative_debug_format() {
    assert_eq!(format!("{:?}", Derivative::Perp), "Perp");
    assert_eq!(format!("{:?}", Derivative::Cfd), "Cfd");
}

// === AssetType tests ===

#[test]
fn asset_type_spot_vs_derivative() {
    assert_ne!(AssetType::Spot, AssetType::Derivative(Derivative::Perp));
    assert_ne!(AssetType::Spot, AssetType::Derivative(Derivative::Cfd));
}

#[test]
fn asset_type_derivative_variants_are_distinct() {
    assert_ne!(
        AssetType::Derivative(Derivative::Perp),
        AssetType::Derivative(Derivative::Cfd)
    );
}

#[test]
fn asset_type_serde_roundtrip() {
    for variant in [
        AssetType::Spot,
        AssetType::Derivative(Derivative::Perp),
        AssetType::Derivative(Derivative::Cfd),
    ] {
        let json = serde_json::to_string(&variant).expect("valid json");
        let parsed: AssetType = serde_json::from_str(&json).expect("valid json");
        assert_eq!(parsed, variant);
    }
}

// === VenueType tests ===

#[test]
fn venue_type_all_variants_distinct() {
    let variants = [VenueType::TradFi, VenueType::Cex, VenueType::Dex];
    for (i, a) in variants.iter().enumerate() {
        for (j, b) in variants.iter().enumerate() {
            if i != j {
                assert_ne!(a, b);
            }
        }
    }
}

#[test]
fn venue_type_serde_roundtrip() {
    for variant in [VenueType::TradFi, VenueType::Cex, VenueType::Dex] {
        let json = serde_json::to_string(&variant).expect("valid json");
        let parsed: VenueType = serde_json::from_str(&json).expect("valid json");
        assert_eq!(parsed, variant);
    }
}

#[test]
fn venue_type_debug_format() {
    assert_eq!(format!("{:?}", VenueType::TradFi), "TradFi");
    assert_eq!(format!("{:?}", VenueType::Cex), "Cex");
    assert_eq!(format!("{:?}", VenueType::Dex), "Dex");
}

// === Underlying serde test ===

#[test]
fn underlying_serde_roundtrip() {
    let original = Underlying::new("BTC", AssetClass::Crypto);
    let json = serde_json::to_string(&original).expect("valid json");
    let parsed: Underlying = serde_json::from_str(&json).expect("valid json");
    assert_eq!(parsed.canonical(), "BTC");
    assert_eq!(parsed.asset_class(), AssetClass::Crypto);
}

#[test]
fn underlying_equality() {
    let a = Underlying::new("btc", AssetClass::Crypto);
    let b = Underlying::new("BTC", AssetClass::Crypto);
    assert_eq!(a, b, "underlying normalizes to uppercase");
}

// === Ticker serde test ===

#[test]
fn ticker_serde_roundtrip() {
    let u = Underlying::new("SOL", AssetClass::Crypto);
    let original = Ticker::new(
        u,
        "USDC",
        AssetType::Derivative(Derivative::Perp),
        VenueType::Dex,
    );
    let json = serde_json::to_string(&original).expect("valid json");
    let parsed: Ticker = serde_json::from_str(&json).expect("valid json");
    assert_eq!(parsed, original);
}

// === TickerError tests ===

#[test]
fn ticker_error_display_contains_input() {
    let err = TickerError::InvalidFormat("bad_input".to_string());
    let msg = err.to_string();
    assert!(
        msg.contains("bad_input"),
        "error message should contain the invalid input"
    );
}