use crate::core::types::{AccountType, ExchangeId, Symbol};
#[derive(Debug, thiserror::Error)]
pub enum NormalizerError {
#[error("unknown exchange: {0:?}")]
UnknownExchange(ExchangeId),
#[error("invalid format for {exchange:?}: '{raw}'")]
InvalidFormat { exchange: ExchangeId, raw: String },
#[error("account type {account_type:?} not supported for {exchange:?}")]
UnsupportedAccountType {
exchange: ExchangeId,
account_type: AccountType,
},
#[error("symbol requires full instrument name (e.g. Deribit options): {msg}")]
RequiresRawInstrument { msg: String },
}
pub struct SymbolNormalizer;
impl SymbolNormalizer {
pub fn to_exchange(
id: ExchangeId,
sym: &Symbol,
account_type: AccountType,
) -> Result<String, NormalizerError> {
match id {
ExchangeId::Binance => binance::to_exchange(sym, account_type),
ExchangeId::Bybit => bybit::to_exchange(sym, account_type),
ExchangeId::OKX => okx::to_exchange(sym, account_type),
ExchangeId::KuCoin => kucoin::to_exchange(sym, account_type),
ExchangeId::Kraken => kraken::to_exchange(sym, account_type),
ExchangeId::Coinbase => coinbase::to_exchange(sym, account_type),
ExchangeId::GateIO => gateio::to_exchange(sym, account_type),
ExchangeId::Gemini => gemini::to_exchange(sym, account_type),
ExchangeId::MEXC => mexc::to_exchange(sym, account_type),
ExchangeId::HTX => htx::to_exchange(sym, account_type),
ExchangeId::Bitget => bitget::to_exchange(sym, account_type),
ExchangeId::BingX => bingx::to_exchange(sym, account_type),
ExchangeId::CryptoCom => crypto_com::to_exchange(sym, account_type),
ExchangeId::Upbit => upbit::to_exchange(sym, account_type),
ExchangeId::Bitfinex => bitfinex::to_exchange(sym, account_type),
ExchangeId::Bitstamp => bitstamp::to_exchange(sym, account_type),
ExchangeId::Deribit => deribit::to_exchange(sym, account_type),
ExchangeId::HyperLiquid => hyperliquid::to_exchange(sym, account_type),
ExchangeId::Dydx => dydx::to_exchange(sym, account_type),
ExchangeId::Lighter => lighter::to_exchange(sym, account_type),
ExchangeId::Polymarket => polymarket::to_exchange(sym, account_type),
ExchangeId::Moex => moex::to_exchange(sym, account_type),
ExchangeId::CryptoCompare => cryptocompare::to_exchange(sym, account_type),
other => Err(NormalizerError::UnknownExchange(other)),
}
}
pub fn from_exchange(
id: ExchangeId,
raw: &str,
account_type: AccountType,
) -> Result<Symbol, NormalizerError> {
match id {
ExchangeId::Binance => binance::from_exchange(raw, account_type),
ExchangeId::Bybit => bybit::from_exchange(raw, account_type),
ExchangeId::OKX => okx::from_exchange(raw, account_type),
ExchangeId::KuCoin => kucoin::from_exchange(raw, account_type),
ExchangeId::Kraken => kraken::from_exchange(raw, account_type),
ExchangeId::Coinbase => coinbase::from_exchange(raw, account_type),
ExchangeId::GateIO => gateio::from_exchange(raw, account_type),
ExchangeId::Gemini => gemini::from_exchange(raw, account_type),
ExchangeId::MEXC => mexc::from_exchange(raw, account_type),
ExchangeId::HTX => htx::from_exchange(raw, account_type),
ExchangeId::Bitget => bitget::from_exchange(raw, account_type),
ExchangeId::BingX => bingx::from_exchange(raw, account_type),
ExchangeId::CryptoCom => crypto_com::from_exchange(raw, account_type),
ExchangeId::Upbit => upbit::from_exchange(raw, account_type),
ExchangeId::Bitfinex => bitfinex::from_exchange(raw, account_type),
ExchangeId::Bitstamp => bitstamp::from_exchange(raw, account_type),
ExchangeId::Deribit => deribit::from_exchange(raw, account_type),
ExchangeId::HyperLiquid => hyperliquid::from_exchange(raw, account_type),
ExchangeId::Dydx => dydx::from_exchange(raw, account_type),
ExchangeId::Lighter => lighter::from_exchange(raw, account_type),
ExchangeId::Polymarket => polymarket::from_exchange(raw, account_type),
ExchangeId::Moex => moex::from_exchange(raw, account_type),
ExchangeId::CryptoCompare => cryptocompare::from_exchange(raw, account_type),
other => Err(NormalizerError::UnknownExchange(other)),
}
}
pub fn is_valid_for(id: ExchangeId, raw: &str, account_type: AccountType) -> bool {
match id {
ExchangeId::Binance => binance::is_valid_for(raw, account_type),
ExchangeId::Bybit => bybit::is_valid_for(raw, account_type),
ExchangeId::OKX => okx::is_valid_for(raw, account_type),
ExchangeId::KuCoin => kucoin::is_valid_for(raw, account_type),
ExchangeId::Kraken => kraken::is_valid_for(raw, account_type),
ExchangeId::Coinbase => coinbase::is_valid_for(raw, account_type),
ExchangeId::GateIO => gateio::is_valid_for(raw, account_type),
ExchangeId::Gemini => gemini::is_valid_for(raw, account_type),
ExchangeId::MEXC => mexc::is_valid_for(raw, account_type),
ExchangeId::HTX => htx::is_valid_for(raw, account_type),
ExchangeId::Bitget => bitget::is_valid_for(raw, account_type),
ExchangeId::BingX => bingx::is_valid_for(raw, account_type),
ExchangeId::CryptoCom => crypto_com::is_valid_for(raw, account_type),
ExchangeId::Upbit => upbit::is_valid_for(raw, account_type),
ExchangeId::Bitfinex => bitfinex::is_valid_for(raw, account_type),
ExchangeId::Bitstamp => bitstamp::is_valid_for(raw, account_type),
ExchangeId::Deribit => deribit::is_valid_for(raw, account_type),
ExchangeId::HyperLiquid => hyperliquid::is_valid_for(raw, account_type),
ExchangeId::Dydx => dydx::is_valid_for(raw, account_type),
ExchangeId::Lighter => lighter::is_valid_for(raw, account_type),
ExchangeId::Polymarket => polymarket::is_valid_for(raw, account_type),
ExchangeId::Moex => moex::is_valid_for(raw, account_type),
ExchangeId::CryptoCompare => cryptocompare::is_valid_for(raw, account_type),
_ => false,
}
}
}
mod binance {
use super::*;
const QUOTE_SUFFIXES: &[&str] = &[
"USDT", "USDC", "BUSD", "TUSD", "USDP",
"BTC", "ETH", "BNB", "XRP", "DOGE",
"AUD", "BRL", "EUR", "GBP", "RUB", "TRY", "UAH",
"USD",
];
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
match account_type {
AccountType::Options => Ok(format!("{}USD_PERP", base)),
_ => Ok(format!("{}{}", base, quote)),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let upper = raw.to_uppercase();
if let Some(stripped) = upper.strip_suffix("_PERP") {
if let Some(base) = stripped.strip_suffix("USD") {
return Ok(Symbol::new(base, "USD"));
}
return Ok(Symbol::new(stripped, "USD"));
}
if let Some(pos) = upper.rfind('_') {
let pair = &upper[..pos];
if let Some(sym) = split_by_suffix(pair) {
return Ok(sym);
}
}
split_by_suffix(&upper).ok_or_else(|| NormalizerError::InvalidFormat {
exchange: ExchangeId::Binance,
raw: raw.to_string(),
})
}
fn split_by_suffix(upper: &str) -> Option<Symbol> {
for &suffix in QUOTE_SUFFIXES {
if upper.ends_with(suffix) && upper.len() > suffix.len() {
let base = &upper[..upper.len() - suffix.len()];
if !base.is_empty() {
return Some(Symbol::new(base, suffix));
}
}
}
None
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
}
mod bybit {
use super::*;
const QUOTE_SUFFIXES: &[&str] = &["USDT", "USDC", "BUSD", "DAI", "BTC", "ETH", "BNB", "USD"];
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() || sym.quote.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bybit,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
Ok(format!("{}{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let upper = raw.to_uppercase();
for &suffix in QUOTE_SUFFIXES {
if upper.ends_with(suffix) && upper.len() > suffix.len() {
let base = &upper[..upper.len() - suffix.len()];
if !base.is_empty() {
return Ok(Symbol::new(base, suffix));
}
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bybit,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric())
}
}
mod okx {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}-{}-SWAP", base, quote))
}
_ => Ok(format!("{}-{}", base, quote)),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let parts: Vec<&str> = raw.split('-').collect();
if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::OKX,
raw: raw.to_string(),
});
}
Ok(Symbol::new(parts[0], parts[1]))
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.contains('-')
}
}
mod kucoin {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
match account_type {
AccountType::Spot | AccountType::Margin => {
Ok(format!("{}-{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
AccountType::FuturesCross | AccountType::FuturesIsolated => {
let base = if sym.base.to_uppercase() == "BTC" { "XBT" } else { sym.base.as_str() };
match sym.quote.to_uppercase().as_str() {
"USDT" => Ok(format!("{}USDTM", base)),
"USD" => Ok(format!("{}USDM", base)),
other => Ok(format!("{}{}M", base, other)),
}
}
other => Err(NormalizerError::UnsupportedAccountType {
exchange: ExchangeId::KuCoin,
account_type: other,
}),
}
}
pub(super) fn from_exchange(raw: &str, account_type: AccountType) -> Result<Symbol, NormalizerError> {
match account_type {
AccountType::Spot | AccountType::Margin => {
if let Some((base, quote)) = raw.split_once('-') {
return Ok(Symbol::new(base, quote));
}
Err(NormalizerError::InvalidFormat { exchange: ExchangeId::KuCoin, raw: raw.to_string() })
}
AccountType::FuturesCross | AccountType::FuturesIsolated => {
let s = raw.strip_suffix('M').unwrap_or(raw);
let (base_raw, quote) = if let Some(b) = s.strip_suffix("USDT") {
(b, "USDT")
} else if let Some(b) = s.strip_suffix("USD") {
(b, "USD")
} else {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::KuCoin,
raw: raw.to_string(),
});
};
let base = if base_raw.eq_ignore_ascii_case("XBT") { "BTC" } else { base_raw };
Ok(Symbol::new(base, quote))
}
other => Err(NormalizerError::UnsupportedAccountType {
exchange: ExchangeId::KuCoin,
account_type: other,
}),
}
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
}
mod kraken {
use super::*;
fn to_xbt(base: &str) -> &str {
if base.eq_ignore_ascii_case("BTC") { "XBT" } else { base }
}
fn from_xbt(base: &str) -> &str {
if base.eq_ignore_ascii_case("XBT") { "BTC" } else { base }
}
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = to_xbt(sym.base.as_str()).to_uppercase();
let quote = sym.quote.to_uppercase();
match account_type {
AccountType::Spot | AccountType::Margin => Ok(format!("{}{}", base, quote)),
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("PI_{}{}", base, quote))
}
other => Err(NormalizerError::UnsupportedAccountType {
exchange: ExchangeId::Kraken,
account_type: other,
}),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let inner = if let Some(rest) = raw.strip_prefix("PI_").or_else(|| raw.strip_prefix("PF_")) {
rest
} else {
raw
};
let cleaned = inner
.strip_prefix("XX")
.or_else(|| inner.strip_prefix('X'))
.unwrap_or(inner);
for (fiat_with_z, fiat_canonical) in [
("ZUSD", "USD"),
("ZEUR", "EUR"),
("ZGBP", "GBP"),
("ZJPY", "JPY"),
("ZCAD", "CAD"),
] {
if let Some(base_raw) = cleaned.strip_suffix(fiat_with_z) {
if !base_raw.is_empty() {
return Ok(Symbol::new(from_xbt(base_raw), fiat_canonical));
}
}
}
for (plain_suffix, canonical) in [
("USDT", "USDT"), ("USDC", "USDC"),
("USD", "USD"), ("EUR", "EUR"), ("GBP", "GBP"),
("JPY", "JPY"), ("CAD", "CAD"),
] {
if let Some(base_raw) = cleaned.strip_suffix(plain_suffix) {
if !base_raw.is_empty() {
return Ok(Symbol::new(from_xbt(base_raw), canonical));
}
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Kraken,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
}
mod coinbase {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}-PERP", base))
}
_ => Ok(format!("{}-{}", base, quote)),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
match raw.split_once('-') {
Some((base, quote)) if !base.is_empty() && !quote.is_empty() => {
Ok(Symbol::new(base, quote))
}
_ => Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Coinbase,
raw: raw.to_string(),
}),
}
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
match raw.split_once('-') {
Some((base, quote)) => !base.is_empty() && !quote.is_empty(),
None => false,
}
}
}
mod gateio {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
Ok(format!("{}_{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some((base, quote)) = raw.split_once('_') {
return Ok(Symbol::new(base, quote));
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::GateIO,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
if let Some((base, quote)) = raw.split_once('_') {
!base.is_empty()
&& !quote.is_empty()
&& base.chars().all(|c| c.is_alphanumeric())
&& quote.chars().all(|c| c.is_alphanumeric())
} else {
false
}
}
}
mod gemini {
use super::*;
const QUOTE_SUFFIXES: &[&str] = &["usdt", "gusd", "usdc", "usd", "btc", "eth"];
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_lowercase();
let quote = sym.quote.to_lowercase();
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}gusdperp", base))
}
_ => Ok(format!("{}{}", base, quote)),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let lower = raw.to_lowercase();
if let Some(without_perp) = lower.strip_suffix("perp") {
let base = if let Some(b) = without_perp.strip_suffix("gusd") {
b.to_uppercase()
} else {
without_perp.to_uppercase()
};
return Ok(Symbol::new(&base, "USD"));
}
for &suffix in QUOTE_SUFFIXES {
if lower.ends_with(suffix) && lower.len() > suffix.len() {
let base = &lower[..lower.len() - suffix.len()];
if !base.is_empty() {
return Ok(Symbol::new(&base.to_uppercase(), &suffix.to_uppercase()));
}
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Gemini,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
}
mod mexc {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
match account_type {
AccountType::Spot | AccountType::Margin => {
Ok(format!("{}{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}_{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
other => Err(NormalizerError::UnsupportedAccountType {
exchange: ExchangeId::MEXC,
account_type: other,
}),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some((base, quote)) = raw.split_once('_') {
return Ok(Symbol::new(base, quote));
}
const QUOTES: &[&str] = &["USDT", "USDC", "BUSD", "BTC", "ETH", "BNB", "USD"];
for q in QUOTES {
if raw.ends_with(q) && raw.len() > q.len() {
let base = &raw[..raw.len() - q.len()];
if !base.is_empty() {
return Ok(Symbol::new(base, *q));
}
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::MEXC,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, account_type: AccountType) -> bool {
if raw.is_empty() {
return false;
}
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
if let Some((base, quote)) = raw.split_once('_') {
!base.is_empty()
&& !quote.is_empty()
&& !quote.contains('_')
&& base.chars().all(|c| c.is_ascii_alphanumeric())
&& quote.chars().all(|c| c.is_ascii_alphanumeric())
} else {
false
}
}
_ => raw.chars().all(|c| c.is_ascii_alphanumeric()),
}
}
}
mod htx {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}-{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
_ => Ok(format!("{}{}", sym.base.to_lowercase(), sym.quote.to_lowercase())),
}
}
pub(super) fn from_exchange(raw: &str, account_type: AccountType) -> Result<Symbol, NormalizerError> {
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
raw.split_once('-')
.map(|(base, quote)| Symbol::new(base, quote))
.ok_or_else(|| NormalizerError::InvalidFormat {
exchange: ExchangeId::HTX,
raw: raw.to_string(),
})
}
_ => {
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::HTX,
raw: raw.to_string(),
})
}
}
}
pub(super) fn is_valid_for(raw: &str, account_type: AccountType) -> bool {
if raw.is_empty() {
return false;
}
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
let dash_count = raw.chars().filter(|&c| c == '-').count();
dash_count == 1 && raw.chars().all(|c| c.is_alphanumeric() || c == '-')
}
_ => raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
}
}
}
mod bitget {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
Ok(format!("{}{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
const KNOWN_QUOTES: &[&str] = &[
"USDT", "USDC", "BUSD", "TUSD", "FDUSD",
"BTC", "ETH", "BNB", "USD",
];
let upper = raw.to_uppercase();
for quote in KNOWN_QUOTES {
if upper.ends_with(quote) && upper.len() > quote.len() {
let base = &upper[..upper.len() - quote.len()];
return Ok(Symbol::new(base, *quote));
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitget,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric())
}
}
mod bingx {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() || sym.quote.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::BingX,
raw: format!("{}-{}", sym.base, sym.quote),
});
}
Ok(format!("{}-{}", sym.base.to_uppercase(), sym.quote.to_uppercase()))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some((base, quote)) = raw.split_once('-') {
if base.is_empty() || quote.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::BingX,
raw: raw.to_string(),
});
}
return Ok(Symbol::new(base, quote));
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::BingX,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
if raw.is_empty() {
return false;
}
if let Some((base, quote)) = raw.split_once('-') {
!base.is_empty() && !quote.is_empty() && !quote.contains('-')
} else {
false
}
}
}
mod crypto_com {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
match account_type {
AccountType::FuturesCross | AccountType::FuturesIsolated => {
Ok(format!("{}USD-PERP", base))
}
_ => {
let quote = sym.quote.to_uppercase();
Ok(format!("{}_{}", base, quote))
}
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some(stripped) = raw.strip_suffix("-PERP") {
for &q in &["USDT", "USDC", "USD"] {
if stripped.ends_with(q) && stripped.len() > q.len() {
return Ok(Symbol::new(&stripped[..stripped.len() - q.len()], q));
}
}
return Err(NormalizerError::InvalidFormat { exchange: ExchangeId::CryptoCom, raw: raw.to_string() });
}
raw.split_once('_')
.map(|(base, quote)| Symbol::new(base, quote))
.ok_or_else(|| NormalizerError::InvalidFormat { exchange: ExchangeId::CryptoCom, raw: raw.to_string() })
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
if raw.is_empty() { return false; }
if raw.ends_with("-PERP") {
let prefix = &raw[..raw.len() - 5];
return !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric());
}
raw.split_once('_').map_or(false, |(b, q)|
!b.is_empty() && !q.is_empty()
&& b.chars().all(|c| c.is_ascii_alphanumeric())
&& q.chars().all(|c| c.is_ascii_alphanumeric())
)
}
}
mod upbit {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() || sym.quote.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Upbit,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
Ok(format!("{}-{}", sym.quote.to_uppercase(), sym.base.to_uppercase()))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
match raw.split_once('-') {
Some((quote, base)) if !quote.is_empty() && !base.is_empty() => {
Ok(Symbol::new(base, quote))
}
_ => Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Upbit,
raw: raw.to_string(),
}),
}
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
match raw.split_once('-') {
Some((quote, base)) => {
!quote.is_empty()
&& !base.is_empty()
&& quote.chars().all(|c| c.is_ascii_alphanumeric())
&& base.chars().all(|c| c.is_ascii_alphanumeric())
}
None => false,
}
}
}
mod bitfinex {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitfinex,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
if account_type == AccountType::Lending {
return Ok(format!("f{}", sym.base.to_uppercase()));
}
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
if base.len() > 3 || quote.len() > 3 {
Ok(format!("t{}:{}", base, quote))
} else {
Ok(format!("t{}{}", base, quote))
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some(currency) = raw.strip_prefix('f') {
if !currency.is_empty() && currency.chars().all(|c| c.is_ascii_alphanumeric()) {
return Ok(Symbol::new(currency, ""));
}
}
if let Some(pair) = raw.strip_prefix('t') {
if pair.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitfinex,
raw: raw.to_string(),
});
}
if let Some((base, quote)) = pair.split_once(':') {
if !base.is_empty() && !quote.is_empty() {
return Ok(Symbol::new(base, quote));
}
}
let len = pair.len();
const KNOWN_QUOTES: &[&str] = &[
"USDT", "USDC", "BUSD", "TUSD", "USDP",
"BTC", "ETH", "EUR", "GBP", "USD",
];
for &q in KNOWN_QUOTES {
if pair.ends_with(q) && len > q.len() {
let base = &pair[..len - q.len()];
if !base.is_empty() {
return Ok(Symbol::new(base, q));
}
}
}
if len == 6 {
return Ok(Symbol::new(&pair[..3], &pair[3..]));
}
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitfinex,
raw: raw.to_string(),
});
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitfinex,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
if raw.is_empty() {
return false;
}
let starts_ok = raw.starts_with('t') || raw.starts_with('f');
if !starts_ok {
return false;
}
let rest = &raw[1..];
!rest.is_empty()
&& rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == ':')
}
}
mod bitstamp {
use super::*;
const QUOTE_SUFFIXES: &[&str] = &["usdt", "usdc", "eur", "gbp", "pax", "usd", "btc", "eth"];
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
match account_type {
AccountType::Spot | AccountType::Margin => {
Ok(format!("{}{}", sym.base.to_lowercase(), sym.quote.to_lowercase()))
}
other => Err(NormalizerError::UnsupportedAccountType {
exchange: ExchangeId::Bitstamp,
account_type: other,
}),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
let lower = raw.to_lowercase();
for &suffix in QUOTE_SUFFIXES {
if lower.ends_with(suffix) && lower.len() > suffix.len() {
let base = &lower[..lower.len() - suffix.len()];
if !base.is_empty() {
return Ok(Symbol::new(&base.to_uppercase(), &suffix.to_uppercase()));
}
}
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Bitstamp,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
}
mod deribit {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, account_type: AccountType) -> Result<String, NormalizerError> {
if let Some(r) = sym.raw() {
return Ok(r.to_string());
}
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
match account_type {
AccountType::Options => Err(NormalizerError::RequiresRawInstrument {
msg: "Deribit options require concrete instrument_name like BTC-30MAY26-50000-C use Symbol::with_raw(base, quote, instrument)".to_string(),
}),
AccountType::Spot => Ok(format!("{}-{}", base, quote)),
AccountType::FuturesCross | AccountType::FuturesIsolated | AccountType::Margin => {
match quote.as_str() {
"" | "USD" | "PERP" => Ok(format!("{}-PERPETUAL", base)),
"USDC" => Ok(format!("{}_USDC-PERPETUAL", base)),
"USDT" => Ok(format!("{}_USDT-PERPETUAL", base)),
other => Ok(format!("{}-{}", base, other)),
}
}
_ => Ok(format!("{}-{}", base, quote)),
}
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if raw.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Deribit,
raw: raw.to_string(),
});
}
if let Some(pair) = raw.strip_suffix("-PERPETUAL") {
if let Some((base, quote)) = pair.split_once('_') {
return Ok(Symbol::with_raw(base, quote, raw.to_string()));
}
return Ok(Symbol::with_raw(pair, "USD", raw.to_string()));
}
let parts: Vec<&str> = raw.splitn(4, '-').collect();
match parts.len() {
4 => Ok(Symbol::with_raw(parts[0], "USD", raw.to_string())),
2 => {
let second = parts[1];
if second.chars().next().map_or(false, |c| c.is_ascii_digit())
|| is_month_prefix(second)
{
Ok(Symbol::with_raw(parts[0], "USD", raw.to_string()))
} else {
Ok(Symbol::new(parts[0], second))
}
}
_ => Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Deribit,
raw: raw.to_string(),
}),
}
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty()
&& (raw.contains('-') || raw.contains('_'))
&& raw.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& raw.chars().next().map_or(false, |c| c.is_ascii_uppercase())
}
fn is_month_prefix(s: &str) -> bool {
const MONTHS: &[&str] = &[
"JAN", "FEB", "MAR", "APR", "MAY", "JUN",
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
];
let upper = s.to_uppercase();
MONTHS.iter().any(|m| upper.starts_with(m))
}
}
mod hyperliquid {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::HyperLiquid,
raw: sym.base.clone(),
});
}
Ok(sym.base.to_uppercase())
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if raw.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::HyperLiquid,
raw: raw.to_string(),
});
}
Ok(Symbol::new(&raw.to_uppercase(), "USD"))
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric())
}
}
mod dydx {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
if base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Dydx,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
Ok(format!("{}-USD", base))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if let Some((base, quote)) = raw.split_once('-') {
return Ok(Symbol::new(base, quote));
}
Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Dydx,
raw: raw.to_string(),
})
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
if let Some((base, quote)) = raw.split_once('-') {
!base.is_empty() && !quote.is_empty()
} else {
false
}
}
}
mod lighter {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Lighter,
raw: sym.base.clone(),
});
}
Ok(sym.base.to_uppercase())
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if raw.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Lighter,
raw: raw.to_string(),
});
}
Ok(Symbol::new(raw, ""))
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty()
}
}
mod polymarket {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
if sym.base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Polymarket,
raw: sym.base.clone(),
});
}
Ok(sym.base.to_lowercase())
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if raw.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Polymarket,
raw: raw.to_string(),
});
}
Ok(Symbol { base: raw.to_string(), quote: String::new(), raw: None })
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty()
}
}
mod moex {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
if base.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Moex,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
Ok(base)
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
if raw.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::Moex,
raw: raw.to_string(),
});
}
Ok(Symbol::new(&raw.to_uppercase(), "RUB"))
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
!raw.is_empty() && raw.chars().all(|c| c.is_ascii_alphanumeric())
}
}
mod cryptocompare {
use super::*;
pub(super) fn to_exchange(sym: &Symbol, _account_type: AccountType) -> Result<String, NormalizerError> {
let base = sym.base.to_uppercase();
let quote = sym.quote.to_uppercase();
if base.is_empty() || quote.is_empty() {
return Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::CryptoCompare,
raw: format!("{}/{}", sym.base, sym.quote),
});
}
Ok(format!("{}-{}", base, quote))
}
pub(super) fn from_exchange(raw: &str, _account_type: AccountType) -> Result<Symbol, NormalizerError> {
match raw.split_once('-') {
Some((b, q)) if !b.is_empty() && !q.is_empty() => Ok(Symbol::new(b, q)),
_ => Err(NormalizerError::InvalidFormat {
exchange: ExchangeId::CryptoCompare,
raw: raw.to_string(),
}),
}
}
pub(super) fn is_valid_for(raw: &str, _account_type: AccountType) -> bool {
raw.split_once('-').map_or(false, |(b, q)| !b.is_empty() && !q.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn btc_usdt() -> Symbol {
Symbol::new("BTC", "USDT")
}
fn all_exchanges() -> Vec<ExchangeId> {
vec![
ExchangeId::Binance,
ExchangeId::Bybit,
ExchangeId::OKX,
ExchangeId::KuCoin,
ExchangeId::Kraken,
ExchangeId::Coinbase,
ExchangeId::GateIO,
ExchangeId::Gemini,
ExchangeId::MEXC,
ExchangeId::HTX,
ExchangeId::Bitget,
ExchangeId::BingX,
ExchangeId::CryptoCom,
ExchangeId::Upbit,
ExchangeId::Bitfinex,
ExchangeId::Bitstamp,
ExchangeId::Deribit,
ExchangeId::HyperLiquid,
ExchangeId::Dydx,
ExchangeId::Lighter,
ExchangeId::Polymarket,
ExchangeId::Moex,
]
}
#[test]
fn to_exchange_all_arms_produce_nonempty() {
let sym = btc_usdt();
for id in all_exchanges() {
let result = SymbolNormalizer::to_exchange(id, &sym, AccountType::Spot);
let raw = result.unwrap_or_else(|_| "DERIBIT_OPTIONS_SKIP".to_string());
assert!(!raw.is_empty(), "to_exchange({id:?}) returned empty string");
}
}
#[test]
fn gemini_normalizer_spot() {
let sym = Symbol::new("BTC", "USD");
let raw = SymbolNormalizer::to_exchange(ExchangeId::Gemini, &sym, AccountType::Spot).unwrap();
assert_eq!(raw, "btcusd");
let parsed = SymbolNormalizer::from_exchange(ExchangeId::Gemini, "btcusd", AccountType::Spot).unwrap();
assert_eq!(parsed.base.to_uppercase(), "BTC");
assert_eq!(parsed.quote.to_uppercase(), "USD");
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Gemini, "btcusd", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Gemini, "BTCUSDT", AccountType::Spot));
}
#[test]
fn gemini_normalizer_perp() {
let sym = Symbol::new("BTC", "USD");
let raw = SymbolNormalizer::to_exchange(ExchangeId::Gemini, &sym, AccountType::FuturesCross).unwrap();
assert_eq!(raw, "btcgusdperp");
let parsed = SymbolNormalizer::from_exchange(ExchangeId::Gemini, "btcgusdperp", AccountType::FuturesCross).unwrap();
assert_eq!(parsed.base.to_uppercase(), "BTC");
assert_eq!(parsed.quote.to_uppercase(), "USD");
}
#[test]
fn upbit_normalizer_reversed_format() {
let btc_krw = Symbol::new("BTC", "KRW");
let raw = SymbolNormalizer::to_exchange(ExchangeId::Upbit, &btc_krw, AccountType::Spot).unwrap();
assert_eq!(raw, "KRW-BTC");
let eth_usdt = Symbol::new("ETH", "USDT");
let raw2 = SymbolNormalizer::to_exchange(ExchangeId::Upbit, ð_usdt, AccountType::Spot).unwrap();
assert_eq!(raw2, "USDT-ETH");
let parsed = SymbolNormalizer::from_exchange(ExchangeId::Upbit, "KRW-BTC", AccountType::Spot).unwrap();
assert_eq!(parsed.base.to_uppercase(), "BTC");
assert_eq!(parsed.quote.to_uppercase(), "KRW");
let parsed2 = SymbolNormalizer::from_exchange(ExchangeId::Upbit, "USDT-ETH", AccountType::Spot).unwrap();
assert_eq!(parsed2.base.to_uppercase(), "ETH");
assert_eq!(parsed2.quote.to_uppercase(), "USDT");
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Upbit, "KRW-BTC", AccountType::Spot));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Upbit, "USDT-ETH", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Upbit, "BTCUSDT", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Upbit, "", AccountType::Spot));
}
#[test]
fn upbit_normalizer_roundtrip() {
let sym = Symbol::new("BTC", "KRW");
let raw = SymbolNormalizer::to_exchange(ExchangeId::Upbit, &sym, AccountType::Spot).unwrap();
let back = SymbolNormalizer::from_exchange(ExchangeId::Upbit, &raw, AccountType::Spot).unwrap();
assert_eq!(back.base.to_uppercase(), sym.base.to_uppercase());
assert_eq!(back.quote.to_uppercase(), sym.quote.to_uppercase());
}
#[test]
fn unknown_exchange_returns_err() {
let sym = btc_usdt();
let result = SymbolNormalizer::to_exchange(
ExchangeId::Custom(9999),
&sym,
AccountType::Spot,
);
assert!(result.is_err());
}
#[test]
fn bitfinex_to_exchange_short_pairs() {
let btc_usd = Symbol::new("BTC", "USD");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &btc_usd, AccountType::Spot).unwrap(),
"tBTCUSD"
);
let eth_usd = Symbol::new("ETH", "USD");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, ð_usd, AccountType::Spot).unwrap(),
"tETHUSD"
);
let eth_btc = Symbol::new("ETH", "BTC");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, ð_btc, AccountType::Spot).unwrap(),
"tETHBTC"
);
}
#[test]
fn bitfinex_to_exchange_long_pairs_use_colon() {
let btc_usdt = Symbol::new("BTC", "USDT");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &btc_usdt, AccountType::Spot).unwrap(),
"tBTC:USDT"
);
let btc_ust = Symbol::new("BTC", "UST");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &btc_ust, AccountType::Spot).unwrap(),
"tBTCUST"
);
let link_usd = Symbol::new("LINK", "USD");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &link_usd, AccountType::Spot).unwrap(),
"tLINK:USD"
);
}
#[test]
fn bitfinex_to_exchange_funding() {
let usd = Symbol::new("USD", "");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &usd, AccountType::Lending).unwrap(),
"fUSD"
);
let btc = Symbol::new("BTC", "");
assert_eq!(
SymbolNormalizer::to_exchange(ExchangeId::Bitfinex, &btc, AccountType::Lending).unwrap(),
"fBTC"
);
}
#[test]
fn bitfinex_from_exchange_no_separator() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "tBTCUSD", AccountType::Spot).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USD");
let sym2 = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "tETHUSD", AccountType::Spot).unwrap();
assert_eq!(sym2.base, "ETH");
assert_eq!(sym2.quote, "USD");
}
#[test]
fn bitfinex_from_exchange_colon_separator() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "tBTC:USDT", AccountType::Spot).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USDT");
let sym2 = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "tBTC:UST", AccountType::Spot).unwrap();
assert_eq!(sym2.base, "BTC");
assert_eq!(sym2.quote, "UST");
}
#[test]
fn bitfinex_from_exchange_funding() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "fUSD", AccountType::Lending).unwrap();
assert_eq!(sym.base, "USD");
assert_eq!(sym.quote, "");
let sym2 = SymbolNormalizer::from_exchange(ExchangeId::Bitfinex, "fBTC", AccountType::Lending).unwrap();
assert_eq!(sym2.base, "BTC");
}
#[test]
fn bitfinex_is_valid_for() {
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "tBTCUSD", AccountType::Spot));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "tETHUSD", AccountType::Spot));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "tBTC:USDT", AccountType::Spot));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "fUSD", AccountType::Lending));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "BTCUSD", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Bitfinex, "tbtcusd", AccountType::Spot));
}
#[test]
fn deribit_to_exchange_coin_perp() {
let sym = Symbol::new("BTC", "USD");
let r = SymbolNormalizer::to_exchange(ExchangeId::Deribit, &sym, AccountType::FuturesCross).unwrap();
assert_eq!(r, "BTC-PERPETUAL");
let eth = Symbol::new("ETH", "");
let r2 = SymbolNormalizer::to_exchange(ExchangeId::Deribit, ð, AccountType::FuturesCross).unwrap();
assert_eq!(r2, "ETH-PERPETUAL");
}
#[test]
fn deribit_to_exchange_usdc_perp() {
let sol = Symbol::new("SOL", "USDC");
let r = SymbolNormalizer::to_exchange(ExchangeId::Deribit, &sol, AccountType::FuturesCross).unwrap();
assert_eq!(r, "SOL_USDC-PERPETUAL");
}
#[test]
fn deribit_to_exchange_spot() {
let sym = Symbol::new("BTC", "USDC");
let r = SymbolNormalizer::to_exchange(ExchangeId::Deribit, &sym, AccountType::Spot).unwrap();
assert_eq!(r, "BTC-USDC");
}
#[test]
fn deribit_options_without_raw_returns_err() {
let sym = Symbol::new("BTC", "USD");
let result = SymbolNormalizer::to_exchange(ExchangeId::Deribit, &sym, AccountType::Options);
assert!(result.is_err());
match result.unwrap_err() {
NormalizerError::RequiresRawInstrument { msg } => {
assert!(msg.contains("instrument_name"), "got: {}", msg);
}
other => panic!("expected RequiresRawInstrument, got {:?}", other),
}
}
#[test]
fn deribit_options_with_raw_passthrough() {
let sym = Symbol::with_raw("BTC", "USD", "BTC-30MAY26-50000-C".to_string());
let r = SymbolNormalizer::to_exchange(ExchangeId::Deribit, &sym, AccountType::Options).unwrap();
assert_eq!(r, "BTC-30MAY26-50000-C");
}
#[test]
fn deribit_from_exchange_perps() {
let btc = SymbolNormalizer::from_exchange(ExchangeId::Deribit, "BTC-PERPETUAL", AccountType::FuturesCross).unwrap();
assert_eq!(btc.base, "BTC");
assert_eq!(btc.quote, "USD");
assert_eq!(btc.raw().unwrap(), "BTC-PERPETUAL");
let sol = SymbolNormalizer::from_exchange(ExchangeId::Deribit, "SOL_USDC-PERPETUAL", AccountType::FuturesCross).unwrap();
assert_eq!(sol.base, "SOL");
assert_eq!(sol.quote, "USDC");
assert_eq!(sol.raw().unwrap(), "SOL_USDC-PERPETUAL");
}
#[test]
fn deribit_from_exchange_option() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Deribit, "BTC-30MAY26-50000-C", AccountType::Options).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USD");
assert_eq!(sym.raw().unwrap(), "BTC-30MAY26-50000-C");
}
#[test]
fn deribit_from_exchange_dated_future() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Deribit, "BTC-30MAY26", AccountType::FuturesCross).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USD");
assert_eq!(sym.raw().unwrap(), "BTC-30MAY26");
}
#[test]
fn deribit_from_exchange_spot() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Deribit, "BTC-USDC", AccountType::Spot).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USDC");
}
#[test]
fn deribit_is_valid_for() {
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "BTC-PERPETUAL", AccountType::FuturesCross));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "SOL_USDC-PERPETUAL", AccountType::FuturesCross));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "BTC-30MAY26-50000-C", AccountType::Options));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "BTCUSDT", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Deribit, "btc-perpetual", AccountType::FuturesCross));
}
#[test]
fn hyperliquid_normalizer_to_exchange() {
let sym = Symbol::new("BTC", "USD");
let raw = SymbolNormalizer::to_exchange(ExchangeId::HyperLiquid, &sym, AccountType::FuturesCross).unwrap();
assert_eq!(raw, "BTC");
let eth = Symbol::new("eth", "USD");
let raw_eth = SymbolNormalizer::to_exchange(ExchangeId::HyperLiquid, ð, AccountType::FuturesCross).unwrap();
assert_eq!(raw_eth, "ETH");
let sol = Symbol::new("SOL", "USDC");
let raw_sol = SymbolNormalizer::to_exchange(ExchangeId::HyperLiquid, &sol, AccountType::Spot).unwrap();
assert_eq!(raw_sol, "SOL");
}
#[test]
fn dydx_normalizer_to_exchange() {
let btc_usd = Symbol::new("BTC", "USD");
let raw = SymbolNormalizer::to_exchange(ExchangeId::Dydx, &btc_usd, AccountType::FuturesCross).unwrap();
assert_eq!(raw, "BTC-USD");
let btc_usdt = Symbol::new("BTC", "USDT");
let raw2 = SymbolNormalizer::to_exchange(ExchangeId::Dydx, &btc_usdt, AccountType::Spot).unwrap();
assert_eq!(raw2, "BTC-USD");
}
#[test]
fn dydx_normalizer_from_exchange() {
let sym = SymbolNormalizer::from_exchange(ExchangeId::Dydx, "BTC-USD", AccountType::FuturesCross).unwrap();
assert_eq!(sym.base, "BTC");
assert_eq!(sym.quote, "USD");
}
#[test]
fn dydx_normalizer_is_valid_for() {
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Dydx, "BTC-USD", AccountType::FuturesCross));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::Dydx, "ETH-USD", AccountType::FuturesCross));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Dydx, "BTCUSDT", AccountType::Spot));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::Dydx, "", AccountType::Spot));
}
#[test]
fn hyperliquid_normalizer_from_exchange() {
let parsed = SymbolNormalizer::from_exchange(ExchangeId::HyperLiquid, "BTC", AccountType::FuturesCross).unwrap();
assert_eq!(parsed.base, "BTC");
assert_eq!(parsed.quote, "USD");
let parsed_eth = SymbolNormalizer::from_exchange(ExchangeId::HyperLiquid, "eth", AccountType::FuturesCross).unwrap();
assert_eq!(parsed_eth.base, "ETH");
assert_eq!(parsed_eth.quote, "USD");
assert!(SymbolNormalizer::from_exchange(ExchangeId::HyperLiquid, "", AccountType::FuturesCross).is_err());
}
#[test]
fn hyperliquid_normalizer_is_valid_for() {
assert!(SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "BTC", AccountType::FuturesCross));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "ETH", AccountType::FuturesCross));
assert!(SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "SOL", AccountType::FuturesCross));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "", AccountType::FuturesCross));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "BTC-USD", AccountType::FuturesCross));
assert!(!SymbolNormalizer::is_valid_for(ExchangeId::HyperLiquid, "BTC/USD", AccountType::FuturesCross));
}
}