use std::{borrow::Cow, fmt::Display};
use nautilus_model::identifiers::{InstrumentId, Symbol};
use ustr::Ustr;
use super::{consts::BYBIT_VENUE, enums::BybitProductType};
const VALID_SUFFIXES: &[&str] = &["-SPOT", "-LINEAR", "-INVERSE", "-OPTION"];
fn has_valid_suffix(value: &str) -> bool {
VALID_SUFFIXES.iter().any(|suffix| value.contains(suffix))
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct BybitSymbol {
value: Ustr,
}
impl BybitSymbol {
pub fn new<S: AsRef<str>>(value: S) -> anyhow::Result<Self> {
let value_ref = value.as_ref();
let needs_upper = value_ref.bytes().any(|b| b.is_ascii_lowercase());
let normalised: Cow<'_, str> = if needs_upper {
Cow::Owned(value_ref.to_ascii_uppercase())
} else {
Cow::Borrowed(value_ref)
};
anyhow::ensure!(
has_valid_suffix(normalised.as_ref()),
"invalid Bybit symbol '{value_ref}': expected suffix in {VALID_SUFFIXES:?}"
);
Ok(Self {
value: Ustr::from(normalised.as_ref()),
})
}
#[must_use]
pub fn raw_symbol(&self) -> &str {
self.value
.rsplit_once('-')
.map_or(self.value.as_str(), |(prefix, _)| prefix)
}
#[must_use]
pub fn product_type(&self) -> BybitProductType {
BybitProductType::from_suffix(self.value.as_str())
.expect("symbol checked for suffix during construction")
}
#[must_use]
pub fn to_instrument_id(&self) -> InstrumentId {
InstrumentId::new(Symbol::from_ustr_unchecked(self.value), *BYBIT_VENUE)
}
#[must_use]
pub fn as_ustr(&self) -> Ustr {
self.value
}
}
impl Display for BybitSymbol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.value.as_str())
}
}
impl TryFrom<&str> for BybitSymbol {
type Error = anyhow::Error;
fn try_from(value: &str) -> anyhow::Result<Self> {
Self::new(value)
}
}
impl TryFrom<String> for BybitSymbol {
type Error = anyhow::Error;
fn try_from(value: String) -> anyhow::Result<Self> {
Self::new(value)
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn new_valid_symbol_is_uppercased() {
let symbol = BybitSymbol::new("btcusdt-linear").unwrap();
assert_eq!(symbol.to_string(), "BTCUSDT-LINEAR");
}
#[rstest]
fn new_invalid_symbol_errors() {
let err = BybitSymbol::new("BTCUSDT").unwrap_err();
assert!(format!("{err}").contains("expected suffix"));
}
#[rstest]
fn raw_symbol_strips_suffix() {
let symbol = BybitSymbol::new("ETH-26JUN26-16000-P-OPTION").unwrap();
assert_eq!(symbol.raw_symbol(), "ETH-26JUN26-16000-P");
}
#[rstest]
fn product_type_detection_matches_suffix() {
let linear = BybitSymbol::new("BTCUSDT-LINEAR").unwrap();
assert!(linear.product_type().is_linear());
let inverse = BybitSymbol::new("BTCUSD-INVERSE").unwrap();
assert!(inverse.product_type().is_inverse());
let spot = BybitSymbol::new("ETHUSDT-SPOT").unwrap();
assert!(spot.product_type().is_spot());
let option = BybitSymbol::new("ETH-26JUN26-16000-P-OPTION").unwrap();
assert!(option.product_type().is_option());
}
#[rstest]
fn instrument_id_uses_bybit_venue() {
let symbol = BybitSymbol::new("BTCUSDT-LINEAR").unwrap();
let instrument_id = symbol.to_instrument_id();
assert_eq!(instrument_id.to_string(), "BTCUSDT-LINEAR.BYBIT");
}
}