quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
//! Ticker taxonomy - universal asset identification.
//!
//! Provides a canonical representation for assets across different venues
//! and instrument types (spot, derivatives, etc.).

use std::fmt;
use std::str::FromStr;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::crypto_classifier::{is_crypto_base, is_crypto_quote};

/// Errors from parsing a [`Ticker`] from a string.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TickerError {
    #[error("invalid ticker format '{0}': expected BASE/QUOTE (e.g., BTC/USD)")]
    InvalidFormat(String),
}

/// The asset class of an underlying.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetClass {
    Equity,
    Crypto,
    Commodity,
    Forex,
}

impl FromStr for AssetClass {
    type Err = TickerError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "equity" | "stock" => Ok(AssetClass::Equity),
            "crypto" | "cryptocurrency" => Ok(AssetClass::Crypto),
            "commodity" => Ok(AssetClass::Commodity),
            "forex" | "fx" => Ok(AssetClass::Forex),
            _ => Err(TickerError::InvalidFormat(format!(
                "unknown asset class: '{s}'"
            ))),
        }
    }
}

/// The real-world asset being traded.
///
/// Backed by `Arc<str>` for O(1) clone in hot loops.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Underlying {
    canonical: Arc<str>,
    asset_class: AssetClass,
}

impl Underlying {
    pub fn new(canonical: impl Into<String>, asset_class: AssetClass) -> Self {
        Self {
            canonical: Arc::from(canonical.into().to_uppercase().as_str()),
            asset_class,
        }
    }

    pub fn canonical(&self) -> &str {
        &self.canonical
    }

    pub fn asset_class(&self) -> AssetClass {
        self.asset_class
    }
}

/// Derivative instrument types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Derivative {
    Perp,
    Cfd,
}

/// What instrument type you're trading.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetType {
    Spot,
    Derivative(Derivative),
}

/// Where the asset trades.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VenueType {
    TradFi,
    Cex,
    Dex,
}

/// Universal ticker - canonical representation across venues.
///
/// Backed by `Arc<str>` for O(1) clone in hot loops.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Ticker {
    underlying: Underlying,
    quote: Arc<str>,
    asset_type: AssetType,
    venue_type: VenueType,
    /// Optional exchange suffix hint for provider formatting (#701).
    ///
    /// When set, providers use this directly instead of reverse-looking up
    /// the exchange from the quote currency. Required because multiple exchanges
    /// share the same currency (e.g. PA, XETRA, AS all use EUR).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    exchange_hint: Option<Arc<str>>,
}

impl Ticker {
    pub fn new(
        underlying: Underlying,
        quote: impl Into<String>,
        asset_type: AssetType,
        venue_type: VenueType,
    ) -> Self {
        Self {
            underlying,
            quote: Arc::from(quote.into().to_uppercase().as_str()),
            asset_type,
            venue_type,
            exchange_hint: None,
        }
    }

    /// Set the exchange hint for provider formatting (#701).
    pub fn with_exchange_hint(mut self, hint: impl Into<String>) -> Self {
        self.exchange_hint = Some(Arc::from(hint.into().to_uppercase().as_str()));
        self
    }

    pub fn underlying(&self) -> &Underlying {
        &self.underlying
    }

    pub fn quote(&self) -> &str {
        &self.quote
    }

    pub fn asset_type(&self) -> AssetType {
        self.asset_type
    }

    pub fn venue_type(&self) -> VenueType {
        self.venue_type
    }

    /// Exchange suffix hint for provider formatting (e.g. "XETRA", "AS").
    pub fn exchange_hint(&self) -> Option<&str> {
        self.exchange_hint.as_deref()
    }

    pub fn canonical(&self) -> String {
        self.to_string()
    }
}

impl fmt::Display for Ticker {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.underlying.canonical(), self.quote)
    }
}

impl FromStr for Ticker {
    type Err = TickerError;

    /// Parse a `BASE/QUOTE` string into a Ticker.
    ///
    /// Infers asset class heuristically:
    /// - Crypto quotes (USDT, USDC, BTC, ETH, etc.) → Crypto
    /// - USD/fiat quotes with crypto base (BTC, ETH, SOL, etc.) → Crypto
    /// - USD/fiat quotes with non-crypto base → Equity
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let Some((base, quote)) = s.split_once('/') else {
            return Err(TickerError::InvalidFormat(s.to_string()));
        };

        if base.is_empty() || quote.is_empty() {
            return Err(TickerError::InvalidFormat(s.to_string()));
        }

        if quote.contains('/') {
            return Err(TickerError::InvalidFormat(s.to_string()));
        }

        // Infer asset class based on quote and base
        let asset_class = if is_crypto_quote(quote) || is_crypto_base(base) {
            AssetClass::Crypto
        } else {
            AssetClass::Equity
        };

        let venue_type = match asset_class {
            AssetClass::Crypto => VenueType::Cex,
            AssetClass::Equity => VenueType::TradFi,
            _ => VenueType::TradFi,
        };

        let underlying = Underlying::new(base, asset_class);
        Ok(Ticker::new(underlying, quote, AssetType::Spot, venue_type))
    }
}

// ── Symbol → Ticker conversion ─────────────────────────────────────────

use crate::Symbol;

impl From<&Symbol> for Ticker {
    /// Convert a [`Symbol`] to a [`Ticker`], inferring asset class and venue.
    ///
    /// - Crypto pairs (USDT, BTC, etc. quote/base) → `Crypto` + `Cex`
    /// - Everything else → `Equity` + `TradFi`
    fn from(symbol: &Symbol) -> Self {
        let (asset_class, venue_type) = if symbol.is_crypto() {
            (AssetClass::Crypto, VenueType::Cex)
        } else {
            (AssetClass::Equity, VenueType::TradFi)
        };
        let underlying = Underlying::new(symbol.base(), asset_class);
        Ticker::new(underlying, symbol.quote(), AssetType::Spot, venue_type)
    }
}

#[cfg(test)]
#[path = "taxonomy_tests.rs"]
mod tests;