Skip to main content

nautilus_interactive_brokers/common/
symbology.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//! Symbology for converting between Interactive Brokers contracts and Nautilus identifiers.
17
18use std::{collections::HashMap, str::FromStr, sync::LazyLock};
19
20use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
21use jiff::{Timestamp, tz::Offset};
22use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
23
24use crate::{
25    common::{
26        enums::{IbOptionRight, IbSecurityType},
27        futures::{
28            format_futures_symbol, futures_month, futures_month_code,
29            parse_futures_symbol as parse_futures_code, resolve_futures_year,
30        },
31    },
32    config::SymbologyMethod,
33};
34
35/// Converts between IB contracts and Nautilus instrument IDs using one configured method.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Symbology {
38    method: SymbologyMethod,
39    futures_year_digits: u8,
40}
41
42impl Symbology {
43    #[must_use]
44    pub const fn new(method: SymbologyMethod) -> Self {
45        Self {
46            method,
47            // Pinned to the venue local symbol; two-digit contract years are not enabled.
48            futures_year_digits: 1,
49        }
50    }
51
52    #[must_use]
53    pub const fn method(self) -> SymbologyMethod {
54        self.method
55    }
56
57    /// Converts an IB contract to a Nautilus instrument ID.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the instrument ID cannot be constructed.
62    pub fn instrument_id(
63        self,
64        contract: &Contract,
65        venue: Option<Venue>,
66    ) -> anyhow::Result<InstrumentId> {
67        anyhow::ensure!(
68            matches!(self.futures_year_digits, 1 | 2),
69            "`futures_year_digits` must be either 1 or 2"
70        );
71
72        let instrument_id = match self.method {
73            SymbologyMethod::Simplified => {
74                contract_to_instrument_id_simplified(contract, venue, self.futures_year_digits)
75            }
76            SymbologyMethod::Raw => {
77                contract_to_instrument_id_raw(contract, venue, self.futures_year_digits)
78            }
79        };
80
81        Ok(instrument_id)
82    }
83
84    /// Converts a Nautilus instrument ID to an IB contract.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the identifier format is unsupported.
89    pub fn contract(
90        self,
91        instrument_id: InstrumentId,
92        exchange: Option<&str>,
93    ) -> anyhow::Result<Contract> {
94        self.contract_with_currency(instrument_id, exchange, None)
95    }
96
97    pub(crate) fn contract_with_currency(
98        self,
99        instrument_id: InstrumentId,
100        exchange: Option<&str>,
101        currency: Option<&str>,
102    ) -> anyhow::Result<Contract> {
103        anyhow::ensure!(
104            matches!(self.futures_year_digits, 1 | 2),
105            "`futures_year_digits` must be either 1 or 2"
106        );
107
108        if instrument_id.venue.as_str() == "OPRA" {
109            let symbol = instrument_id
110                .symbol
111                .as_str()
112                .strip_suffix("=OPT")
113                .unwrap_or(instrument_id.symbol.as_str());
114
115            if !is_canonical_occ_option_symbol(symbol) {
116                anyhow::bail!("Invalid OPRA option symbol: {symbol}");
117            }
118        }
119
120        let reference_year = i32::from(Offset::UTC.to_datetime(Timestamp::now()).year());
121        Ok(instrument_id_to_contract(
122            instrument_id,
123            exchange,
124            self.futures_year_digits,
125            reference_year,
126            currency,
127        ))
128    }
129}
130
131impl From<SymbologyMethod> for Symbology {
132    fn from(method: SymbologyMethod) -> Self {
133        Self::new(method)
134    }
135}
136
137fn ib_option_right_to_option_right(right: IbOptionRight) -> OptionRight {
138    match right {
139        IbOptionRight::Call => OptionRight::Call,
140        IbOptionRight::Put => OptionRight::Put,
141    }
142}
143
144/// Convert an IB Contract to an InstrumentId using simplified symbology.
145///
146/// This implements IB_SIMPLIFIED symbology: clean, readable symbols.
147/// For example:
148/// - STK: "AAPL" -> "AAPL.SMART"
149/// - CASH: "EUR.USD" -> "EUR/USD.IDEALPRO"
150/// - FUT: "ESM23" -> "ESM23.GLOBEX"
151/// - OPT: "AAPL230120C00150000" -> "AAPL230120C00150000.SMART"
152/// - IND: "SPX" -> "^SPX.SMART"
153fn contract_to_instrument_id_simplified(
154    contract: &Contract,
155    venue: Option<Venue>,
156    futures_year_digits: u8,
157) -> InstrumentId {
158    let venue = venue.unwrap_or_else(|| {
159        // For Index and Future, use contract exchange when set (e.g. ESTX50 -> EUREX, FESX -> EUREX).
160        match contract.security_type {
161            SecurityType::Index => {
162                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
163                    Venue::from(contract.exchange.as_str())
164                } else {
165                    Venue::from("SMART")
166                }
167            }
168            SecurityType::Future => {
169                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
170                    Venue::from(contract.exchange.as_str())
171                } else {
172                    Venue::from("GLOBEX")
173                }
174            }
175            SecurityType::ForexPair => Venue::from("IDEALPRO"),
176            SecurityType::Crypto => derive_crypto_venue(contract),
177            SecurityType::Stock => Venue::from("SMART"),
178            SecurityType::Option | SecurityType::FuturesOption => {
179                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
180                    Venue::from(contract.exchange.as_str())
181                } else {
182                    Venue::from("SMART")
183                }
184            }
185            SecurityType::CFD => Venue::from("IBCFD"),
186            SecurityType::Commodity => Venue::from("IBCMDTY"),
187            SecurityType::Bond => Venue::from("SMART"),
188            _ => Venue::from("SMART"),
189        }
190    });
191
192    let symbol = match contract.security_type {
193        SecurityType::Stock => {
194            // STK: Use localSymbol with spaces replaced by hyphens, fallback to symbol
195            let symbol_str = if contract.local_symbol.is_empty() {
196                contract.symbol.as_str().to_string()
197            } else {
198                contract.local_symbol.as_str().replace(' ', "-")
199            };
200
201            NautilusSymbol::from(symbol_str.as_str())
202        }
203        SecurityType::Index => {
204            // IND: Prefix with ^
205            let base = if contract.local_symbol.is_empty() {
206                contract.symbol.as_str()
207            } else {
208                contract.local_symbol.as_str()
209            };
210
211            NautilusSymbol::from(format!("^{base}").as_str())
212        }
213        SecurityType::Option => {
214            // OPT: Preserve OCC 6-character root padding when present.
215            let symbol_str = if contract.local_symbol.is_empty() {
216                format!(
217                    "{} {} {} {}",
218                    contract.right.map_or("", |right| right.as_str()),
219                    contract.trading_class.as_str(),
220                    contract.last_trade_date_or_contract_month.as_str(),
221                    format_option_strike(contract.strike),
222                )
223            } else {
224                normalize_option_symbol(contract.local_symbol.as_str())
225            };
226
227            NautilusSymbol::from(symbol_str.as_str())
228        }
229        SecurityType::ForexPair | SecurityType::Crypto => {
230            // CASH/CRYPTO: Replace dots with slashes (e.g., "EUR.USD" -> "EUR/USD")
231            let symbol_str = if contract.local_symbol.is_empty() {
232                format!(
233                    "{}/{}",
234                    contract.symbol.as_str(),
235                    contract.currency.as_str()
236                )
237            } else {
238                contract.local_symbol.as_str().replace('.', "/")
239            };
240
241            NautilusSymbol::from(symbol_str.as_str())
242        }
243        SecurityType::Future => {
244            if futures_year_digits == 2 {
245                match futures_symbol_from_contract(contract, futures_year_digits) {
246                    Ok(symbol) => {
247                        return InstrumentId::new(NautilusSymbol::from(symbol), venue);
248                    }
249                    Err(e) => tracing::warn!(
250                        "Falling back to legacy futures symbol for local symbol '{}': {e}",
251                        contract.local_symbol
252                    ),
253                }
254            }
255
256            // FUT: Use localSymbol if available; else symbol + trading_class + expiry (e.g. ESTX50 FESX 20240315).
257            if contract.local_symbol.is_empty() {
258                if !contract.trading_class.is_empty()
259                    && !contract.last_trade_date_or_contract_month.is_empty()
260                {
261                    let symbol_str = format!(
262                        "{} {} {}",
263                        contract.symbol.as_str(),
264                        contract.trading_class.as_str(),
265                        contract.last_trade_date_or_contract_month.as_str()
266                    );
267                    NautilusSymbol::from(symbol_str.as_str())
268                } else if !contract.last_trade_date_or_contract_month.is_empty() {
269                    let expiry = contract.last_trade_date_or_contract_month.as_str();
270                    let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
271                    NautilusSymbol::from(symbol_str.as_str())
272                } else {
273                    NautilusSymbol::from(contract.symbol.as_str())
274                }
275            } else {
276                NautilusSymbol::from(contract.local_symbol.as_str())
277            }
278        }
279        SecurityType::FuturesOption => {
280            if futures_year_digits == 2 {
281                match futures_symbol_from_contract(contract, futures_year_digits) {
282                    Ok(symbol) => {
283                        return InstrumentId::new(NautilusSymbol::from(symbol), venue);
284                    }
285                    Err(e) => tracing::warn!(
286                        "Falling back to legacy futures option symbol for local symbol '{}': {e}",
287                        contract.local_symbol
288                    ),
289                }
290            }
291
292            // FOP: Preserve IB local symbol spacing, matching Python simplified symbology.
293            if contract.local_symbol.is_empty() {
294                // Fallback construction
295                let expiry = contract.last_trade_date_or_contract_month.as_str();
296                let right = contract.right.map_or("P", |right| right.as_str());
297                let strike_str = format!("{}", contract.strike as i64);
298                let symbol_str = format!(
299                    "{}{} {}{}",
300                    contract.symbol.as_str(),
301                    expiry,
302                    right,
303                    strike_str
304                );
305                NautilusSymbol::from(symbol_str.as_str())
306            } else {
307                NautilusSymbol::from(contract.local_symbol.as_str())
308            }
309        }
310        SecurityType::CFD => {
311            // CFD: If localSymbol matches EUR.USD pattern, convert to EUR/USD, else use symbol with spaces as hyphens
312            if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
313                let cash_like = contract.local_symbol.as_str().replace('.', "/");
314                NautilusSymbol::from(cash_like.as_str())
315            } else {
316                let symbol_str = contract.symbol.as_str().replace(' ', "-");
317                NautilusSymbol::from(symbol_str.as_str())
318            }
319        }
320        SecurityType::Commodity => {
321            // CMDTY: Replace spaces with hyphens
322            let symbol_str = contract.symbol.as_str().replace(' ', "-");
323            NautilusSymbol::from(symbol_str.as_str())
324        }
325        SecurityType::Bond => {
326            // BOND: Use localSymbol or symbol
327            let symbol_str = if contract.local_symbol.is_empty() {
328                contract.symbol.as_str()
329            } else {
330                contract.local_symbol.as_str()
331            };
332
333            NautilusSymbol::from(symbol_str)
334        }
335        _ => {
336            // Default: use localSymbol or symbol
337            let symbol_str = if contract.local_symbol.is_empty() {
338                contract.symbol.as_str()
339            } else {
340                contract.local_symbol.as_str()
341            };
342
343            NautilusSymbol::from(symbol_str)
344        }
345    };
346
347    InstrumentId::new(symbol, venue)
348}
349
350/// Convert an IB Contract to an InstrumentId using raw symbology.
351///
352/// This implements IB_RAW symbology: preserves IB raw format with security type suffix.
353/// For example:
354/// - "AAPL=STK.SMART"
355/// - "EUR.USD=CASH.IDEALPRO"
356/// - "ESM23=FUT.GLOBEX"
357fn contract_to_instrument_id_raw(
358    contract: &Contract,
359    venue: Option<Venue>,
360    futures_year_digits: u8,
361) -> InstrumentId {
362    let venue = venue.unwrap_or_else(|| match contract.security_type {
363        SecurityType::ForexPair => Venue::from("IDEALPRO"),
364        SecurityType::Crypto => derive_crypto_venue(contract),
365        SecurityType::Stock => Venue::from("SMART"),
366        SecurityType::Option => Venue::from("SMART"),
367        SecurityType::FuturesOption => Venue::from("SMART"),
368        SecurityType::Future => Venue::from("GLOBEX"),
369        SecurityType::Index => Venue::from("SMART"),
370        SecurityType::CFD => Venue::from("IBCFD"),
371        SecurityType::Commodity => Venue::from("IBCMDTY"),
372        SecurityType::Bond => Venue::from("SMART"),
373        _ => Venue::from("SMART"),
374    });
375
376    let local_symbol = if futures_year_digits == 2
377        && matches!(
378            contract.security_type,
379            SecurityType::Future | SecurityType::FuturesOption
380        ) {
381        match futures_symbol_from_contract(contract, futures_year_digits) {
382            Ok(symbol) => symbol,
383            Err(e) => {
384                tracing::warn!(
385                    "Falling back to legacy futures symbol for local symbol '{}': {e}",
386                    contract.local_symbol
387                );
388                legacy_raw_local_symbol(contract)
389            }
390        }
391    } else if contract.local_symbol.is_empty() {
392        contract.symbol.as_str().to_string()
393    } else {
394        contract.local_symbol.as_str().to_string()
395    };
396
397    let sec_type_str = IbSecurityType::try_from(&contract.security_type).map_or_else(
398        |_| "OTHER".to_string(),
399        |security_type| security_type.to_string(),
400    );
401
402    let symbol_str = format!("{local_symbol}={sec_type_str}");
403    let symbol = NautilusSymbol::from(symbol_str.as_str());
404    InstrumentId::new(symbol, venue)
405}
406
407fn legacy_raw_local_symbol(contract: &Contract) -> String {
408    if contract.local_symbol.is_empty() {
409        contract.symbol.as_str().to_string()
410    } else {
411        contract.local_symbol.as_str().to_string()
412    }
413}
414
415/// Venue to IB exchange mappings.
416/// Maps MIC venue codes to lists of IB exchange codes used by Interactive Brokers.
417pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
418    LazyLock::new(|| {
419        let mut map = HashMap::new();
420        // ICE Endex
421        map.insert("NDEX", vec!["ENDEX"]);
422        // CME Group Exchanges
423        map.insert("XCME", vec!["CME"]);
424        map.insert("XCEC", vec!["CME"]);
425        map.insert("XFXS", vec!["CME"]);
426        // Chicago Board of Trade Segments
427        map.insert("XCBT", vec!["CBOT"]);
428        map.insert("CBCM", vec!["CBOT"]);
429        // New York Mercantile Exchange Segments
430        map.insert("XNYM", vec!["NYMEX"]);
431        map.insert("NYUM", vec!["NYMEX"]);
432        // ICE Futures US (formerly NYBOT)
433        map.insert("IFUS", vec!["NYBOT"]);
434        // GLBX, Name used by databento
435        map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
436        // US Major Exchanges & Index Venues
437        map.insert("XNAS", vec!["NASDAQ"]);
438        map.insert("XNYS", vec!["NYSE"]);
439        map.insert("ARCX", vec!["ARCA"]);
440        map.insert("BATS", vec!["BATS"]);
441        map.insert("IEXG", vec!["IEX"]);
442        map.insert("XCBO", vec!["CBOE"]);
443        map.insert("XCBF", vec!["CFE"]);
444        // Canadian Exchanges
445        map.insert("XTSE", vec!["TSX"]);
446        // ICE Europe Exchanges
447        map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
448        // European Exchanges
449        map.insert("XLON", vec!["LSE"]);
450        map.insert("XPAR", vec!["SBF"]);
451        map.insert("XETR", vec!["IBIS"]);
452        map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
453        map.insert("XAMS", vec!["AEB"]);
454        map.insert("XBRU", vec!["EBS"]);
455        map.insert("XBRD", vec!["BELFOX"]);
456        map.insert("XLIS", vec!["BVLP"]);
457        map.insert("XDUB", vec!["IRE"]);
458        map.insert("XOSL", vec!["OSL"]);
459        map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
460        map.insert("XSVX", vec!["VRTX"]);
461        map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
462        map.insert("XMAD", vec!["MDRD", "BME"]);
463        map.insert("DXEX", vec!["BATEEN"]);
464        map.insert("XWBO", vec!["WBAG"]);
465        map.insert("XBUD", vec!["BUX"]);
466        map.insert("XPRA", vec!["PRA"]);
467        map.insert("XWAR", vec!["WSE"]);
468        map.insert("XIST", vec!["ISE"]);
469        // Nasdaq Nordic Exchanges
470        map.insert("XSTO", vec!["SFB"]);
471        map.insert("XCSE", vec!["KFB"]);
472        map.insert("XHEL", vec!["HMB"]);
473        map.insert("XICE", vec!["ISB"]);
474        // Asia-Pacific Exchanges
475        map.insert("XASX", vec!["ASX"]);
476        map.insert("XHKG", vec!["SEHK"]);
477        map.insert("XHKF", vec!["HKFE"]);
478        map.insert("XSES", vec!["SGX"]);
479        map.insert("XOSE", vec!["OSE.JPN"]);
480        map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
481        map.insert("XKRX", vec!["KSE", "KRX"]);
482        map.insert("XTAI", vec!["TASE", "TWSE"]);
483        map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
484        map.insert("XSHE", vec!["SEHKSZSE"]);
485        map.insert("XNSE", vec!["NSE"]);
486        map.insert("XBOM", vec!["BSE"]);
487        // Other Derivatives Exchanges
488        map.insert("XSFE", vec!["SNFE"]);
489        map.insert("XMEX", vec!["MEXDER"]);
490        // African, Middle Eastern, South American Exchanges
491        map.insert("XJSE", vec!["JSE"]);
492        map.insert("XBOG", vec!["BVC"]);
493        map.insert("XTAE", vec!["TASE"]);
494        map.insert("BVMF", vec!["BVMF"]);
495        map
496    });
497
498/// Returns `true` if the contract is a cryptocurrency contract.
499///
500/// Centralizes the crypto check used to gate crypto-specific request handling
501/// (e.g. the `AGGTRADES` `whatToShow` rule - see
502/// [`crate::data::convert::price_type_to_ib_what_to_show_for_security`]).
503#[must_use]
504pub fn is_crypto_contract(contract: &Contract) -> bool {
505    matches!(contract.security_type, SecurityType::Crypto)
506}
507
508/// Derives the venue for a crypto contract.
509///
510/// IB routes crypto to multiple venues; both PAXOS and ZEROHASH are live (which one
511/// applies depends on the account/region). Uses the contract's actual exchange when
512/// set (e.g. ZEROHASH or PAXOS), falling back to PAXOS when it is unspecified.
513#[must_use]
514pub fn derive_crypto_venue(contract: &Contract) -> Venue {
515    if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
516        Venue::from(contract.exchange.as_str())
517    } else {
518        Venue::from("PAXOS")
519    }
520}
521
522#[must_use]
523pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
524    if venue == "OPRA" {
525        return vec!["SMART".to_string()];
526    }
527
528    if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
529        return exchanges
530            .iter()
531            .map(|exchange| (*exchange).to_string())
532            .collect();
533    }
534
535    vec![venue.to_string()]
536}
537
538/// Venue lists for different asset classes
539const VENUES_CASH: &[&str] = &["IDEALPRO"];
540// IB routes crypto to both PAXOS and ZEROHASH (which one applies depends on the
541// account/region); accept both.
542const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
543const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
544const VENUES_FUT: &[&str] = &[
545    "BELFOX",
546    "GLOBEX",
547    "CBOT",
548    "CFE",
549    "CME",
550    "COMEX",
551    "CBOE",
552    "DTB",
553    "EUREX",
554    "HKFE",
555    "ICE",
556    "ICEEU",
557    "ICEEUSOFT",
558    "IDEM",
559    "IPE",
560    "KCBT",
561    "MEXDER",
562    "MGE",
563    "NYBOT",
564    "NYMEX",
565    "OSE.JPN",
566    "SNFE",
567    "SOFFEX",
568    "VRTX",
569    "CMECRYPTO",
570    "NYMEXMETALS",
571    "NYMEXNG",
572    "NYMEXENERGY",
573    "CMEPRECIOUS",
574    "CMECURRENCY",
575    "CMEINDEX",
576    "CMEWEATHER",
577    "CMEINTEREST",
578    "CMEFLOOR",
579    "CBOTFLOOR",
580    "NYMEXFLOOR",
581    "NYBOTFLOOR",
582    "CFEFLOOR",
583    "CMEOPTIONS",
584    "CBOTOPTIONS",
585    "NYMEXOPTIONS",
586    "NYBOTOPTIONS",
587    "ECBOT",
588];
589const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
590const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
591
592fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
593    venues.contains(&venue_str)
594        || VENUE_MEMBERS
595            .get(venue_str)
596            .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
597}
598
599fn is_option_venue(venue: &str) -> bool {
600    venue == "OPRA" || venue_matches(venue, VENUES_OPT)
601}
602
603fn is_canonical_occ_option_symbol(symbol: &str) -> bool {
604    let bytes = symbol.as_bytes();
605    if bytes.len() != 21 || !symbol.is_ascii() {
606        return false;
607    }
608
609    let root = &bytes[..6];
610    let root_len = root.iter().position(|byte| *byte == b' ').unwrap_or(6);
611
612    root_len > 0
613        && root[..root_len].iter().all(u8::is_ascii_graphic)
614        && root[root_len..].iter().all(|byte| *byte == b' ')
615        && bytes[6..12].iter().all(u8::is_ascii_digit)
616        && matches!(bytes[12], b'C' | b'P')
617        && bytes[13..].iter().all(u8::is_ascii_digit)
618}
619
620/// Futures month codes mapping (F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun, N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec)
621/// This constant is kept for potential future use in more complex parsing scenarios.
622#[allow(dead_code)]
623const FUTURES_MONTH_CODES: &[(char, &str)] = &[
624    ('F', "01"),
625    ('G', "02"),
626    ('H', "03"),
627    ('J', "04"),
628    ('K', "05"),
629    ('M', "06"),
630    ('N', "07"),
631    ('Q', "08"),
632    ('U', "09"),
633    ('V', "10"),
634    ('X', "11"),
635    ('Z', "12"),
636];
637
638/// Returns the venue `symbol_to_mic_venue` maps the contract's symbol to, by prefix match.
639#[must_use]
640pub fn symbol_mic_venue(
641    contract: &Contract,
642    symbol_to_mic_venue: &std::collections::HashMap<String, String>,
643) -> Option<String> {
644    let symbol = contract.symbol.as_str();
645    symbol_to_mic_venue
646        .iter()
647        .find(|(symbol_prefix, _)| symbol.starts_with(symbol_prefix.as_str()))
648        .map(|(_, symbol_venue)| symbol_venue.clone())
649}
650
651/// Determine venue from contract using provider configuration.
652///
653/// This implements the same logic as Python's `determine_venue_from_contract`:
654/// 1. Check symbol-specific venue mapping first (prefix matching)
655/// 2. Use VENUE_MEMBERS mapping if convert_exchange_to_mic_venue is enabled
656/// 3. Fall back to exchange
657pub fn determine_venue_from_contract(
658    contract: &Contract,
659    symbol_to_mic_venue: &std::collections::HashMap<String, String>,
660    convert_exchange_to_mic_venue: bool,
661    valid_exchanges: Option<&str>,
662) -> String {
663    if matches!(contract.security_type, SecurityType::CFD) {
664        return "IBCFD".to_string();
665    }
666
667    if matches!(contract.security_type, SecurityType::Commodity) {
668        return "IBCMDTY".to_string();
669    }
670
671    if let Some(venue) = symbol_mic_venue(contract, symbol_to_mic_venue) {
672        return venue;
673    }
674
675    // Use the exchange from the contract (primaryExchange if exchange is SMART)
676    let mut exchange = if contract.exchange.as_str() == "SMART"
677        && !contract.primary_exchange.as_str().is_empty()
678        && contract.primary_exchange.as_str() != "SMART"
679    {
680        contract.primary_exchange.as_str().to_string()
681    } else {
682        contract.exchange.as_str().to_string()
683    };
684
685    if exchange == "SMART"
686        && let Some(valid_exchanges) = valid_exchanges
687    {
688        let parts: Vec<&str> = valid_exchanges
689            .split(',')
690            .map(str::trim)
691            .filter(|part| !part.is_empty())
692            .collect();
693
694        if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
695            exchange = (*chosen).to_string();
696        } else if let Some(first) = parts.first() {
697            exchange = (*first).to_string();
698        }
699    }
700
701    if convert_exchange_to_mic_venue && let Some(venue) = exchange_to_mic_venue(&exchange) {
702        return venue;
703    }
704
705    exchange
706}
707
708/// Convert an Interactive Brokers exchange code to a MIC venue when known.
709#[must_use]
710pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
711    VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
712        exchanges
713            .contains(&exchange)
714            .then(|| (*venue_member).to_string())
715    })
716}
717
718/// Convert a NautilusTrader `InstrumentId` to an Interactive Brokers `Contract`.
719///
720/// This function handles all instrument types:
721/// - Stocks (STK)
722/// - Options (OPT)
723/// - Futures (FUT, CONTFUT)
724/// - Futures Options (FOP)
725/// - Forex (CASH)
726/// - Crypto (CRYPTO)
727/// - CFDs (CFD)
728/// - Commodities (CMDTY)
729/// - Indices (IND)
730/// - Option Spreads (BAG) - requires contract details map
731fn instrument_id_to_contract(
732    instrument_id: InstrumentId,
733    exchange: Option<&str>,
734    futures_year_digits: u8,
735    reference_year: i32,
736    currency: Option<&str>,
737) -> Contract {
738    let venue_str = instrument_id.venue.to_string();
739
740    let derived_exchange = if venue_str == "OPRA" {
741        "SMART"
742    } else {
743        VENUE_MEMBERS
744            .get(venue_str.as_str())
745            .and_then(|exchanges| exchanges.first().copied())
746            .or_else(|| {
747                if venue_matches(venue_str.as_str(), VENUES_CASH)
748                    || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
749                    || venue_matches(venue_str.as_str(), VENUES_OPT)
750                    || venue_matches(venue_str.as_str(), VENUES_FUT)
751                {
752                    Some(venue_str.as_str())
753                } else {
754                    None
755                }
756            })
757            .unwrap_or("SMART")
758    };
759
760    let exchange_str = exchange.unwrap_or(derived_exchange);
761    let symbol_str = instrument_id.symbol.as_str();
762
763    if let Some(contract) = instrument_id_to_ib_contract_raw(
764        &instrument_id,
765        exchange,
766        futures_year_digits,
767        reference_year,
768        currency,
769    ) {
770        return contract;
771    }
772
773    // Handle spreads (BAG contracts) - requires contract details, so we skip for now
774    // This should be handled by the instrument provider which has access to contract details
775    // if symbol_str.contains(":") {
776    //     return create_bag_contract(instrument_id, exchange_str);
777    // }
778
779    // Handle Forex (CASH)
780    if venue_matches(venue_str.as_str(), VENUES_CASH)
781        && let Some(captures) = parse_cash_symbol(symbol_str)
782    {
783        return Contract {
784            symbol: Symbol::from(&captures.base),
785            security_type: SecurityType::ForexPair,
786            exchange: Exchange::from(exchange_str),
787            currency: Currency::from(&captures.quote),
788            local_symbol: format!("{}.{}", captures.base, captures.quote),
789            ..Default::default()
790        };
791    }
792
793    // Handle Crypto
794    if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
795        && let Some(captures) = parse_crypto_symbol(symbol_str)
796    {
797        return Contract {
798            symbol: Symbol::from(&captures.base),
799            security_type: SecurityType::Crypto,
800            exchange: Exchange::from(exchange_str),
801            currency: Currency::from(&captures.quote),
802            local_symbol: format!("{}.{}", captures.base, captures.quote),
803            ..Default::default()
804        };
805    }
806
807    // Handle Options (OPT)
808    if is_option_venue(venue_str.as_str()) {
809        if venue_str == "OPRA" {
810            return Contract {
811                security_type: SecurityType::Option,
812                exchange: Exchange::from(exchange_str),
813                currency: Currency::from("USD"),
814                local_symbol: symbol_str.to_string(),
815                ..Default::default()
816            };
817        }
818
819        if let Some(opt) = parse_option_symbol(symbol_str) {
820            return Contract {
821                symbol: Symbol::from(&opt.symbol),
822                security_type: SecurityType::Option,
823                exchange: Exchange::from(exchange_str),
824                currency: Currency::from("USD"), // Will be resolved from contract details
825                local_symbol: opt.local_symbol,
826                last_trade_date_or_contract_month: opt.expiry,
827                strike: opt.strike_value,
828                right: Some(opt.right),
829                ..Default::default()
830            };
831        }
832
833        if let Some(opt) = parse_named_option_symbol(symbol_str) {
834            return Contract {
835                symbol: Symbol::from(&opt.trading_class),
836                security_type: SecurityType::Option,
837                exchange: Exchange::from(exchange_str),
838                currency: Currency::from("USD"),
839                trading_class: opt.trading_class,
840                last_trade_date_or_contract_month: opt.expiry,
841                strike: opt.strike_value,
842                right: Some(opt.right),
843                ..Default::default()
844            };
845        }
846    }
847
848    // Handle Futures and Futures Options
849    if venue_matches(venue_str.as_str(), VENUES_FUT) {
850        if let Some(fut) = parse_named_futures_symbol(symbol_str) {
851            return Contract {
852                symbol: Symbol::from(&fut.underlying),
853                security_type: SecurityType::Future,
854                exchange: Exchange::from(exchange_str),
855                currency: Currency::from("USD"),
856                trading_class: fut.trading_class,
857                last_trade_date_or_contract_month: fut.expiry,
858                ..Default::default()
859            };
860        }
861
862        // Check for continuous futures (underlying only, no expiry)
863        // IB uses FUT with no expiry date to represent continuous futures
864        if is_futures_underlying(symbol_str) {
865            return Contract {
866                symbol: Symbol::from(symbol_str),
867                security_type: SecurityType::ContinuousFuture,
868                exchange: Exchange::from(exchange_str),
869                currency: Currency::from("USD"), // Will be resolved from contract details
870                ..Default::default()
871            };
872        }
873
874        if futures_year_digits == 2
875            && let Some(contract) = futures_contract_from_symbol(
876                symbol_str,
877                SecurityType::FuturesOption,
878                exchange_str,
879                reference_year,
880                currency,
881            )
882        {
883            return contract;
884        }
885
886        // Check for Futures Options (FOP)
887        if validate_futures_option_symbol(symbol_str).is_some() {
888            return Contract {
889                security_type: SecurityType::FuturesOption,
890                exchange: Exchange::from(exchange_str),
891                currency: Currency::from("USD"),
892                local_symbol: symbol_str.to_string(),
893                ..Default::default()
894            };
895        }
896
897        if futures_year_digits == 2
898            && let Some(contract) = futures_contract_from_symbol(
899                symbol_str,
900                SecurityType::Future,
901                exchange_str,
902                reference_year,
903                currency,
904            )
905        {
906            return contract;
907        }
908
909        // Check for regular Futures (FUT)
910        if parse_futures_month_and_year(symbol_str).is_some() {
911            return Contract {
912                security_type: SecurityType::Future,
913                exchange: Exchange::from(exchange_str),
914                currency: Currency::from("USD"),
915                local_symbol: symbol_str.to_string(),
916                ..Default::default()
917            };
918        }
919    }
920
921    // Handle CFDs
922    if venue_matches(venue_str.as_str(), VENUES_CFD) {
923        if let Some(captures) =
924            parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
925        {
926            return Contract {
927                symbol: Symbol::from(&captures.base),
928                security_type: SecurityType::CFD,
929                exchange: Exchange::from("SMART"),
930                currency: Currency::from(&captures.quote),
931                local_symbol: format!("{}.{}", captures.base, captures.quote),
932                ..Default::default()
933            };
934        } else {
935            // CFD with space-separated symbol
936            let symbol_clean = symbol_str.replace('-', " ");
937
938            return Contract {
939                symbol: Symbol::from(&symbol_clean),
940                security_type: SecurityType::CFD,
941                exchange: Exchange::from("SMART"),
942                currency: Currency::from(""),
943                ..Default::default()
944            };
945        }
946    }
947
948    // Handle Commodities
949    if VENUES_CMDTY.contains(&venue_str.as_str()) {
950        let symbol_clean = symbol_str.replace('-', " ");
951
952        return Contract {
953            symbol: Symbol::from(&symbol_clean),
954            security_type: SecurityType::Commodity,
955            exchange: Exchange::from("SMART"),
956            currency: Currency::from(""),
957            ..Default::default()
958        };
959    }
960
961    // Handle Indices (symbols starting with ^)
962    if let Some(local_symbol) = symbol_str.strip_prefix('^') {
963        return Contract {
964            symbol: Symbol::from(local_symbol),
965            security_type: SecurityType::Index,
966            exchange: Exchange::from(exchange_str),
967            currency: Currency::from("USD"),
968            local_symbol: local_symbol.into(),
969            ..Default::default()
970        };
971    }
972
973    // Default to Stock (STK)
974    let symbol_clean = symbol_str.replace('-', " ");
975
976    Contract {
977        symbol: Symbol::from(&symbol_clean),
978        security_type: SecurityType::Stock,
979        exchange: Exchange::from("SMART"),
980        currency: Currency::from(""), // Will be resolved from contract details
981        primary_exchange: Exchange::from(exchange_str),
982        ..Default::default()
983    }
984}
985
986fn instrument_id_to_ib_contract_raw(
987    instrument_id: &InstrumentId,
988    exchange: Option<&str>,
989    futures_year_digits: u8,
990    reference_year: i32,
991    currency: Option<&str>,
992) -> Option<Contract> {
993    let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
994
995    let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
996    let security_type = IbSecurityType::from_str(sec_type_code)
997        .ok()
998        .map(IbSecurityType::ibapi_security_type)?;
999
1000    let default_exchange =
1001        if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
1002            "SMART"
1003        } else {
1004            venue_exchange.as_str()
1005        };
1006
1007    let exchange_str = exchange.unwrap_or(default_exchange);
1008
1009    if futures_year_digits == 2
1010        && matches!(
1011            security_type,
1012            SecurityType::Future | SecurityType::FuturesOption
1013        )
1014        && let Some(contract) = futures_contract_from_symbol(
1015            local_symbol,
1016            security_type.clone(),
1017            exchange_str,
1018            reference_year,
1019            currency,
1020        )
1021    {
1022        return Some(contract);
1023    }
1024
1025    let contract = match security_type {
1026        SecurityType::Stock => Contract {
1027            security_type,
1028            exchange: Exchange::from("SMART"),
1029            primary_exchange: Exchange::from(exchange_str),
1030            local_symbol: local_symbol.to_string(),
1031            ..Default::default()
1032        },
1033        SecurityType::CFD | SecurityType::Commodity => Contract {
1034            security_type,
1035            exchange: Exchange::from("SMART"),
1036            currency: Currency::from(""),
1037            local_symbol: local_symbol.to_string(),
1038            ..Default::default()
1039        },
1040        SecurityType::Index => Contract {
1041            security_type,
1042            exchange: Exchange::from(exchange_str),
1043            local_symbol: local_symbol.to_string(),
1044            ..Default::default()
1045        },
1046        _ => Contract {
1047            security_type,
1048            exchange: Exchange::from(exchange_str),
1049            local_symbol: local_symbol.to_string(),
1050            ..Default::default()
1051        },
1052    };
1053
1054    Some(contract)
1055}
1056
1057/// Currency pair captures
1058struct CurrencyPair {
1059    base: String,
1060    quote: String,
1061}
1062
1063/// Parse cash/forex symbol like "EUR/USD"
1064fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1065    if let Some((base, quote)) = symbol.split_once('/')
1066        && base.len() == 3
1067        && quote.len() == 3
1068    {
1069        return Some(CurrencyPair {
1070            base: base.to_string(),
1071            quote: quote.to_string(),
1072        });
1073    }
1074
1075    None
1076}
1077
1078/// Parse crypto symbol like "BTC/USD".
1079fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
1080    if let Some((base, quote)) = symbol.split_once('/')
1081        && !base.is_empty()
1082        && base.chars().all(|ch| ch.is_ascii_uppercase())
1083        && quote.len() == 3
1084        && quote.chars().all(|ch| ch.is_ascii_uppercase())
1085    {
1086        return Some(CurrencyPair {
1087            base: base.to_string(),
1088            quote: quote.to_string(),
1089        });
1090    }
1091
1092    None
1093}
1094
1095/// Parse CFD cash symbol like "EUR.USD"
1096fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1097    if let Some((base, quote)) = symbol.split_once('.')
1098        && base.len() == 3
1099        && quote.len() == 3
1100    {
1101        return Some(CurrencyPair {
1102            base: base.to_string(),
1103            quote: quote.to_string(),
1104        });
1105    }
1106
1107    None
1108}
1109
1110/// Option symbol captures
1111struct OptionSymbol {
1112    symbol: String,
1113    expiry: String,
1114    right: OptionRight,
1115    local_symbol: String,
1116    strike_value: f64,
1117}
1118
1119/// Parse option symbol like "AAPL230120C00150000" (6-char symbol, 6-char expiry YYMMDD, 1-char right, 8-char strike)
1120fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
1121    // Pattern: SYMBOL + YYMMDD + C/P + STRIKE (8 digits, could have decimal)
1122    // Minimum: 6 (symbol) + 6 (date) + 1 (right) + 8 (strike) = 21 chars
1123    if symbol.len() < 21 {
1124        return None;
1125    }
1126
1127    // Try to match: 6-char symbol, 6-char date, 1-char right (C/P), remainder is strike
1128    let symbol_part = symbol[..6.min(symbol.len())].trim();
1129    let remaining = &symbol[6.min(symbol.len())..];
1130
1131    if remaining.len() < 15 {
1132        return None;
1133    }
1134
1135    let expiry = &remaining[..6];
1136    let right_char = remaining.chars().nth(6)?;
1137    let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1138
1139    let strike_str = &remaining[7..];
1140    if strike_str.len() < 8 {
1141        return None;
1142    }
1143
1144    // Strike is typically 8 digits with possible decimal
1145    let strike_value = if strike_str.contains('.') {
1146        strike_str.parse().ok()?
1147    } else {
1148        // 8-digit integer strike, divide by 1000 for typical option strikes
1149        let strike_int: i32 = strike_str.parse().ok()?;
1150        strike_int as f64 / 1000.0
1151    };
1152
1153    Some(OptionSymbol {
1154        symbol: symbol_part.to_string(),
1155        expiry: expiry.to_string(),
1156        right: ib_option_right_to_option_right(right),
1157        local_symbol: symbol.to_string(),
1158        strike_value,
1159    })
1160}
1161
1162/// Named option symbol captures for formats like "C OESX 20260213 4775".
1163struct NamedOptionSymbol {
1164    trading_class: String,
1165    expiry: String,
1166    right: OptionRight,
1167    strike_value: f64,
1168}
1169
1170/// Named futures symbol captures for formats like "ESTX50 FESX 20240315".
1171struct NamedFuturesSymbol {
1172    underlying: String,
1173    trading_class: String,
1174    expiry: String,
1175}
1176
1177fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
1178    let parts: Vec<&str> = symbol.split_whitespace().collect();
1179    if parts.len() != 3 {
1180        return None;
1181    }
1182
1183    let expiry = parts[2];
1184    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1185        return None;
1186    }
1187
1188    Some(NamedFuturesSymbol {
1189        underlying: parts[0].to_string(),
1190        trading_class: parts[1].to_string(),
1191        expiry: expiry.to_string(),
1192    })
1193}
1194
1195fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1196    let parts: Vec<&str> = symbol.split_whitespace().collect();
1197    if !(parts.len() == 4 || parts.len() == 5) {
1198        return None;
1199    }
1200
1201    let right = IbOptionRight::from_str(parts[0]).ok()?;
1202
1203    let expiry = parts[2];
1204    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1205        return None;
1206    }
1207
1208    Some(NamedOptionSymbol {
1209        trading_class: parts[1].to_string(),
1210        expiry: expiry.to_string(),
1211        right: ib_option_right_to_option_right(right),
1212        strike_value: parts[3].parse::<f64>().ok()?,
1213    })
1214}
1215
1216fn normalize_option_symbol(local_symbol: &str) -> String {
1217    if local_symbol.len() >= 15 {
1218        let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1219        let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1220            && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1221            && suffix[7..].chars().all(|c| c.is_ascii_digit());
1222
1223        if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1224            return format!("{:<6}{}", root.trim_end(), suffix);
1225        }
1226    }
1227
1228    local_symbol.to_string()
1229}
1230
1231fn format_option_strike(strike: f64) -> String {
1232    if strike.fract() == 0.0 {
1233        format!("{strike:.0}")
1234    } else {
1235        format!("{strike}")
1236    }
1237}
1238
1239fn futures_symbol_from_contract(
1240    contract: &Contract,
1241    futures_year_digits: u8,
1242) -> anyhow::Result<String> {
1243    let expiry = contract.last_trade_date_or_contract_month.as_str();
1244    let year = expiry
1245        .get(..4)
1246        .filter(|value| value.bytes().all(|b| b.is_ascii_digit()))
1247        .and_then(|value| value.parse::<i32>().ok())
1248        .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1249
1250    let (futures_symbol, suffix) = if contract.security_type == SecurityType::FuturesOption {
1251        contract
1252            .local_symbol
1253            .split_once(' ')
1254            .map(|(symbol, suffix)| (symbol, Some(suffix)))
1255            .ok_or_else(|| {
1256                anyhow::anyhow!(
1257                    "Invalid futures option local symbol '{}'",
1258                    contract.local_symbol
1259                )
1260            })?
1261    } else if contract.local_symbol.is_empty() {
1262        let month = expiry
1263            .get(4..6)
1264            .and_then(|value| value.parse::<u8>().ok())
1265            .and_then(futures_month_code)
1266            .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1267
1268        let formatted =
1269            format_futures_symbol(contract.symbol.as_str(), month, year, futures_year_digits)
1270                .ok_or_else(|| {
1271                    anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1272                })?;
1273
1274        return Ok(formatted);
1275    } else {
1276        (contract.local_symbol.as_str(), None)
1277    };
1278
1279    let parsed = parse_futures_code(futures_symbol)
1280        .ok_or_else(|| anyhow::anyhow!("Invalid futures local symbol '{futures_symbol}'"))?;
1281    // The expiry field carries the last-trade date on details responses, which can fall in
1282    // the year before the contract month (January contracts such as CLF6 trading last in
1283    // December). Resolving the symbol's own year digits against that year is exact because
1284    // the contract-month year is never earlier than the last-trade year.
1285    let year = resolve_futures_year(parsed.year_digits, year)
1286        .ok_or_else(|| anyhow::anyhow!("Invalid futures year digits '{}'", parsed.year_digits))?;
1287
1288    let formatted =
1289        format_futures_symbol(parsed.root, parsed.month_code, year, futures_year_digits)
1290            .ok_or_else(|| {
1291                anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1292            })?;
1293
1294    Ok(suffix.map_or(formatted.clone(), |suffix| format!("{formatted} {suffix}")))
1295}
1296
1297fn futures_contract_from_symbol(
1298    symbol: &str,
1299    security_type: SecurityType,
1300    exchange: &str,
1301    reference_year: i32,
1302    currency: Option<&str>,
1303) -> Option<Contract> {
1304    let (futures_symbol, option) = if security_type == SecurityType::FuturesOption {
1305        let (futures_symbol, rest) = symbol.split_once(' ')?;
1306        let mut chars = rest.chars();
1307        let right_char = chars.next()?;
1308        let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1309        let strike = chars.as_str().parse::<f64>().ok()?;
1310        (futures_symbol, Some((right, strike)))
1311    } else {
1312        (symbol, None)
1313    };
1314
1315    let parsed = parse_futures_code(futures_symbol)?;
1316    let year = resolve_futures_year(parsed.year_digits, reference_year)?;
1317    let month = futures_month(parsed.month_code)?;
1318
1319    let currency = currency.unwrap_or_else(|| {
1320        log::warn!("Falling back to USD for unresolved IB futures contract {symbol} on {exchange}");
1321        "USD"
1322    });
1323
1324    Some(Contract {
1325        symbol: Symbol::from(parsed.root),
1326        trading_class: parsed.root.to_string(),
1327        security_type,
1328        exchange: Exchange::from(exchange),
1329        currency: Currency::from(currency),
1330        last_trade_date_or_contract_month: format!("{year:04}{month:02}"),
1331        right: option.map(|(right, _)| ib_option_right_to_option_right(right)),
1332        strike: option.map_or(0.0, |(_, strike)| strike),
1333        ..Default::default()
1334    })
1335}
1336
1337fn is_futures_underlying(symbol: &str) -> bool {
1338    symbol.len() <= 3 && symbol.chars().all(char::is_alphabetic)
1339}
1340
1341fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1342    let parsed = parse_futures_code(symbol)?;
1343    Some((
1344        parsed.root.len(),
1345        parsed.month_code,
1346        parsed.year_digits.to_string(),
1347    ))
1348}
1349
1350fn validate_futures_option_symbol(symbol: &str) -> Option<()> {
1351    let (futures_symbol, rest) = symbol.split_once(' ')?;
1352    parse_futures_month_and_year(futures_symbol)?;
1353
1354    let right_char = rest.chars().next()?;
1355    IbOptionRight::from_str(&right_char.to_string()).ok()?;
1356
1357    let strike_str = &rest[1..];
1358    strike_str.parse::<f64>().ok()?;
1359
1360    Some(())
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365    use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1366    use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
1367    use rstest::rstest;
1368
1369    use super::{Symbology, exchange_to_mic_venue, possible_exchanges_for_venue};
1370    use crate::config::SymbologyMethod;
1371
1372    #[rstest]
1373    fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1374        let contract = Contract {
1375            symbol: Symbol::from("SPXW"),
1376            security_type: SecurityType::Option,
1377            exchange: Exchange::from("SMART"),
1378            currency: Currency::from("USD"),
1379            local_symbol: "SPXW260313P06630000".to_string(),
1380            last_trade_date_or_contract_month: "260313".to_string(),
1381            right: Some(OptionRight::Put),
1382            strike: 6630.0,
1383            ..Default::default()
1384        };
1385
1386        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1387            .instrument_id(&contract, None)
1388            .unwrap();
1389
1390        assert_eq!(
1391            instrument_id,
1392            InstrumentId::from("SPXW  260313P06630000.SMART")
1393        );
1394    }
1395
1396    #[rstest]
1397    fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1398        let contract = Contract {
1399            symbol: Symbol::from("OESX"),
1400            security_type: SecurityType::Option,
1401            exchange: Exchange::from("EUREX"),
1402            currency: Currency::from("EUR"),
1403            trading_class: "OESX".to_string(),
1404            local_symbol: String::new(),
1405            last_trade_date_or_contract_month: "20260213".to_string(),
1406            right: Some(OptionRight::Call),
1407            strike: 4775.0,
1408            ..Default::default()
1409        };
1410
1411        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1412            .instrument_id(&contract, None)
1413            .unwrap();
1414
1415        assert_eq!(
1416            instrument_id,
1417            InstrumentId::from("C OESX 20260213 4775.EUREX")
1418        );
1419    }
1420
1421    #[rstest]
1422    fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1423        let contract = Contract {
1424            symbol: Symbol::from("EX2"),
1425            security_type: SecurityType::FuturesOption,
1426            exchange: Exchange::from("NYBOT"),
1427            currency: Currency::from("USD"),
1428            local_symbol: "EX2G3 P4080".to_string(),
1429            ..Default::default()
1430        };
1431
1432        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1433            .instrument_id(&contract, None)
1434            .unwrap();
1435
1436        assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1437    }
1438
1439    #[rstest]
1440    #[case(SecurityType::Future, "ESZ6", "ESZ6.XCME")]
1441    #[case(SecurityType::FuturesOption, "ESZ6 P5000", "ESZ6 P5000.XCME")]
1442    fn test_ib_contract_to_instrument_id_keeps_the_local_symbol_year(
1443        #[case] security_type: SecurityType,
1444        #[case] local_symbol: &str,
1445        #[case] expected: &str,
1446    ) {
1447        let contract = Contract {
1448            symbol: Symbol::from("ES"),
1449            security_type,
1450            exchange: Exchange::from("CME"),
1451            currency: Currency::from("USD"),
1452            local_symbol: local_symbol.to_string(),
1453            last_trade_date_or_contract_month: "202612".to_string(),
1454            ..Default::default()
1455        };
1456
1457        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1458            .instrument_id(&contract, Some(Venue::from("XCME")))
1459            .unwrap();
1460
1461        assert_eq!(instrument_id, InstrumentId::from(expected));
1462    }
1463
1464    #[rstest]
1465    #[case(SecurityType::Future, "CLF6", "20251219", "CLF6.XNYM")]
1466    #[case(SecurityType::Future, "ESZ6", "20261218", "ESZ6.XNYM")]
1467    #[case(
1468        SecurityType::FuturesOption,
1469        "CLF6 P7000",
1470        "20251216",
1471        "CLF6 P7000.XNYM"
1472    )]
1473    fn test_local_symbol_year_is_not_rewritten_from_last_trade_date(
1474        #[case] security_type: SecurityType,
1475        #[case] local_symbol: &str,
1476        #[case] last_trade_date: &str,
1477        #[case] expected: &str,
1478    ) {
1479        let contract = Contract {
1480            symbol: Symbol::from("CL"),
1481            security_type,
1482            exchange: Exchange::from("NYMEX"),
1483            currency: Currency::from("USD"),
1484            local_symbol: local_symbol.to_string(),
1485            last_trade_date_or_contract_month: last_trade_date.to_string(),
1486            ..Default::default()
1487        };
1488
1489        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1490            .instrument_id(&contract, Some(Venue::from("XNYM")))
1491            .unwrap();
1492
1493        assert_eq!(instrument_id, InstrumentId::from(expected));
1494    }
1495
1496    #[rstest]
1497    fn test_non_grammar_local_symbol_is_emitted_verbatim() {
1498        let contract = Contract {
1499            symbol: Symbol::from("ZC"),
1500            security_type: SecurityType::Future,
1501            exchange: Exchange::from("CBOT"),
1502            currency: Currency::from("USD"),
1503            local_symbol: "ZC   DEC 26".to_string(),
1504            last_trade_date_or_contract_month: "20261214".to_string(),
1505            ..Default::default()
1506        };
1507
1508        let simplified = Symbology::new(SymbologyMethod::Simplified)
1509            .instrument_id(&contract, Some(Venue::from("XCBT")))
1510            .unwrap();
1511        let raw = Symbology::new(SymbologyMethod::Raw)
1512            .instrument_id(&contract, Some(Venue::from("XCBT")))
1513            .unwrap();
1514
1515        assert_eq!(simplified, InstrumentId::from("ZC   DEC 26.XCBT"));
1516        assert_eq!(raw, InstrumentId::from("ZC   DEC 26=FUT.XCBT"));
1517    }
1518
1519    #[rstest]
1520    fn test_fop_without_local_symbol_falls_back_to_contract_fields() {
1521        let contract = Contract {
1522            symbol: Symbol::from("ES"),
1523            security_type: SecurityType::FuturesOption,
1524            exchange: Exchange::from("CME"),
1525            currency: Currency::from("USD"),
1526            local_symbol: String::new(),
1527            last_trade_date_or_contract_month: "20261218".to_string(),
1528            right: Some(OptionRight::Put),
1529            strike: 5000.0,
1530            ..Default::default()
1531        };
1532
1533        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1534            .instrument_id(&contract, Some(Venue::from("XCME")))
1535            .unwrap();
1536
1537        assert_eq!(instrument_id, InstrumentId::from("ES20261218 P5000.XCME"));
1538    }
1539
1540    #[rstest]
1541    #[case("ESZ6", SecurityType::Future)]
1542    #[case("ESZ26", SecurityType::Future)]
1543    #[case("ESZ2026", SecurityType::Future)]
1544    #[case("ESZ26 P5000", SecurityType::FuturesOption)]
1545    fn test_futures_id_keeps_its_symbol_as_the_contract_local_symbol_outbound(
1546        #[case] symbol: &str,
1547        #[case] security_type: SecurityType,
1548    ) {
1549        let instrument_id = InstrumentId::new(NautilusSymbol::from(symbol), Venue::from("XCME"));
1550
1551        let contract = Symbology::new(SymbologyMethod::Simplified)
1552            .contract(instrument_id, None)
1553            .unwrap();
1554
1555        assert_eq!(contract.security_type, security_type);
1556        assert_eq!(contract.local_symbol.as_str(), symbol);
1557        assert_eq!(contract.exchange.as_str(), "CME");
1558        assert_eq!(contract.currency.as_str(), "USD");
1559        assert!(contract.symbol.as_str().is_empty());
1560        assert!(contract.trading_class.is_empty());
1561        assert!(contract.last_trade_date_or_contract_month.is_empty());
1562        assert_eq!(contract.right, None);
1563        assert_eq!(contract.strike, 0.0);
1564    }
1565
1566    #[rstest]
1567    fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1568        let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1569
1570        let contract = Symbology::new(SymbologyMethod::Simplified)
1571            .contract(instrument_id, None)
1572            .unwrap();
1573
1574        assert_eq!(contract.security_type, SecurityType::Option);
1575        assert_eq!(contract.exchange.as_str(), "EUREX");
1576        assert_eq!(contract.symbol.as_str(), "OESX");
1577        assert_eq!(contract.trading_class.as_str(), "OESX");
1578        assert_eq!(
1579            contract.last_trade_date_or_contract_month.as_str(),
1580            "20260213"
1581        );
1582        assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1583        assert_eq!(contract.strike, 4775.0);
1584    }
1585
1586    #[rstest]
1587    fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1588        let instrument_id = InstrumentId::from("AAPL  230217P00155000.SMART");
1589
1590        let contract = Symbology::new(SymbologyMethod::Simplified)
1591            .contract(instrument_id, None)
1592            .unwrap();
1593
1594        assert_eq!(contract.security_type, SecurityType::Option);
1595        assert_eq!(contract.exchange.as_str(), "SMART");
1596        assert_eq!(contract.symbol.as_str(), "AAPL");
1597        assert_eq!(contract.local_symbol.as_str(), "AAPL  230217P00155000");
1598        assert_eq!(
1599            contract.last_trade_date_or_contract_month.as_str(),
1600            "230217"
1601        );
1602        assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1603        assert_eq!(contract.strike, 155.0);
1604    }
1605
1606    #[rstest]
1607    fn test_possible_exchanges_for_opra_routes_to_smart() {
1608        assert_eq!(
1609            possible_exchanges_for_venue("OPRA"),
1610            vec!["SMART".to_string()]
1611        );
1612    }
1613
1614    #[rstest]
1615    fn test_opra_occ_option_default_contract_routes_to_smart() {
1616        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1617
1618        let contract = Symbology::new(SymbologyMethod::Simplified)
1619            .contract(instrument_id, None)
1620            .unwrap();
1621
1622        assert_eq!(contract.security_type, SecurityType::Option);
1623        assert_eq!(contract.exchange.as_str(), "SMART");
1624        assert!(contract.symbol.as_str().is_empty());
1625        assert_eq!(contract.currency.as_str(), "USD");
1626        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1627        assert!(contract.last_trade_date_or_contract_month.is_empty());
1628        assert!(contract.right.is_none());
1629        assert_eq!(contract.strike, 0.0);
1630    }
1631
1632    #[rstest]
1633    fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1634        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1635        let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1636
1637        assert_eq!(exchanges, vec!["SMART".to_string()]);
1638
1639        let contract = Symbology::new(SymbologyMethod::Simplified)
1640            .contract(instrument_id, exchanges.first().map(String::as_str))
1641            .unwrap();
1642
1643        assert_eq!(contract.security_type, SecurityType::Option);
1644        assert_eq!(contract.exchange.as_str(), "SMART");
1645        assert!(contract.symbol.as_str().is_empty());
1646        assert_eq!(contract.currency.as_str(), "USD");
1647        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1648        assert!(contract.last_trade_date_or_contract_month.is_empty());
1649        assert!(contract.right.is_none());
1650        assert_eq!(contract.strike, 0.0);
1651    }
1652
1653    #[rstest]
1654    fn test_opra_raw_option_default_contract_routes_to_smart() {
1655        let instrument_id = InstrumentId::from("SPY   240319P00511000=OPT.OPRA");
1656
1657        let contract = Symbology::new(SymbologyMethod::Raw)
1658            .contract(instrument_id, None)
1659            .unwrap();
1660
1661        assert_eq!(contract.security_type, SecurityType::Option);
1662        assert_eq!(contract.exchange.as_str(), "SMART");
1663        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1664    }
1665
1666    #[rstest]
1667    #[case("SPY   240319P00511000.OPRA")]
1668    #[case("SPY   240319P00511000=OPT.OPRA")]
1669    fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1670        let instrument_id = InstrumentId::from(value);
1671
1672        let method = if value.contains("=OPT") {
1673            SymbologyMethod::Raw
1674        } else {
1675            SymbologyMethod::Simplified
1676        };
1677
1678        let contract = Symbology::new(method)
1679            .contract(instrument_id, Some("CBOE"))
1680            .unwrap();
1681
1682        assert_eq!(contract.security_type, SecurityType::Option);
1683        assert_eq!(contract.exchange.as_str(), "CBOE");
1684    }
1685
1686    #[rstest]
1687    #[case("AAPL.OPRA")]
1688    #[case("SPY   abcdefP00511000.OPRA")]
1689    #[case("P SPY 20240319 511.OPRA")]
1690    fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1691        let instrument_id = InstrumentId::from(value);
1692
1693        let result = Symbology::new(SymbologyMethod::Simplified).contract(instrument_id, None);
1694
1695        assert!(result.is_err());
1696    }
1697
1698    #[rstest]
1699    fn test_opra_route_does_not_create_reverse_smart_mapping() {
1700        assert_eq!(exchange_to_mic_venue("SMART"), None);
1701    }
1702
1703    #[rstest]
1704    fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1705        let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1706
1707        let contract = Symbology::new(SymbologyMethod::Simplified)
1708            .contract(instrument_id, None)
1709            .unwrap();
1710
1711        assert_eq!(contract.security_type, SecurityType::Future);
1712        assert_eq!(contract.exchange.as_str(), "EUREX");
1713        assert_eq!(contract.symbol.as_str(), "ESTX50");
1714        assert_eq!(contract.trading_class.as_str(), "FESX");
1715        assert_eq!(
1716            contract.last_trade_date_or_contract_month.as_str(),
1717            "20240315"
1718        );
1719    }
1720
1721    #[rstest]
1722    fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1723        let instrument_id = InstrumentId::from("YMM6.XCBT");
1724
1725        let contract = Symbology::new(SymbologyMethod::Simplified)
1726            .contract(instrument_id, None)
1727            .unwrap();
1728
1729        assert_eq!(contract.security_type, SecurityType::Future);
1730        assert_eq!(contract.exchange.as_str(), "CBOT");
1731        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1732        assert!(contract.symbol.as_str().is_empty());
1733        assert!(contract.last_trade_date_or_contract_month.is_empty());
1734    }
1735
1736    #[rstest]
1737    fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1738        let instrument_id = InstrumentId::from("OESXH6.XEUR");
1739
1740        let contract = Symbology::new(SymbologyMethod::Simplified)
1741            .contract(instrument_id, None)
1742            .unwrap();
1743
1744        assert_eq!(contract.security_type, SecurityType::Future);
1745        assert_eq!(contract.exchange.as_str(), "DTB");
1746        assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1747        assert!(contract.symbol.as_str().is_empty());
1748        assert!(contract.last_trade_date_or_contract_month.is_empty());
1749    }
1750
1751    #[rstest]
1752    fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1753        let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1754
1755        let contract = Symbology::new(SymbologyMethod::Simplified)
1756            .contract(instrument_id, None)
1757            .unwrap();
1758
1759        assert_eq!(contract.security_type, SecurityType::FuturesOption);
1760        assert_eq!(contract.exchange.as_str(), "CBOT");
1761        assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1762        assert!(contract.symbol.as_str().is_empty());
1763        assert!(contract.last_trade_date_or_contract_month.is_empty());
1764        assert_eq!(contract.right, None);
1765        assert_eq!(contract.strike, 0.0);
1766    }
1767
1768    #[rstest]
1769    fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1770        let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1771
1772        let contract = Symbology::new(SymbologyMethod::Simplified)
1773            .contract(instrument_id, None)
1774            .unwrap();
1775
1776        assert_eq!(contract.security_type, SecurityType::CFD);
1777        assert_eq!(contract.exchange.as_str(), "SMART");
1778        assert_eq!(contract.symbol.as_str(), "EUR");
1779        assert_eq!(contract.currency.as_str(), "USD");
1780        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1781    }
1782
1783    #[rstest]
1784    #[case(
1785        SymbologyMethod::Simplified,
1786        SecurityType::CFD,
1787        "IBDE30",
1788        "EUR",
1789        "IBDE30.IBCFD"
1790    )]
1791    #[case(
1792        SymbologyMethod::Raw,
1793        SecurityType::CFD,
1794        "IBDE30",
1795        "EUR",
1796        "IBDE30=CFD.IBCFD"
1797    )]
1798    #[case(
1799        SymbologyMethod::Simplified,
1800        SecurityType::Commodity,
1801        "XAUUSD",
1802        "GBP",
1803        "XAUUSD.IBCMDTY"
1804    )]
1805    #[case(
1806        SymbologyMethod::Raw,
1807        SecurityType::Commodity,
1808        "XAUUSD",
1809        "GBP",
1810        "XAUUSD=CMDTY.IBCMDTY"
1811    )]
1812    fn test_non_cash_pseudo_venue_round_trip_defers_currency_to_contract_details(
1813        #[case] method: SymbologyMethod,
1814        #[case] security_type: SecurityType,
1815        #[case] symbol: &str,
1816        #[case] currency: &str,
1817        #[case] expected_instrument_id: &str,
1818    ) {
1819        let contract = Contract {
1820            symbol: Symbol::from(symbol),
1821            security_type: security_type.clone(),
1822            exchange: Exchange::from("SMART"),
1823            currency: Currency::from(currency),
1824            local_symbol: symbol.to_string(),
1825            ..Default::default()
1826        };
1827
1828        let symbology = Symbology::new(method);
1829
1830        let instrument_id = symbology.instrument_id(&contract, None).unwrap();
1831        let unresolved = symbology.contract(instrument_id, None).unwrap();
1832        let round_trip_id = symbology.instrument_id(&unresolved, None).unwrap();
1833
1834        assert_eq!(instrument_id, InstrumentId::from(expected_instrument_id));
1835        assert_eq!(unresolved.security_type, security_type);
1836        assert_eq!(unresolved.exchange.as_str(), "SMART");
1837        assert!(unresolved.currency.as_str().is_empty());
1838        assert_eq!(round_trip_id, instrument_id);
1839    }
1840
1841    #[rstest]
1842    fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1843        let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1844
1845        let contract = Symbology::new(SymbologyMethod::Simplified)
1846            .contract(instrument_id, None)
1847            .unwrap();
1848
1849        assert_eq!(contract.security_type, SecurityType::Crypto);
1850        assert_eq!(contract.exchange.as_str(), "PAXOS");
1851        assert_eq!(contract.symbol.as_str(), "DOGE");
1852        assert_eq!(contract.currency.as_str(), "USD");
1853        assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1854    }
1855
1856    #[rstest]
1857    fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1858        // ZEROHASH is one of IB's crypto venues (alongside PAXOS).
1859        let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1860
1861        let contract = Symbology::new(SymbologyMethod::Simplified)
1862            .contract(instrument_id, None)
1863            .unwrap();
1864
1865        assert_eq!(contract.security_type, SecurityType::Crypto);
1866        assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1867        assert_eq!(contract.symbol.as_str(), "BTC");
1868        assert_eq!(contract.currency.as_str(), "USD");
1869        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1870    }
1871
1872    #[rstest]
1873    fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1874        // Contract -> InstrumentId derives the venue from the ZEROHASH exchange.
1875        let contract = Contract {
1876            symbol: Symbol::from("BTC"),
1877            security_type: SecurityType::Crypto,
1878            exchange: Exchange::from("ZEROHASH"),
1879            currency: Currency::from("USD"),
1880            local_symbol: "BTC.USD".to_string(),
1881            ..Default::default()
1882        };
1883
1884        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1885            .instrument_id(&contract, None)
1886            .unwrap();
1887
1888        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1889    }
1890
1891    #[rstest]
1892    fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1893        // Contract -> InstrumentId falls back to PAXOS when no exchange is set,
1894        // preserving backwards compatibility.
1895        let contract = Contract {
1896            symbol: Symbol::from("DOGE"),
1897            security_type: SecurityType::Crypto,
1898            currency: Currency::from("USD"),
1899            local_symbol: "DOGE.USD".to_string(),
1900            ..Default::default()
1901        };
1902
1903        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1904            .instrument_id(&contract, None)
1905            .unwrap();
1906
1907        assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1908    }
1909
1910    #[rstest]
1911    fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1912        // PAXOS remains a live IB crypto venue alongside ZEROHASH.
1913        let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1914
1915        let contract = Symbology::new(SymbologyMethod::Simplified)
1916            .contract(instrument_id, None)
1917            .unwrap();
1918
1919        assert_eq!(contract.security_type, SecurityType::Crypto);
1920        assert_eq!(contract.exchange.as_str(), "PAXOS");
1921        assert_eq!(contract.symbol.as_str(), "BTC");
1922        assert_eq!(contract.currency.as_str(), "USD");
1923        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1924    }
1925
1926    #[rstest]
1927    fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1928        // A PAXOS contract derives the PAXOS venue from its exchange (not via the
1929        // fallback), proving both venues are honored when explicitly set.
1930        let contract = Contract {
1931            symbol: Symbol::from("BTC"),
1932            security_type: SecurityType::Crypto,
1933            exchange: Exchange::from("PAXOS"),
1934            currency: Currency::from("USD"),
1935            local_symbol: "BTC.USD".to_string(),
1936            ..Default::default()
1937        };
1938
1939        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1940            .instrument_id(&contract, None)
1941            .unwrap();
1942
1943        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1944    }
1945
1946    #[rstest]
1947    fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1948        let instrument_id = InstrumentId::from("ES.XCME");
1949
1950        let contract = Symbology::new(SymbologyMethod::Simplified)
1951            .contract(instrument_id, None)
1952            .unwrap();
1953
1954        assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1955        assert_eq!(contract.exchange.as_str(), "CME");
1956        assert_eq!(contract.symbol.as_str(), "ES");
1957    }
1958
1959    #[rstest]
1960    fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1961        let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1962
1963        let contract = Symbology::new(SymbologyMethod::Simplified)
1964            .contract(instrument_id, None)
1965            .unwrap();
1966
1967        assert_eq!(contract.security_type, SecurityType::Stock);
1968        assert_eq!(contract.exchange.as_str(), "SMART");
1969        assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1970        assert_eq!(contract.local_symbol.as_str(), "AAPL");
1971        assert!(contract.symbol.as_str().is_empty());
1972    }
1973
1974    #[rstest]
1975    fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1976        let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1977
1978        let contract = Symbology::new(SymbologyMethod::Simplified)
1979            .contract(instrument_id, None)
1980            .unwrap();
1981
1982        assert_eq!(contract.security_type, SecurityType::ForexPair);
1983        assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1984        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1985        assert!(contract.symbol.as_str().is_empty());
1986    }
1987
1988    #[rstest]
1989    fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1990        let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1991
1992        let contract = Symbology::new(SymbologyMethod::Simplified)
1993            .contract(instrument_id, Some("CBOT"))
1994            .unwrap();
1995
1996        assert_eq!(contract.security_type, SecurityType::Future);
1997        assert_eq!(contract.exchange.as_str(), "CBOT");
1998        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1999        assert!(contract.symbol.as_str().is_empty());
2000        assert!(contract.last_trade_date_or_contract_month.is_empty());
2001    }
2002
2003    #[rstest]
2004    fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
2005        let instrument_id = InstrumentId::from("IUSA.IBIS");
2006
2007        let contract = Symbology::new(SymbologyMethod::Simplified)
2008            .contract(instrument_id, Some("IBIS"))
2009            .unwrap();
2010
2011        assert_eq!(contract.security_type, SecurityType::Stock);
2012        assert_eq!(contract.symbol.as_str(), "IUSA");
2013        assert_eq!(contract.exchange.as_str(), "SMART");
2014        assert_eq!(contract.primary_exchange.as_str(), "IBIS");
2015        assert!(contract.currency.as_str().is_empty());
2016        assert!(contract.local_symbol.is_empty());
2017    }
2018}