1use 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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub enum Derivative {
76 Perp,
77 Cfd,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum AssetType {
83 Spot,
84 Derivative(Derivative),
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub enum VenueType {
90 TradFi,
91 Cex,
92 Dex,
93}
94
95#[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 #[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 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 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 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 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
207use crate::Symbol;
210
211impl From<&Symbol> for Ticker {
212 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;