Skip to main content

quant_primitives/
taxonomy.rs

1//! Ticker taxonomy - universal asset identification.
2//!
3//! Provides a canonical representation for assets across different venues
4//! and instrument types (spot, derivatives, etc.).
5
6use std::fmt;
7use std::str::FromStr;
8use std::sync::Arc;
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::crypto_classifier::{is_crypto_base, is_crypto_quote};
14
15/// Errors from parsing a [`Ticker`] from a string.
16#[derive(Debug, Error, PartialEq, Eq)]
17pub enum TickerError {
18    #[error("invalid ticker format '{0}': expected BASE/QUOTE (e.g., BTC/USD)")]
19    InvalidFormat(String),
20}
21
22/// The asset class of an underlying.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub enum AssetClass {
25    Equity,
26    Crypto,
27    Commodity,
28    Forex,
29}
30
31impl FromStr for AssetClass {
32    type Err = TickerError;
33
34    fn from_str(s: &str) -> Result<Self, Self::Err> {
35        match s.to_lowercase().as_str() {
36            "equity" | "stock" => Ok(AssetClass::Equity),
37            "crypto" | "cryptocurrency" => Ok(AssetClass::Crypto),
38            "commodity" => Ok(AssetClass::Commodity),
39            "forex" | "fx" => Ok(AssetClass::Forex),
40            _ => Err(TickerError::InvalidFormat(format!(
41                "unknown asset class: '{s}'"
42            ))),
43        }
44    }
45}
46
47/// The real-world asset being traded.
48///
49/// Backed by `Arc<str>` for O(1) clone in hot loops.
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct Underlying {
52    canonical: Arc<str>,
53    asset_class: AssetClass,
54}
55
56impl Underlying {
57    pub fn new(canonical: impl Into<String>, asset_class: AssetClass) -> Self {
58        Self {
59            canonical: Arc::from(canonical.into().to_uppercase().as_str()),
60            asset_class,
61        }
62    }
63
64    pub fn canonical(&self) -> &str {
65        &self.canonical
66    }
67
68    pub fn asset_class(&self) -> AssetClass {
69        self.asset_class
70    }
71}
72
73/// Derivative instrument types.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub enum Derivative {
76    Perp,
77    Cfd,
78}
79
80/// What instrument type you're trading.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum AssetType {
83    Spot,
84    Derivative(Derivative),
85}
86
87/// Where the asset trades.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub enum VenueType {
90    TradFi,
91    Cex,
92    Dex,
93}
94
95/// Universal ticker - canonical representation across venues.
96///
97/// Backed by `Arc<str>` for O(1) clone in hot loops.
98#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
99pub struct Ticker {
100    underlying: Underlying,
101    quote: Arc<str>,
102    asset_type: AssetType,
103    venue_type: VenueType,
104    /// Optional exchange suffix hint for provider formatting (#701).
105    ///
106    /// When set, providers use this directly instead of reverse-looking up
107    /// the exchange from the quote currency. Required because multiple exchanges
108    /// share the same currency (e.g. PA, XETRA, AS all use EUR).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    exchange_hint: Option<Arc<str>>,
111}
112
113impl Ticker {
114    pub fn new(
115        underlying: Underlying,
116        quote: impl Into<String>,
117        asset_type: AssetType,
118        venue_type: VenueType,
119    ) -> Self {
120        Self {
121            underlying,
122            quote: Arc::from(quote.into().to_uppercase().as_str()),
123            asset_type,
124            venue_type,
125            exchange_hint: None,
126        }
127    }
128
129    /// Set the exchange hint for provider formatting (#701).
130    pub fn with_exchange_hint(mut self, hint: impl Into<String>) -> Self {
131        self.exchange_hint = Some(Arc::from(hint.into().to_uppercase().as_str()));
132        self
133    }
134
135    pub fn underlying(&self) -> &Underlying {
136        &self.underlying
137    }
138
139    pub fn quote(&self) -> &str {
140        &self.quote
141    }
142
143    pub fn asset_type(&self) -> AssetType {
144        self.asset_type
145    }
146
147    pub fn venue_type(&self) -> VenueType {
148        self.venue_type
149    }
150
151    /// Exchange suffix hint for provider formatting (e.g. "XETRA", "AS").
152    pub fn exchange_hint(&self) -> Option<&str> {
153        self.exchange_hint.as_deref()
154    }
155
156    pub fn canonical(&self) -> String {
157        self.to_string()
158    }
159}
160
161impl fmt::Display for Ticker {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(f, "{}/{}", self.underlying.canonical(), self.quote)
164    }
165}
166
167impl FromStr for Ticker {
168    type Err = TickerError;
169
170    /// Parse a `BASE/QUOTE` string into a Ticker.
171    ///
172    /// Infers asset class heuristically:
173    /// - Crypto quotes (USDT, USDC, BTC, ETH, etc.) → Crypto
174    /// - USD/fiat quotes with crypto base (BTC, ETH, SOL, etc.) → Crypto
175    /// - USD/fiat quotes with non-crypto base → Equity
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        let Some((base, quote)) = s.split_once('/') else {
178            return Err(TickerError::InvalidFormat(s.to_string()));
179        };
180
181        if base.is_empty() || quote.is_empty() {
182            return Err(TickerError::InvalidFormat(s.to_string()));
183        }
184
185        if quote.contains('/') {
186            return Err(TickerError::InvalidFormat(s.to_string()));
187        }
188
189        // Infer asset class based on quote and base
190        let asset_class = if is_crypto_quote(quote) || is_crypto_base(base) {
191            AssetClass::Crypto
192        } else {
193            AssetClass::Equity
194        };
195
196        let venue_type = match asset_class {
197            AssetClass::Crypto => VenueType::Cex,
198            AssetClass::Equity => VenueType::TradFi,
199            _ => VenueType::TradFi,
200        };
201
202        let underlying = Underlying::new(base, asset_class);
203        Ok(Ticker::new(underlying, quote, AssetType::Spot, venue_type))
204    }
205}
206
207// ── Symbol → Ticker conversion ─────────────────────────────────────────
208
209use crate::Symbol;
210
211impl From<&Symbol> for Ticker {
212    /// Convert a [`Symbol`] to a [`Ticker`], inferring asset class and venue.
213    ///
214    /// - Crypto pairs (USDT, BTC, etc. quote/base) → `Crypto` + `Cex`
215    /// - Everything else → `Equity` + `TradFi`
216    fn from(symbol: &Symbol) -> Self {
217        let (asset_class, venue_type) = if symbol.is_crypto() {
218            (AssetClass::Crypto, VenueType::Cex)
219        } else {
220            (AssetClass::Equity, VenueType::TradFi)
221        };
222        let underlying = Underlying::new(symbol.base(), asset_class);
223        Ticker::new(underlying, symbol.quote(), AssetType::Spot, venue_type)
224    }
225}
226
227#[cfg(test)]
228#[path = "taxonomy_tests.rs"]
229mod tests;