1use jiff::civil::Date;
23use nautilus_model::identifiers::{InstrumentId, Symbol, Venue};
24use ustr::Ustr;
25
26#[derive(
28 Clone, Copy, Debug, PartialEq, Eq, Hash, strum::AsRefStr, strum::Display, strum::EnumString,
29)]
30#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
31pub enum CoinApiSymbolType {
32 Spot,
33 Perpetual,
34 Futures,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum Contract {
40 Spot,
42 Linear,
44 Inverse,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct SymbolAssets {
51 pub base: Ustr,
53 pub quote: Ustr,
55 pub base_exchange: Ustr,
57 pub quote_exchange: Ustr,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub struct NativeSymbol {
64 pub instrument_id: InstrumentId,
65 pub contract: Contract,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub struct VenueSymbol {
74 pub exchange_id: Ustr,
75 pub symbol_type: CoinApiSymbolType,
76 pub symbol: Ustr,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
81pub enum SymbolError {
82 #[error("CoinAPI {exchange_id} {symbol_type} symbols have no native mapping")]
83 Unsupported {
84 exchange_id: Ustr,
85 symbol_type: CoinApiSymbolType,
86 },
87 #[error(
88 "CoinAPI {exchange_id} {symbol_type} symbol {symbol} does not match its native form for the given assets and delivery date"
89 )]
90 Unresolved {
91 exchange_id: Ustr,
92 symbol_type: CoinApiSymbolType,
93 symbol: Ustr,
94 },
95 #[error("instrument ID {0} has no CoinAPI mapping")]
96 UnsupportedInstrument(InstrumentId),
97 #[error("instrument ID {0} matches more than one CoinAPI product")]
98 Ambiguous(InstrumentId),
99}
100
101const MONTH_CODES: [char; 12] = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z'];
102
103impl VenueSymbol {
104 pub fn to_native(
113 &self,
114 assets: &SymbolAssets,
115 delivery: Option<Date>,
116 ) -> Result<NativeSymbol, SymbolError> {
117 let symbol = self.symbol.as_str();
118 let pair = format!("{}{}", assets.base_exchange, assets.quote_exchange);
119 let dashed = format!("{}-{}", assets.base_exchange, assets.quote_exchange);
120 let yymmdd = delivery.map(|date| date.strftime("%y%m%d").to_string());
121 let matches =
122 |expected: Option<String>| expected.is_some_and(|expected| expected == symbol);
123
124 let resolved =
125 match (self.exchange_id.as_str(), self.symbol_type) {
126 ("BINANCE", CoinApiSymbolType::Spot) => {
127 (symbol == pair).then(|| (symbol.to_string(), "BINANCE", Contract::Spot))
128 }
129 ("BINANCEFTS", CoinApiSymbolType::Perpetual) => (symbol == pair)
130 .then(|| (format!("{symbol}-PERP"), "BINANCE", Contract::Linear)),
131 ("BINANCEFTS", CoinApiSymbolType::Futures) => {
132 matches(yymmdd.map(|date| format!("{pair}_{date}")))
133 .then(|| (symbol.to_string(), "BINANCE", Contract::Linear))
134 }
135 ("BYBITSPOT", CoinApiSymbolType::Spot) => {
136 (symbol == pair).then(|| (format!("{symbol}-SPOT"), "BYBIT", Contract::Spot))
137 }
138 ("BYBIT", CoinApiSymbolType::Perpetual) => (symbol == pair)
139 .then(|| (format!("{symbol}-LINEAR"), "BYBIT", Contract::Linear)),
140 ("BYBIT", CoinApiSymbolType::Futures) => matches(delivery.map(|date| {
141 let date = date.strftime("%d%b%y").to_string().to_ascii_uppercase();
142 format!("{pair}-{date}")
143 }))
144 .then(|| (format!("{symbol}-LINEAR"), "BYBIT", Contract::Linear)),
145 ("BYBITINVERSE", CoinApiSymbolType::Perpetual) => (symbol == pair)
146 .then(|| (format!("{symbol}-INVERSE"), "BYBIT", Contract::Inverse)),
147 ("BYBITINVERSE", CoinApiSymbolType::Futures) => matches(delivery.map(|date| {
148 let month = MONTH_CODES[date.month() as usize - 1];
149 format!("{pair}{month}{}", date.strftime("%y"))
150 }))
151 .then(|| (format!("{symbol}-INVERSE"), "BYBIT", Contract::Inverse)),
152 ("OKEX", CoinApiSymbolType::Spot) => {
153 (symbol == dashed).then(|| (symbol.to_string(), "OKX", Contract::Spot))
154 }
155 ("OKEX", CoinApiSymbolType::Perpetual) => (symbol == format!("{dashed}-SWAP"))
156 .then(|| (symbol.to_string(), "OKX", okx_contract(assets))),
157 ("OKEX", CoinApiSymbolType::Futures) => {
158 matches(yymmdd.map(|date| format!("{dashed}-{date}")))
159 .then(|| (symbol.to_string(), "OKX", okx_contract(assets)))
160 }
161 ("COINBASE", CoinApiSymbolType::Spot) => {
162 (symbol == dashed).then(|| (symbol.to_string(), "COINBASE", Contract::Spot))
163 }
164 ("KRAKEN", CoinApiSymbolType::Spot) => kraken_spot_native(symbol, assets)
165 .map(|native| (native, "KRAKEN", Contract::Spot)),
166 ("KRAKENFTS", CoinApiSymbolType::Perpetual) => {
167 [("PI", Contract::Inverse), ("PF", Contract::Linear)]
168 .into_iter()
169 .find(|(prefix, _)| symbol == format!("{prefix}_{pair}"))
170 .map(|(_, contract)| (symbol.to_string(), "KRAKEN", contract))
171 }
172 _ => {
173 return Err(SymbolError::Unsupported {
174 exchange_id: self.exchange_id,
175 symbol_type: self.symbol_type,
176 });
177 }
178 };
179
180 let unresolved = || SymbolError::Unresolved {
181 exchange_id: self.exchange_id,
182 symbol_type: self.symbol_type,
183 symbol: self.symbol,
184 };
185
186 let (native, venue, contract) = resolved.ok_or_else(unresolved)?;
187 let symbol = Symbol::new_checked(native).map_err(|_| unresolved())?;
188 let instrument_id = InstrumentId::new(symbol, Venue::new(venue));
189
190 if Self::from_native(instrument_id) != Ok(*self) {
192 return Err(unresolved());
193 }
194
195 Ok(NativeSymbol {
196 instrument_id,
197 contract,
198 })
199 }
200
201 pub fn from_native(instrument_id: InstrumentId) -> Result<Self, SymbolError> {
208 let symbol = instrument_id.symbol.as_str();
209 let resolved = match instrument_id.venue.as_str() {
210 "BINANCE" if is_binance_usd_delivery(symbol) => {
211 return Err(SymbolError::Ambiguous(instrument_id));
212 }
213 "BINANCE" => binance_venue_symbol(symbol),
214 "BYBIT" => bybit_venue_symbol(symbol),
215 "OKX" => okx_venue_symbol(symbol),
216 "COINBASE" => coinbase_venue_symbol(symbol),
217 "KRAKEN" => kraken_venue_symbol(symbol),
218 _ => None,
219 };
220
221 resolved.ok_or(SymbolError::UnsupportedInstrument(instrument_id))
222 }
223
224 fn new(exchange_id: &str, symbol_type: CoinApiSymbolType, symbol: &str) -> Self {
225 Self {
226 exchange_id: Ustr::from(exchange_id),
227 symbol_type,
228 symbol: Ustr::from(symbol),
229 }
230 }
231}
232
233fn is_binance_usd_delivery(symbol: &str) -> bool {
235 symbol
236 .split_once('_')
237 .is_some_and(|(stem, date)| stem.ends_with("USD") && is_digits(date, 6))
238}
239
240fn binance_venue_symbol(symbol: &str) -> Option<VenueSymbol> {
241 if let Some(stem) = symbol.strip_suffix("-PERP") {
242 return is_alphanumeric(stem)
243 .then(|| VenueSymbol::new("BINANCEFTS", CoinApiSymbolType::Perpetual, stem));
244 }
245
246 if let Some((stem, date)) = symbol.split_once('_') {
247 return (is_alphanumeric(stem) && is_digits(date, 6))
248 .then(|| VenueSymbol::new("BINANCEFTS", CoinApiSymbolType::Futures, symbol));
249 }
250
251 is_alphanumeric(symbol).then(|| VenueSymbol::new("BINANCE", CoinApiSymbolType::Spot, symbol))
252}
253
254fn bybit_venue_symbol(symbol: &str) -> Option<VenueSymbol> {
255 let (raw, suffix) = symbol.rsplit_once('-')?;
256 let (exchange_id, symbol_type) = match (suffix, raw.split_once('-')) {
257 ("SPOT", None) if is_alphanumeric(raw) => ("BYBITSPOT", CoinApiSymbolType::Spot),
258 ("LINEAR", None) if is_alphanumeric(raw) => ("BYBIT", CoinApiSymbolType::Perpetual),
259 ("LINEAR", Some((stem, date))) if is_alphanumeric(stem) && is_bybit_linear_date(date) => {
260 ("BYBIT", CoinApiSymbolType::Futures)
261 }
262 ("INVERSE", None) if is_bybit_inverse_future(raw) => {
263 ("BYBITINVERSE", CoinApiSymbolType::Futures)
264 }
265 ("INVERSE", None) if !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_uppercase()) => {
266 ("BYBITINVERSE", CoinApiSymbolType::Perpetual)
267 }
268 _ => return None,
269 };
270
271 Some(VenueSymbol::new(exchange_id, symbol_type, raw))
272}
273
274fn okx_venue_symbol(symbol: &str) -> Option<VenueSymbol> {
275 let parts: Vec<&str> = symbol.split('-').collect();
276 if !parts.iter().all(|part| is_alphanumeric(part)) {
277 return None;
278 }
279
280 let symbol_type = match parts.as_slice() {
281 [_, _] => CoinApiSymbolType::Spot,
282 [_, _, "SWAP"] => CoinApiSymbolType::Perpetual,
283 [_, _, date] if is_digits(date, 6) => CoinApiSymbolType::Futures,
284 _ => return None,
285 };
286
287 Some(VenueSymbol::new("OKEX", symbol_type, symbol))
288}
289
290fn coinbase_venue_symbol(symbol: &str) -> Option<VenueSymbol> {
291 let (base, quote) = symbol.split_once('-')?;
292 (is_alphanumeric(base) && is_alphanumeric(quote))
293 .then(|| VenueSymbol::new("COINBASE", CoinApiSymbolType::Spot, symbol))
294}
295
296fn kraken_venue_symbol(symbol: &str) -> Option<VenueSymbol> {
297 if let Some((base, quote)) = symbol.split_once('/') {
298 let base = kraken_spot_venue_code(base)?;
299 let quote = kraken_spot_venue_code(quote)?;
300 return Some(VenueSymbol::new(
301 "KRAKEN",
302 CoinApiSymbolType::Spot,
303 &format!("{base}/{quote}"),
304 ));
305 }
306
307 let (prefix, rest) = symbol.split_at_checked(3)?;
308 (matches!(prefix, "PI_" | "PF_") && is_alphanumeric(rest))
309 .then(|| VenueSymbol::new("KRAKENFTS", CoinApiSymbolType::Perpetual, symbol))
310}
311
312fn okx_contract(assets: &SymbolAssets) -> Contract {
313 if assets.quote_exchange == "USD" {
314 Contract::Inverse
315 } else {
316 Contract::Linear
317 }
318}
319
320fn kraken_spot_native(symbol: &str, assets: &SymbolAssets) -> Option<String> {
323 let (base, quote) = symbol.split_once('/')?;
324 let base = kraken_spot_native_code(base)?;
325 let quote = kraken_spot_native_code(quote)?;
326 (base == assets.base && quote == assets.quote).then(|| format!("{base}/{quote}"))
327}
328
329fn kraken_spot_native_code(code: &str) -> Option<&str> {
330 match code {
331 "XBT" => Some("BTC"),
332 "XDG" => Some("DOGE"),
333 "BTC" | "DOGE" => None,
334 code if is_alphanumeric(code) => Some(code),
335 _ => None,
336 }
337}
338
339fn kraken_spot_venue_code(code: &str) -> Option<&str> {
340 match code {
341 "BTC" => Some("XBT"),
342 "DOGE" => Some("XDG"),
343 "XBT" | "XDG" => None,
344 code if is_alphanumeric(code) => Some(code),
345 _ => None,
346 }
347}
348
349fn is_alphanumeric(value: &str) -> bool {
350 !value.is_empty() && value.bytes().all(|b| b.is_ascii_alphanumeric())
351}
352
353fn is_digits(value: &str, len: usize) -> bool {
354 value.len() == len && value.bytes().all(|b| b.is_ascii_digit())
355}
356
357fn is_bybit_linear_date(value: &str) -> bool {
358 Date::strptime("%d%b%y", value)
359 .is_ok_and(|date| date.strftime("%d%b%y").to_string().to_ascii_uppercase() == value)
360}
361
362fn is_bybit_inverse_future(raw: &str) -> bool {
363 let Some((stem, code)) = raw.split_at_checked(raw.len().saturating_sub(3)) else {
364 return false;
365 };
366
367 let mut chars = code.chars();
368
369 !stem.is_empty()
370 && stem.bytes().all(|b| b.is_ascii_uppercase())
371 && chars
372 .next()
373 .is_some_and(|month| MONTH_CODES.contains(&month))
374 && is_digits(chars.as_str(), 2)
375}
376
377#[cfg(test)]
378mod tests {
379 use rstest::rstest;
380
381 use super::*;
382
383 fn assets(base: &str, quote: &str, base_exchange: &str, quote_exchange: &str) -> SymbolAssets {
384 SymbolAssets {
385 base: Ustr::from(base),
386 quote: Ustr::from(quote),
387 base_exchange: Ustr::from(base_exchange),
388 quote_exchange: Ustr::from(quote_exchange),
389 }
390 }
391
392 fn venue_symbol(
393 exchange_id: &str,
394 symbol_type: CoinApiSymbolType,
395 symbol: &str,
396 ) -> VenueSymbol {
397 VenueSymbol {
398 exchange_id: Ustr::from(exchange_id),
399 symbol_type,
400 symbol: Ustr::from(symbol),
401 }
402 }
403
404 #[rstest]
405 #[case("BINANCE", CoinApiSymbolType::Spot, "BTCUSDT", ("BTC", "USDT", "BTC", "USDT"), None, "BTCUSDT.BINANCE", Contract::Spot)]
406 #[case("BINANCEFTS", CoinApiSymbolType::Perpetual, "ETHUSDC", ("ETH", "USDC", "ETH", "USDC"), None, "ETHUSDC-PERP.BINANCE", Contract::Linear)]
407 #[case("BINANCEFTS", CoinApiSymbolType::Futures, "BTCUSDT_261225", ("BTC", "USDT", "BTC", "USDT"), Some("2026-12-25"), "BTCUSDT_261225.BINANCE", Contract::Linear)]
408 #[case("BYBITSPOT", CoinApiSymbolType::Spot, "SOLUSDC", ("SOL", "USDC", "SOL", "USDC"), None, "SOLUSDC-SPOT.BYBIT", Contract::Spot)]
409 #[case("BYBIT", CoinApiSymbolType::Perpetual, "XRPUSDT", ("XRP", "USDT", "XRP", "USDT"), None, "XRPUSDT-LINEAR.BYBIT", Contract::Linear)]
410 #[case("BYBIT", CoinApiSymbolType::Futures, "BTCUSDT-09OCT26", ("BTC", "USDT", "BTC", "USDT"), Some("2026-10-09"), "BTCUSDT-09OCT26-LINEAR.BYBIT", Contract::Linear)]
411 #[case("BYBITINVERSE", CoinApiSymbolType::Perpetual, "ETHUSD", ("ETH", "USD", "ETH", "USD"), None, "ETHUSD-INVERSE.BYBIT", Contract::Inverse)]
412 #[case("BYBITINVERSE", CoinApiSymbolType::Futures, "BTCUSDH27", ("BTC", "USD", "BTC", "USD"), Some("2027-03-26"), "BTCUSDH27-INVERSE.BYBIT", Contract::Inverse)]
413 #[case("OKEX", CoinApiSymbolType::Spot, "ETH-USDC", ("ETH", "USDC", "ETH", "USDC"), None, "ETH-USDC.OKX", Contract::Spot)]
414 #[case("OKEX", CoinApiSymbolType::Perpetual, "BTC-USDT-SWAP", ("BTC", "USDT", "BTC", "USDT"), None, "BTC-USDT-SWAP.OKX", Contract::Linear)]
415 #[case("OKEX", CoinApiSymbolType::Perpetual, "ETH-USD-SWAP", ("ETH", "USD", "ETH", "USD"), None, "ETH-USD-SWAP.OKX", Contract::Inverse)]
416 #[case("OKEX", CoinApiSymbolType::Futures, "BTC-USD-270326", ("BTC", "USD", "BTC", "USD"), Some("2027-03-26"), "BTC-USD-270326.OKX", Contract::Inverse)]
417 #[case("OKEX", CoinApiSymbolType::Futures, "SOL-USDT-261225", ("SOL", "USDT", "SOL", "USDT"), Some("2026-12-25"), "SOL-USDT-261225.OKX", Contract::Linear)]
418 #[case("COINBASE", CoinApiSymbolType::Spot, "ETH-USD", ("ETH", "USD", "ETH", "USD"), None, "ETH-USD.COINBASE", Contract::Spot)]
419 #[case("KRAKEN", CoinApiSymbolType::Spot, "XBT/USDC", ("BTC", "USDC", "XXBT", "USDC"), None, "BTC/USDC.KRAKEN", Contract::Spot)]
420 #[case("KRAKEN", CoinApiSymbolType::Spot, "XDG/EUR", ("DOGE", "EUR", "XXDG", "ZEUR"), None, "DOGE/EUR.KRAKEN", Contract::Spot)]
421 #[case("KRAKEN", CoinApiSymbolType::Spot, "ETH/USD", ("ETH", "USD", "XETH", "ZUSD"), None, "ETH/USD.KRAKEN", Contract::Spot)]
422 #[case("KRAKENFTS", CoinApiSymbolType::Perpetual, "PI_XBTUSD", ("BTC", "USD", "XBT", "USD"), None, "PI_XBTUSD.KRAKEN", Contract::Inverse)]
423 #[case("KRAKENFTS", CoinApiSymbolType::Perpetual, "PF_ETHUSD", ("ETH", "USD", "ETH", "USD"), None, "PF_ETHUSD.KRAKEN", Contract::Linear)]
424 fn test_native_round_trip(
425 #[case] exchange_id: &str,
426 #[case] symbol_type: CoinApiSymbolType,
427 #[case] symbol: &str,
428 #[case] codes: (&str, &str, &str, &str),
429 #[case] delivery: Option<&str>,
430 #[case] expected_id: &str,
431 #[case] expected_contract: Contract,
432 ) {
433 let venue_symbol = venue_symbol(exchange_id, symbol_type, symbol);
434 let assets = assets(codes.0, codes.1, codes.2, codes.3);
435 let delivery = delivery.map(|date| date.parse().unwrap());
436
437 let native = venue_symbol.to_native(&assets, delivery).unwrap();
438 let reverse = VenueSymbol::from_native(native.instrument_id).unwrap();
439
440 assert_eq!(
441 native,
442 NativeSymbol {
443 instrument_id: InstrumentId::from(expected_id),
444 contract: expected_contract,
445 }
446 );
447 assert_eq!(reverse, venue_symbol);
448 }
449
450 #[rstest]
451 #[case::binance_pair_mismatch("BINANCE", CoinApiSymbolType::Spot, "BTCUSDC", ("BTC", "USDT", "BTC", "USDT"), None)]
452 #[case::binance_future_date("BINANCEFTS", CoinApiSymbolType::Futures, "BTCUSDT_261225", ("BTC", "USDT", "BTC", "USDT"), Some("2026-09-25"))]
453 #[case::binance_future_missing_date("BINANCEFTS", CoinApiSymbolType::Futures, "BTCUSDT_261225", ("BTC", "USDT", "BTC", "USDT"), None)]
454 #[case::binance_usd_delivery("BINANCEFTS", CoinApiSymbolType::Futures, "BTCUSD_261225", ("BTC", "USD", "BTC", "USD"), Some("2026-12-25"))]
455 #[case::binance_perp_as_future("BINANCEFTS", CoinApiSymbolType::Futures, "BTCUSDT", ("BTC", "USDT", "BTC", "USDT"), Some("2026-12-25"))]
456 #[case::bybit_usdc_perp("BYBIT", CoinApiSymbolType::Perpetual, "BTCPERP", ("BTC", "USDC", "BTC", "USDC"), None)]
457 #[case::bybit_future_date("BYBIT", CoinApiSymbolType::Futures, "BTCUSDT-30OCT26", ("BTC", "USDT", "BTC", "USDT"), Some("2026-10-09"))]
458 #[case::bybit_inverse_month("BYBITINVERSE", CoinApiSymbolType::Futures, "BTCUSDZ26", ("BTC", "USD", "BTC", "USD"), Some("2026-09-25"))]
459 #[case::okx_usd_margined("OKEX", CoinApiSymbolType::Futures, "BTC-USD_UM-261225", ("BTC", "USD", "BTC", "USD"), Some("2026-12-25"))]
460 #[case::okx_pseudo_perp("OKEX", CoinApiSymbolType::Futures, "BTC-USD_UM_XPERP-310404", ("BTC", "USD", "BTC", "USD"), Some("2031-04-04"))]
461 #[case::coinbase_quote_mismatch("COINBASE", CoinApiSymbolType::Spot, "BTC-USD", ("BTC", "USDT", "BTC", "USDT"), None)]
462 #[case::kraken_literal_btc("KRAKEN", CoinApiSymbolType::Spot, "BTC/USD", ("BTC", "USD", "XXBT", "ZUSD"), None)]
463 #[case::kraken_asset_mismatch("KRAKEN", CoinApiSymbolType::Spot, "XBT/USD", ("BTC", "USDT", "XXBT", "USDT"), None)]
464 #[case::kraken_futures_prefix("KRAKENFTS", CoinApiSymbolType::Perpetual, "PV_XBTUSD", ("BTC", "USD", "XBT", "USD"), None)]
465 #[case::kraken_futures_case("KRAKENFTS", CoinApiSymbolType::Perpetual, "pi_xbtusd", ("BTC", "USD", "XBT", "USD"), None)]
466 fn test_unresolved_forms_are_rejected(
467 #[case] exchange_id: &str,
468 #[case] symbol_type: CoinApiSymbolType,
469 #[case] symbol: &str,
470 #[case] codes: (&str, &str, &str, &str),
471 #[case] delivery: Option<&str>,
472 ) {
473 let venue_symbol = venue_symbol(exchange_id, symbol_type, symbol);
474 let assets = assets(codes.0, codes.1, codes.2, codes.3);
475 let delivery = delivery.map(|date| date.parse().unwrap());
476
477 let result = venue_symbol.to_native(&assets, delivery);
478
479 assert_eq!(
480 result,
481 Err(SymbolError::Unresolved {
482 exchange_id: Ustr::from(exchange_id),
483 symbol_type,
484 symbol: Ustr::from(symbol),
485 })
486 );
487 }
488
489 #[rstest]
490 #[case("BINANCEFTSC", CoinApiSymbolType::Perpetual, "BTCUSD_PERP")]
491 #[case("BINANCEFTSC", CoinApiSymbolType::Futures, "BTCUSD_261225")]
492 #[case("COINBASE", CoinApiSymbolType::Perpetual, "BTC-PERP")]
493 #[case("KRAKENFTS", CoinApiSymbolType::Spot, "XBT/USD")]
494 #[case("KRAKENFTS", CoinApiSymbolType::Futures, "FF_XBTUSD_260925")]
495 #[case("BITSTAMP", CoinApiSymbolType::Spot, "btcusd")]
496 fn test_unsupported_products_are_rejected(
497 #[case] exchange_id: &str,
498 #[case] symbol_type: CoinApiSymbolType,
499 #[case] symbol: &str,
500 ) {
501 let venue_symbol = venue_symbol(exchange_id, symbol_type, symbol);
502 let assets = assets("BTC", "USD", "BTC", "USD");
503
504 let result = venue_symbol.to_native(&assets, None);
505
506 assert_eq!(
507 result,
508 Err(SymbolError::Unsupported {
509 exchange_id: Ustr::from(exchange_id),
510 symbol_type,
511 })
512 );
513 }
514
515 #[rstest]
516 #[case("BTCUSD_PERP.BINANCE")]
517 #[case("BTCUSDT_2612.BINANCE")]
518 #[case("BTC-USDT.BINANCE")]
519 #[case("BTCUSDT.BYBIT")]
520 #[case("BTCUSDT-OPTION.BYBIT")]
521 #[case("BTC-30OCT2026-LINEAR.BYBIT")]
522 #[case("BTCUSDT-30oct26-LINEAR.BYBIT")]
523 #[case("BTCUSDZ6-INVERSE.BYBIT")]
524 #[case("BTC€D-INVERSE.BYBIT")]
525 #[case("BTC-USD-261225-90000-C.OKX")]
526 #[case("BTC-USD_UM-261225.OKX")]
527 #[case("BIP-20DEC30-CDE.COINBASE")]
528 #[case("XBT/USD.KRAKEN")]
529 #[case("PV_XBTUSD.KRAKEN")]
530 #[case("FF_XBTUSD.KRAKEN")]
531 #[case("FF_XBTUSD_260925.KRAKEN")]
532 #[case("FI_XBTUSD_261225.KRAKEN")]
533 #[case("BTCUSDT.BITSTAMP")]
534 fn test_unsupported_instrument_ids_are_rejected(#[case] instrument_id: &str) {
535 let instrument_id = InstrumentId::from(instrument_id);
536
537 let result = VenueSymbol::from_native(instrument_id);
538
539 assert_eq!(
540 result,
541 Err(SymbolError::UnsupportedInstrument(instrument_id))
542 );
543 }
544
545 #[rstest]
546 fn test_binance_usd_delivery_is_ambiguous() {
547 let instrument_id = InstrumentId::from("BTCUSD_261225.BINANCE");
548
549 let result = VenueSymbol::from_native(instrument_id);
550
551 assert_eq!(result, Err(SymbolError::Ambiguous(instrument_id)));
552 }
553}