Skip to main content

nautilus_coinapi/common/
symbol.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Mapping between CoinAPI exchange symbols and native venue instrument IDs.
17//!
18//! Each supported CoinAPI exchange and product maps to the symbol convention of the
19//! corresponding native adapter. The CoinAPI exchange symbol must match the form built from
20//! its exchange asset codes and delivery date; any other form is unresolved rather than guessed.
21
22use jiff::civil::Date;
23use nautilus_model::identifiers::{InstrumentId, Symbol, Venue};
24use ustr::Ustr;
25
26/// A CoinAPI symbol type with a native instrument mapping.
27#[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/// Contract semantics fixed by the native venue product convention.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum Contract {
40    /// Spot pair without contract terms.
41    Spot,
42    /// Quote-settled contract.
43    Linear,
44    /// Base-settled contract.
45    Inverse,
46}
47
48/// Asset codes that a CoinAPI exchange symbol must agree with.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct SymbolAssets {
51    /// CoinAPI normalized base asset.
52    pub base: Ustr,
53    /// CoinAPI normalized quote asset.
54    pub quote: Ustr,
55    /// Venue base asset code.
56    pub base_exchange: Ustr,
57    /// Venue quote asset code.
58    pub quote_exchange: Ustr,
59}
60
61/// A native instrument identity resolved from a CoinAPI exchange symbol.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub struct NativeSymbol {
64    pub instrument_id: InstrumentId,
65    pub contract: Contract,
66}
67
68/// A CoinAPI exchange, symbol type, and exchange symbol.
69///
70/// This identifies the venue product, not the full CoinAPI `symbol_id`, which can carry a
71/// provider suffix and must be retained separately for transport lookup.
72#[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/// A failure to map between CoinAPI and native instrument identity.
80#[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    /// Resolves the native instrument ID and contract semantics.
105    ///
106    /// `delivery` is the UTC delivery date and is required for futures.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the exchange and symbol type have no native mapping, or when the
111    /// exchange symbol does not match the native form for `assets` and `delivery`.
112    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        // A native ID that does not map back to this product would be ambiguous
191        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    /// Resolves the CoinAPI exchange, symbol type, and exchange symbol for a native ID.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the ID has no supported native form, or when its form is shared by more
206    /// than one CoinAPI product.
207    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
233// COIN-M delivery symbols share the USD-M form and carry a USD quote
234fn 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
320// Native Kraken spot IDs rename XBT and XDG, so venue symbols already using BTC or DOGE
321// would share their native ID and cannot round-trip.
322fn 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}