use ccxt_core::types::symbol::{ExpiryDate, ParsedSymbol, SymbolMarketType};
const MONTH_NAMES: [&str; 12] = [
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
];
pub struct BybitSymbolConverter;
impl BybitSymbolConverter {
pub fn to_exchange_id(parsed: &ParsedSymbol) -> String {
let base = &parsed.base;
let quote = &parsed.quote;
match parsed.market_type() {
SymbolMarketType::Spot => {
format!("{}{}", base, quote)
}
SymbolMarketType::Swap => {
format!("{}{}", base, quote)
}
SymbolMarketType::Futures => {
if let Some(expiry) = parsed.expiry {
let date_str = Self::format_expiry_date(expiry);
format!("{}{}-{}", base, quote, date_str)
} else {
format!("{}{}", base, quote)
}
}
}
}
fn format_expiry_date(expiry: ExpiryDate) -> String {
let month_name = MONTH_NAMES[(expiry.month - 1) as usize];
format!("{:02}{}{:02}", expiry.day, month_name, expiry.year)
}
fn parse_expiry_date(date_str: &str) -> Result<ExpiryDate, String> {
if date_str.len() != 7 {
return Err(format!("Invalid Bybit date format: {}", date_str));
}
let day: u8 = date_str[0..2]
.parse()
.map_err(|_| format!("Invalid day in expiry: {}", date_str))?;
let month_str = &date_str[2..5];
let month = MONTH_NAMES
.iter()
.position(|&m| m == month_str)
.map(|i| (i + 1) as u8)
.ok_or_else(|| format!("Invalid month in expiry: {}", month_str))?;
let year: u8 = date_str[5..7]
.parse()
.map_err(|_| format!("Invalid year in expiry: {}", date_str))?;
ExpiryDate::new(year, month, day).map_err(|e| format!("Invalid expiry date: {}", e))
}
pub fn from_exchange_id(
exchange_id: &str,
market_type: SymbolMarketType,
settle: Option<&str>,
base: &str,
quote: &str,
) -> Result<ParsedSymbol, String> {
let base = base.to_uppercase();
let quote = quote.to_uppercase();
match market_type {
SymbolMarketType::Spot => Ok(ParsedSymbol::spot(base, quote)),
SymbolMarketType::Swap => {
let settle = settle
.map(str::to_uppercase)
.ok_or_else(|| "Settlement currency required for swap".to_string())?;
Ok(ParsedSymbol::swap(base, quote, settle))
}
SymbolMarketType::Futures => {
let settle = settle
.map(str::to_uppercase)
.ok_or_else(|| "Settlement currency required for futures".to_string())?;
let expiry = Self::extract_expiry_from_exchange_id(exchange_id)?;
Ok(ParsedSymbol::futures(base, quote, settle, expiry))
}
}
}
fn extract_expiry_from_exchange_id(exchange_id: &str) -> Result<ExpiryDate, String> {
if let Some(hyphen_pos) = exchange_id.rfind('-') {
let date_part = &exchange_id[hyphen_pos + 1..];
return Self::parse_expiry_date(date_part);
}
Err(format!(
"Could not extract expiry date from exchange ID: {}",
exchange_id
))
}
pub fn is_futures(exchange_id: &str) -> bool {
if let Some(hyphen_pos) = exchange_id.rfind('-') {
let suffix = &exchange_id[hyphen_pos + 1..];
suffix.len() == 7
&& suffix[0..2].chars().all(|c| c.is_ascii_digit())
&& suffix[5..7].chars().all(|c| c.is_ascii_digit())
} else {
false
}
}
pub fn is_spot_or_perpetual(exchange_id: &str) -> bool {
!exchange_id.contains('-')
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spot_to_exchange_id() {
let symbol = ParsedSymbol::spot("BTC".to_string(), "USDT".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(&symbol), "BTCUSDT");
}
#[test]
fn test_spot_to_exchange_id_various_pairs() {
let eth_usdt = ParsedSymbol::spot("ETH".to_string(), "USDT".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(ð_usdt), "ETHUSDT");
let btc_usdc = ParsedSymbol::spot("BTC".to_string(), "USDC".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(&btc_usdc), "BTCUSDC");
let sol_btc = ParsedSymbol::spot("SOL".to_string(), "BTC".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(&sol_btc), "SOLBTC");
}
#[test]
fn test_linear_swap_to_exchange_id() {
let symbol = ParsedSymbol::linear_swap("BTC".to_string(), "USDT".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(&symbol), "BTCUSDT");
}
#[test]
fn test_inverse_swap_to_exchange_id() {
let symbol = ParsedSymbol::inverse_swap("BTC".to_string(), "USD".to_string());
assert_eq!(BybitSymbolConverter::to_exchange_id(&symbol), "BTCUSD");
}
#[test]
fn test_linear_futures_to_exchange_id() {
let expiry = ExpiryDate::new(24, 12, 31).unwrap();
let symbol = ParsedSymbol::futures(
"BTC".to_string(),
"USDT".to_string(),
"USDT".to_string(),
expiry,
);
assert_eq!(
BybitSymbolConverter::to_exchange_id(&symbol),
"BTCUSDT-31DEC24"
);
}
#[test]
fn test_inverse_futures_to_exchange_id() {
let expiry = ExpiryDate::new(25, 3, 15).unwrap();
let symbol = ParsedSymbol::futures(
"BTC".to_string(),
"USD".to_string(),
"BTC".to_string(),
expiry,
);
assert_eq!(
BybitSymbolConverter::to_exchange_id(&symbol),
"BTCUSD-15MAR25"
);
}
#[test]
fn test_futures_date_formatting() {
let jan = ExpiryDate::new(25, 1, 5).unwrap();
let symbol_jan = ParsedSymbol::futures(
"ETH".to_string(),
"USDT".to_string(),
"USDT".to_string(),
jan,
);
assert_eq!(
BybitSymbolConverter::to_exchange_id(&symbol_jan),
"ETHUSDT-05JAN25"
);
let jun = ExpiryDate::new(24, 6, 28).unwrap();
let symbol_jun = ParsedSymbol::futures(
"ETH".to_string(),
"USDT".to_string(),
"USDT".to_string(),
jun,
);
assert_eq!(
BybitSymbolConverter::to_exchange_id(&symbol_jun),
"ETHUSDT-28JUN24"
);
}
#[test]
fn test_spot_from_exchange_id() {
let parsed = BybitSymbolConverter::from_exchange_id(
"BTCUSDT",
SymbolMarketType::Spot,
None,
"BTC",
"USDT",
)
.unwrap();
assert_eq!(parsed.base, "BTC");
assert_eq!(parsed.quote, "USDT");
assert!(parsed.settle.is_none());
assert!(parsed.is_spot());
}
#[test]
fn test_swap_from_exchange_id() {
let parsed = BybitSymbolConverter::from_exchange_id(
"BTCUSDT",
SymbolMarketType::Swap,
Some("USDT"),
"BTC",
"USDT",
)
.unwrap();
assert_eq!(parsed.base, "BTC");
assert_eq!(parsed.quote, "USDT");
assert_eq!(parsed.settle, Some("USDT".to_string()));
assert!(parsed.is_swap());
}
#[test]
fn test_futures_from_exchange_id() {
let parsed = BybitSymbolConverter::from_exchange_id(
"BTCUSDT-31DEC24",
SymbolMarketType::Futures,
Some("USDT"),
"BTC",
"USDT",
)
.unwrap();
assert_eq!(parsed.base, "BTC");
assert_eq!(parsed.quote, "USDT");
assert_eq!(parsed.settle, Some("USDT".to_string()));
assert!(parsed.is_futures());
let expiry = parsed.expiry.unwrap();
assert_eq!(expiry.year, 24);
assert_eq!(expiry.month, 12);
assert_eq!(expiry.day, 31);
}
#[test]
fn test_is_futures() {
assert!(BybitSymbolConverter::is_futures("BTCUSDT-31DEC24"));
assert!(BybitSymbolConverter::is_futures("ETHUSDT-15MAR25"));
assert!(!BybitSymbolConverter::is_futures("BTCUSDT"));
assert!(!BybitSymbolConverter::is_futures("BTCUSD"));
}
#[test]
fn test_is_spot_or_perpetual() {
assert!(BybitSymbolConverter::is_spot_or_perpetual("BTCUSDT"));
assert!(BybitSymbolConverter::is_spot_or_perpetual("ETHBTC"));
assert!(!BybitSymbolConverter::is_spot_or_perpetual(
"BTCUSDT-31DEC24"
));
}
#[test]
fn test_format_expiry_date() {
let expiry = ExpiryDate::new(24, 12, 31).unwrap();
assert_eq!(BybitSymbolConverter::format_expiry_date(expiry), "31DEC24");
let expiry2 = ExpiryDate::new(25, 1, 5).unwrap();
assert_eq!(BybitSymbolConverter::format_expiry_date(expiry2), "05JAN25");
}
#[test]
fn test_parse_expiry_date() {
let expiry = BybitSymbolConverter::parse_expiry_date("31DEC24").unwrap();
assert_eq!(expiry.year, 24);
assert_eq!(expiry.month, 12);
assert_eq!(expiry.day, 31);
let expiry2 = BybitSymbolConverter::parse_expiry_date("05JAN25").unwrap();
assert_eq!(expiry2.year, 25);
assert_eq!(expiry2.month, 1);
assert_eq!(expiry2.day, 5);
}
}