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/// Determine venue from contract using provider configuration.
638///
639/// This implements the same logic as Python's `determine_venue_from_contract`:
640/// 1. Check symbol-specific venue mapping first (prefix matching)
641/// 2. Use VENUE_MEMBERS mapping if convert_exchange_to_mic_venue is enabled
642/// 3. Fall back to exchange
643pub fn determine_venue_from_contract(
644    contract: &Contract,
645    symbol_to_mic_venue: &std::collections::HashMap<String, String>,
646    convert_exchange_to_mic_venue: bool,
647    valid_exchanges: Option<&str>,
648) -> String {
649    if matches!(contract.security_type, SecurityType::CFD) {
650        return "IBCFD".to_string();
651    }
652
653    if matches!(contract.security_type, SecurityType::Commodity) {
654        return "IBCMDTY".to_string();
655    }
656
657    if !symbol_to_mic_venue.is_empty() {
658        let symbol = contract.symbol.as_str();
659        for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
660            if symbol.starts_with(symbol_prefix) {
661                return symbol_venue.clone();
662            }
663        }
664    }
665
666    // Use the exchange from the contract (primaryExchange if exchange is SMART)
667    let mut exchange = if contract.exchange.as_str() == "SMART"
668        && !contract.primary_exchange.as_str().is_empty()
669        && contract.primary_exchange.as_str() != "SMART"
670    {
671        contract.primary_exchange.as_str().to_string()
672    } else {
673        contract.exchange.as_str().to_string()
674    };
675
676    if exchange == "SMART"
677        && let Some(valid_exchanges) = valid_exchanges
678    {
679        let parts: Vec<&str> = valid_exchanges
680            .split(',')
681            .map(str::trim)
682            .filter(|part| !part.is_empty())
683            .collect();
684
685        if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
686            exchange = (*chosen).to_string();
687        } else if let Some(first) = parts.first() {
688            exchange = (*first).to_string();
689        }
690    }
691
692    if convert_exchange_to_mic_venue && let Some(venue) = exchange_to_mic_venue(&exchange) {
693        return venue;
694    }
695
696    exchange
697}
698
699/// Convert an Interactive Brokers exchange code to a MIC venue when known.
700#[must_use]
701pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
702    VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
703        exchanges
704            .contains(&exchange)
705            .then(|| (*venue_member).to_string())
706    })
707}
708
709/// Convert a NautilusTrader `InstrumentId` to an Interactive Brokers `Contract`.
710///
711/// This function handles all instrument types:
712/// - Stocks (STK)
713/// - Options (OPT)
714/// - Futures (FUT, CONTFUT)
715/// - Futures Options (FOP)
716/// - Forex (CASH)
717/// - Crypto (CRYPTO)
718/// - CFDs (CFD)
719/// - Commodities (CMDTY)
720/// - Indices (IND)
721/// - Option Spreads (BAG) - requires contract details map
722fn instrument_id_to_contract(
723    instrument_id: InstrumentId,
724    exchange: Option<&str>,
725    futures_year_digits: u8,
726    reference_year: i32,
727    currency: Option<&str>,
728) -> Contract {
729    let venue_str = instrument_id.venue.to_string();
730
731    let derived_exchange = if venue_str == "OPRA" {
732        "SMART"
733    } else {
734        VENUE_MEMBERS
735            .get(venue_str.as_str())
736            .and_then(|exchanges| exchanges.first().copied())
737            .or_else(|| {
738                if venue_matches(venue_str.as_str(), VENUES_CASH)
739                    || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
740                    || venue_matches(venue_str.as_str(), VENUES_OPT)
741                    || venue_matches(venue_str.as_str(), VENUES_FUT)
742                {
743                    Some(venue_str.as_str())
744                } else {
745                    None
746                }
747            })
748            .unwrap_or("SMART")
749    };
750
751    let exchange_str = exchange.unwrap_or(derived_exchange);
752    let symbol_str = instrument_id.symbol.as_str();
753
754    if let Some(contract) = instrument_id_to_ib_contract_raw(
755        &instrument_id,
756        exchange,
757        futures_year_digits,
758        reference_year,
759        currency,
760    ) {
761        return contract;
762    }
763
764    // Handle spreads (BAG contracts) - requires contract details, so we skip for now
765    // This should be handled by the instrument provider which has access to contract details
766    // if symbol_str.contains(":") {
767    //     return create_bag_contract(instrument_id, exchange_str);
768    // }
769
770    // Handle Forex (CASH)
771    if venue_matches(venue_str.as_str(), VENUES_CASH)
772        && let Some(captures) = parse_cash_symbol(symbol_str)
773    {
774        return Contract {
775            symbol: Symbol::from(&captures.base),
776            security_type: SecurityType::ForexPair,
777            exchange: Exchange::from(exchange_str),
778            currency: Currency::from(&captures.quote),
779            local_symbol: format!("{}.{}", captures.base, captures.quote),
780            ..Default::default()
781        };
782    }
783
784    // Handle Crypto
785    if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
786        && let Some(captures) = parse_crypto_symbol(symbol_str)
787    {
788        return Contract {
789            symbol: Symbol::from(&captures.base),
790            security_type: SecurityType::Crypto,
791            exchange: Exchange::from(exchange_str),
792            currency: Currency::from(&captures.quote),
793            local_symbol: format!("{}.{}", captures.base, captures.quote),
794            ..Default::default()
795        };
796    }
797
798    // Handle Options (OPT)
799    if is_option_venue(venue_str.as_str()) {
800        if venue_str == "OPRA" {
801            return Contract {
802                security_type: SecurityType::Option,
803                exchange: Exchange::from(exchange_str),
804                currency: Currency::from("USD"),
805                local_symbol: symbol_str.to_string(),
806                ..Default::default()
807            };
808        }
809
810        if let Some(opt) = parse_option_symbol(symbol_str) {
811            return Contract {
812                symbol: Symbol::from(&opt.symbol),
813                security_type: SecurityType::Option,
814                exchange: Exchange::from(exchange_str),
815                currency: Currency::from("USD"), // Will be resolved from contract details
816                local_symbol: opt.local_symbol,
817                last_trade_date_or_contract_month: opt.expiry,
818                strike: opt.strike_value,
819                right: Some(opt.right),
820                ..Default::default()
821            };
822        }
823
824        if let Some(opt) = parse_named_option_symbol(symbol_str) {
825            return Contract {
826                symbol: Symbol::from(&opt.trading_class),
827                security_type: SecurityType::Option,
828                exchange: Exchange::from(exchange_str),
829                currency: Currency::from("USD"),
830                trading_class: opt.trading_class,
831                last_trade_date_or_contract_month: opt.expiry,
832                strike: opt.strike_value,
833                right: Some(opt.right),
834                ..Default::default()
835            };
836        }
837    }
838
839    // Handle Futures and Futures Options
840    if venue_matches(venue_str.as_str(), VENUES_FUT) {
841        if let Some(fut) = parse_named_futures_symbol(symbol_str) {
842            return Contract {
843                symbol: Symbol::from(&fut.underlying),
844                security_type: SecurityType::Future,
845                exchange: Exchange::from(exchange_str),
846                currency: Currency::from("USD"),
847                trading_class: fut.trading_class,
848                last_trade_date_or_contract_month: fut.expiry,
849                ..Default::default()
850            };
851        }
852
853        // Check for continuous futures (underlying only, no expiry)
854        // IB uses FUT with no expiry date to represent continuous futures
855        if is_futures_underlying(symbol_str) {
856            return Contract {
857                symbol: Symbol::from(symbol_str),
858                security_type: SecurityType::ContinuousFuture,
859                exchange: Exchange::from(exchange_str),
860                currency: Currency::from("USD"), // Will be resolved from contract details
861                ..Default::default()
862            };
863        }
864
865        if futures_year_digits == 2
866            && let Some(contract) = futures_contract_from_symbol(
867                symbol_str,
868                SecurityType::FuturesOption,
869                exchange_str,
870                reference_year,
871                currency,
872            )
873        {
874            return contract;
875        }
876
877        // Check for Futures Options (FOP)
878        if validate_futures_option_symbol(symbol_str).is_some() {
879            return Contract {
880                security_type: SecurityType::FuturesOption,
881                exchange: Exchange::from(exchange_str),
882                currency: Currency::from("USD"),
883                local_symbol: symbol_str.to_string(),
884                ..Default::default()
885            };
886        }
887
888        if futures_year_digits == 2
889            && let Some(contract) = futures_contract_from_symbol(
890                symbol_str,
891                SecurityType::Future,
892                exchange_str,
893                reference_year,
894                currency,
895            )
896        {
897            return contract;
898        }
899
900        // Check for regular Futures (FUT)
901        if parse_futures_month_and_year(symbol_str).is_some() {
902            return Contract {
903                security_type: SecurityType::Future,
904                exchange: Exchange::from(exchange_str),
905                currency: Currency::from("USD"),
906                local_symbol: symbol_str.to_string(),
907                ..Default::default()
908            };
909        }
910    }
911
912    // Handle CFDs
913    if venue_matches(venue_str.as_str(), VENUES_CFD) {
914        if let Some(captures) =
915            parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
916        {
917            return Contract {
918                symbol: Symbol::from(&captures.base),
919                security_type: SecurityType::CFD,
920                exchange: Exchange::from("SMART"),
921                currency: Currency::from(&captures.quote),
922                local_symbol: format!("{}.{}", captures.base, captures.quote),
923                ..Default::default()
924            };
925        } else {
926            // CFD with space-separated symbol
927            let symbol_clean = symbol_str.replace('-', " ");
928
929            return Contract {
930                symbol: Symbol::from(&symbol_clean),
931                security_type: SecurityType::CFD,
932                exchange: Exchange::from("SMART"),
933                currency: Currency::from(""),
934                ..Default::default()
935            };
936        }
937    }
938
939    // Handle Commodities
940    if VENUES_CMDTY.contains(&venue_str.as_str()) {
941        let symbol_clean = symbol_str.replace('-', " ");
942
943        return Contract {
944            symbol: Symbol::from(&symbol_clean),
945            security_type: SecurityType::Commodity,
946            exchange: Exchange::from("SMART"),
947            currency: Currency::from(""),
948            ..Default::default()
949        };
950    }
951
952    // Handle Indices (symbols starting with ^)
953    if let Some(local_symbol) = symbol_str.strip_prefix('^') {
954        return Contract {
955            symbol: Symbol::from(local_symbol),
956            security_type: SecurityType::Index,
957            exchange: Exchange::from(exchange_str),
958            currency: Currency::from("USD"),
959            local_symbol: local_symbol.into(),
960            ..Default::default()
961        };
962    }
963
964    // Default to Stock (STK)
965    let symbol_clean = symbol_str.replace('-', " ");
966
967    Contract {
968        symbol: Symbol::from(&symbol_clean),
969        security_type: SecurityType::Stock,
970        exchange: Exchange::from("SMART"),
971        currency: Currency::from(""), // Will be resolved from contract details
972        primary_exchange: Exchange::from(exchange_str),
973        ..Default::default()
974    }
975}
976
977fn instrument_id_to_ib_contract_raw(
978    instrument_id: &InstrumentId,
979    exchange: Option<&str>,
980    futures_year_digits: u8,
981    reference_year: i32,
982    currency: Option<&str>,
983) -> Option<Contract> {
984    let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
985
986    let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
987    let security_type = IbSecurityType::from_str(sec_type_code)
988        .ok()
989        .map(IbSecurityType::ibapi_security_type)?;
990
991    let default_exchange =
992        if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
993            "SMART"
994        } else {
995            venue_exchange.as_str()
996        };
997
998    let exchange_str = exchange.unwrap_or(default_exchange);
999
1000    if futures_year_digits == 2
1001        && matches!(
1002            security_type,
1003            SecurityType::Future | SecurityType::FuturesOption
1004        )
1005        && let Some(contract) = futures_contract_from_symbol(
1006            local_symbol,
1007            security_type.clone(),
1008            exchange_str,
1009            reference_year,
1010            currency,
1011        )
1012    {
1013        return Some(contract);
1014    }
1015
1016    let contract = match security_type {
1017        SecurityType::Stock => Contract {
1018            security_type,
1019            exchange: Exchange::from("SMART"),
1020            primary_exchange: Exchange::from(exchange_str),
1021            local_symbol: local_symbol.to_string(),
1022            ..Default::default()
1023        },
1024        SecurityType::CFD | SecurityType::Commodity => Contract {
1025            security_type,
1026            exchange: Exchange::from("SMART"),
1027            currency: Currency::from(""),
1028            local_symbol: local_symbol.to_string(),
1029            ..Default::default()
1030        },
1031        SecurityType::Index => Contract {
1032            security_type,
1033            exchange: Exchange::from(exchange_str),
1034            local_symbol: local_symbol.to_string(),
1035            ..Default::default()
1036        },
1037        _ => Contract {
1038            security_type,
1039            exchange: Exchange::from(exchange_str),
1040            local_symbol: local_symbol.to_string(),
1041            ..Default::default()
1042        },
1043    };
1044
1045    Some(contract)
1046}
1047
1048/// Currency pair captures
1049struct CurrencyPair {
1050    base: String,
1051    quote: String,
1052}
1053
1054/// Parse cash/forex symbol like "EUR/USD"
1055fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1056    if let Some((base, quote)) = symbol.split_once('/')
1057        && base.len() == 3
1058        && quote.len() == 3
1059    {
1060        return Some(CurrencyPair {
1061            base: base.to_string(),
1062            quote: quote.to_string(),
1063        });
1064    }
1065
1066    None
1067}
1068
1069/// Parse crypto symbol like "BTC/USD".
1070fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
1071    if let Some((base, quote)) = symbol.split_once('/')
1072        && !base.is_empty()
1073        && base.chars().all(|ch| ch.is_ascii_uppercase())
1074        && quote.len() == 3
1075        && quote.chars().all(|ch| ch.is_ascii_uppercase())
1076    {
1077        return Some(CurrencyPair {
1078            base: base.to_string(),
1079            quote: quote.to_string(),
1080        });
1081    }
1082
1083    None
1084}
1085
1086/// Parse CFD cash symbol like "EUR.USD"
1087fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1088    if let Some((base, quote)) = symbol.split_once('.')
1089        && base.len() == 3
1090        && quote.len() == 3
1091    {
1092        return Some(CurrencyPair {
1093            base: base.to_string(),
1094            quote: quote.to_string(),
1095        });
1096    }
1097
1098    None
1099}
1100
1101/// Option symbol captures
1102struct OptionSymbol {
1103    symbol: String,
1104    expiry: String,
1105    right: OptionRight,
1106    local_symbol: String,
1107    strike_value: f64,
1108}
1109
1110/// Parse option symbol like "AAPL230120C00150000" (6-char symbol, 6-char expiry YYMMDD, 1-char right, 8-char strike)
1111fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
1112    // Pattern: SYMBOL + YYMMDD + C/P + STRIKE (8 digits, could have decimal)
1113    // Minimum: 6 (symbol) + 6 (date) + 1 (right) + 8 (strike) = 21 chars
1114    if symbol.len() < 21 {
1115        return None;
1116    }
1117
1118    // Try to match: 6-char symbol, 6-char date, 1-char right (C/P), remainder is strike
1119    let symbol_part = symbol[..6.min(symbol.len())].trim();
1120    let remaining = &symbol[6.min(symbol.len())..];
1121
1122    if remaining.len() < 15 {
1123        return None;
1124    }
1125
1126    let expiry = &remaining[..6];
1127    let right_char = remaining.chars().nth(6)?;
1128    let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1129
1130    let strike_str = &remaining[7..];
1131    if strike_str.len() < 8 {
1132        return None;
1133    }
1134
1135    // Strike is typically 8 digits with possible decimal
1136    let strike_value = if strike_str.contains('.') {
1137        strike_str.parse().ok()?
1138    } else {
1139        // 8-digit integer strike, divide by 1000 for typical option strikes
1140        let strike_int: i32 = strike_str.parse().ok()?;
1141        strike_int as f64 / 1000.0
1142    };
1143
1144    Some(OptionSymbol {
1145        symbol: symbol_part.to_string(),
1146        expiry: expiry.to_string(),
1147        right: ib_option_right_to_option_right(right),
1148        local_symbol: symbol.to_string(),
1149        strike_value,
1150    })
1151}
1152
1153/// Named option symbol captures for formats like "C OESX 20260213 4775".
1154struct NamedOptionSymbol {
1155    trading_class: String,
1156    expiry: String,
1157    right: OptionRight,
1158    strike_value: f64,
1159}
1160
1161/// Named futures symbol captures for formats like "ESTX50 FESX 20240315".
1162struct NamedFuturesSymbol {
1163    underlying: String,
1164    trading_class: String,
1165    expiry: String,
1166}
1167
1168fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
1169    let parts: Vec<&str> = symbol.split_whitespace().collect();
1170    if parts.len() != 3 {
1171        return None;
1172    }
1173
1174    let expiry = parts[2];
1175    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1176        return None;
1177    }
1178
1179    Some(NamedFuturesSymbol {
1180        underlying: parts[0].to_string(),
1181        trading_class: parts[1].to_string(),
1182        expiry: expiry.to_string(),
1183    })
1184}
1185
1186fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1187    let parts: Vec<&str> = symbol.split_whitespace().collect();
1188    if !(parts.len() == 4 || parts.len() == 5) {
1189        return None;
1190    }
1191
1192    let right = IbOptionRight::from_str(parts[0]).ok()?;
1193
1194    let expiry = parts[2];
1195    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1196        return None;
1197    }
1198
1199    Some(NamedOptionSymbol {
1200        trading_class: parts[1].to_string(),
1201        expiry: expiry.to_string(),
1202        right: ib_option_right_to_option_right(right),
1203        strike_value: parts[3].parse::<f64>().ok()?,
1204    })
1205}
1206
1207fn normalize_option_symbol(local_symbol: &str) -> String {
1208    if local_symbol.len() >= 15 {
1209        let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1210        let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1211            && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1212            && suffix[7..].chars().all(|c| c.is_ascii_digit());
1213
1214        if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1215            return format!("{:<6}{}", root.trim_end(), suffix);
1216        }
1217    }
1218
1219    local_symbol.to_string()
1220}
1221
1222fn format_option_strike(strike: f64) -> String {
1223    if strike.fract() == 0.0 {
1224        format!("{strike:.0}")
1225    } else {
1226        format!("{strike}")
1227    }
1228}
1229
1230fn futures_symbol_from_contract(
1231    contract: &Contract,
1232    futures_year_digits: u8,
1233) -> anyhow::Result<String> {
1234    let expiry = contract.last_trade_date_or_contract_month.as_str();
1235    let year = expiry
1236        .get(..4)
1237        .filter(|value| value.bytes().all(|b| b.is_ascii_digit()))
1238        .and_then(|value| value.parse::<i32>().ok())
1239        .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1240
1241    let (futures_symbol, suffix) = if contract.security_type == SecurityType::FuturesOption {
1242        contract
1243            .local_symbol
1244            .split_once(' ')
1245            .map(|(symbol, suffix)| (symbol, Some(suffix)))
1246            .ok_or_else(|| {
1247                anyhow::anyhow!(
1248                    "Invalid futures option local symbol '{}'",
1249                    contract.local_symbol
1250                )
1251            })?
1252    } else if contract.local_symbol.is_empty() {
1253        let month = expiry
1254            .get(4..6)
1255            .and_then(|value| value.parse::<u8>().ok())
1256            .and_then(futures_month_code)
1257            .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1258
1259        let formatted =
1260            format_futures_symbol(contract.symbol.as_str(), month, year, futures_year_digits)
1261                .ok_or_else(|| {
1262                    anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1263                })?;
1264
1265        return Ok(formatted);
1266    } else {
1267        (contract.local_symbol.as_str(), None)
1268    };
1269
1270    let parsed = parse_futures_code(futures_symbol)
1271        .ok_or_else(|| anyhow::anyhow!("Invalid futures local symbol '{futures_symbol}'"))?;
1272    // The expiry field carries the last-trade date on details responses, which can fall in
1273    // the year before the contract month (January contracts such as CLF6 trading last in
1274    // December). Resolving the symbol's own year digits against that year is exact because
1275    // the contract-month year is never earlier than the last-trade year.
1276    let year = resolve_futures_year(parsed.year_digits, year)
1277        .ok_or_else(|| anyhow::anyhow!("Invalid futures year digits '{}'", parsed.year_digits))?;
1278
1279    let formatted =
1280        format_futures_symbol(parsed.root, parsed.month_code, year, futures_year_digits)
1281            .ok_or_else(|| {
1282                anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1283            })?;
1284
1285    Ok(suffix.map_or(formatted.clone(), |suffix| format!("{formatted} {suffix}")))
1286}
1287
1288fn futures_contract_from_symbol(
1289    symbol: &str,
1290    security_type: SecurityType,
1291    exchange: &str,
1292    reference_year: i32,
1293    currency: Option<&str>,
1294) -> Option<Contract> {
1295    let (futures_symbol, option) = if security_type == SecurityType::FuturesOption {
1296        let (futures_symbol, rest) = symbol.split_once(' ')?;
1297        let mut chars = rest.chars();
1298        let right_char = chars.next()?;
1299        let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1300        let strike = chars.as_str().parse::<f64>().ok()?;
1301        (futures_symbol, Some((right, strike)))
1302    } else {
1303        (symbol, None)
1304    };
1305
1306    let parsed = parse_futures_code(futures_symbol)?;
1307    let year = resolve_futures_year(parsed.year_digits, reference_year)?;
1308    let month = futures_month(parsed.month_code)?;
1309
1310    let currency = currency.unwrap_or_else(|| {
1311        log::warn!("Falling back to USD for unresolved IB futures contract {symbol} on {exchange}");
1312        "USD"
1313    });
1314
1315    Some(Contract {
1316        symbol: Symbol::from(parsed.root),
1317        trading_class: parsed.root.to_string(),
1318        security_type,
1319        exchange: Exchange::from(exchange),
1320        currency: Currency::from(currency),
1321        last_trade_date_or_contract_month: format!("{year:04}{month:02}"),
1322        right: option.map(|(right, _)| ib_option_right_to_option_right(right)),
1323        strike: option.map_or(0.0, |(_, strike)| strike),
1324        ..Default::default()
1325    })
1326}
1327
1328fn is_futures_underlying(symbol: &str) -> bool {
1329    symbol.len() <= 3 && symbol.chars().all(char::is_alphabetic)
1330}
1331
1332fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1333    let parsed = parse_futures_code(symbol)?;
1334    Some((
1335        parsed.root.len(),
1336        parsed.month_code,
1337        parsed.year_digits.to_string(),
1338    ))
1339}
1340
1341fn validate_futures_option_symbol(symbol: &str) -> Option<()> {
1342    let (futures_symbol, rest) = symbol.split_once(' ')?;
1343    parse_futures_month_and_year(futures_symbol)?;
1344
1345    let right_char = rest.chars().next()?;
1346    IbOptionRight::from_str(&right_char.to_string()).ok()?;
1347
1348    let strike_str = &rest[1..];
1349    strike_str.parse::<f64>().ok()?;
1350
1351    Some(())
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1357    use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
1358    use rstest::rstest;
1359
1360    use super::{Symbology, exchange_to_mic_venue, possible_exchanges_for_venue};
1361    use crate::config::SymbologyMethod;
1362
1363    #[rstest]
1364    fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1365        let contract = Contract {
1366            symbol: Symbol::from("SPXW"),
1367            security_type: SecurityType::Option,
1368            exchange: Exchange::from("SMART"),
1369            currency: Currency::from("USD"),
1370            local_symbol: "SPXW260313P06630000".to_string(),
1371            last_trade_date_or_contract_month: "260313".to_string(),
1372            right: Some(OptionRight::Put),
1373            strike: 6630.0,
1374            ..Default::default()
1375        };
1376
1377        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1378            .instrument_id(&contract, None)
1379            .unwrap();
1380
1381        assert_eq!(
1382            instrument_id,
1383            InstrumentId::from("SPXW  260313P06630000.SMART")
1384        );
1385    }
1386
1387    #[rstest]
1388    fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1389        let contract = Contract {
1390            symbol: Symbol::from("OESX"),
1391            security_type: SecurityType::Option,
1392            exchange: Exchange::from("EUREX"),
1393            currency: Currency::from("EUR"),
1394            trading_class: "OESX".to_string(),
1395            local_symbol: String::new(),
1396            last_trade_date_or_contract_month: "20260213".to_string(),
1397            right: Some(OptionRight::Call),
1398            strike: 4775.0,
1399            ..Default::default()
1400        };
1401
1402        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1403            .instrument_id(&contract, None)
1404            .unwrap();
1405
1406        assert_eq!(
1407            instrument_id,
1408            InstrumentId::from("C OESX 20260213 4775.EUREX")
1409        );
1410    }
1411
1412    #[rstest]
1413    fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1414        let contract = Contract {
1415            symbol: Symbol::from("EX2"),
1416            security_type: SecurityType::FuturesOption,
1417            exchange: Exchange::from("NYBOT"),
1418            currency: Currency::from("USD"),
1419            local_symbol: "EX2G3 P4080".to_string(),
1420            ..Default::default()
1421        };
1422
1423        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1424            .instrument_id(&contract, None)
1425            .unwrap();
1426
1427        assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1428    }
1429
1430    #[rstest]
1431    #[case(SecurityType::Future, "ESZ6", "ESZ6.XCME")]
1432    #[case(SecurityType::FuturesOption, "ESZ6 P5000", "ESZ6 P5000.XCME")]
1433    fn test_ib_contract_to_instrument_id_keeps_the_local_symbol_year(
1434        #[case] security_type: SecurityType,
1435        #[case] local_symbol: &str,
1436        #[case] expected: &str,
1437    ) {
1438        let contract = Contract {
1439            symbol: Symbol::from("ES"),
1440            security_type,
1441            exchange: Exchange::from("CME"),
1442            currency: Currency::from("USD"),
1443            local_symbol: local_symbol.to_string(),
1444            last_trade_date_or_contract_month: "202612".to_string(),
1445            ..Default::default()
1446        };
1447
1448        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1449            .instrument_id(&contract, Some(Venue::from("XCME")))
1450            .unwrap();
1451
1452        assert_eq!(instrument_id, InstrumentId::from(expected));
1453    }
1454
1455    #[rstest]
1456    #[case(SecurityType::Future, "CLF6", "20251219", "CLF6.XNYM")]
1457    #[case(SecurityType::Future, "ESZ6", "20261218", "ESZ6.XNYM")]
1458    #[case(
1459        SecurityType::FuturesOption,
1460        "CLF6 P7000",
1461        "20251216",
1462        "CLF6 P7000.XNYM"
1463    )]
1464    fn test_local_symbol_year_is_not_rewritten_from_last_trade_date(
1465        #[case] security_type: SecurityType,
1466        #[case] local_symbol: &str,
1467        #[case] last_trade_date: &str,
1468        #[case] expected: &str,
1469    ) {
1470        let contract = Contract {
1471            symbol: Symbol::from("CL"),
1472            security_type,
1473            exchange: Exchange::from("NYMEX"),
1474            currency: Currency::from("USD"),
1475            local_symbol: local_symbol.to_string(),
1476            last_trade_date_or_contract_month: last_trade_date.to_string(),
1477            ..Default::default()
1478        };
1479
1480        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1481            .instrument_id(&contract, Some(Venue::from("XNYM")))
1482            .unwrap();
1483
1484        assert_eq!(instrument_id, InstrumentId::from(expected));
1485    }
1486
1487    #[rstest]
1488    fn test_non_grammar_local_symbol_is_emitted_verbatim() {
1489        let contract = Contract {
1490            symbol: Symbol::from("ZC"),
1491            security_type: SecurityType::Future,
1492            exchange: Exchange::from("CBOT"),
1493            currency: Currency::from("USD"),
1494            local_symbol: "ZC   DEC 26".to_string(),
1495            last_trade_date_or_contract_month: "20261214".to_string(),
1496            ..Default::default()
1497        };
1498
1499        let simplified = Symbology::new(SymbologyMethod::Simplified)
1500            .instrument_id(&contract, Some(Venue::from("XCBT")))
1501            .unwrap();
1502        let raw = Symbology::new(SymbologyMethod::Raw)
1503            .instrument_id(&contract, Some(Venue::from("XCBT")))
1504            .unwrap();
1505
1506        assert_eq!(simplified, InstrumentId::from("ZC   DEC 26.XCBT"));
1507        assert_eq!(raw, InstrumentId::from("ZC   DEC 26=FUT.XCBT"));
1508    }
1509
1510    #[rstest]
1511    fn test_fop_without_local_symbol_falls_back_to_contract_fields() {
1512        let contract = Contract {
1513            symbol: Symbol::from("ES"),
1514            security_type: SecurityType::FuturesOption,
1515            exchange: Exchange::from("CME"),
1516            currency: Currency::from("USD"),
1517            local_symbol: String::new(),
1518            last_trade_date_or_contract_month: "20261218".to_string(),
1519            right: Some(OptionRight::Put),
1520            strike: 5000.0,
1521            ..Default::default()
1522        };
1523
1524        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1525            .instrument_id(&contract, Some(Venue::from("XCME")))
1526            .unwrap();
1527
1528        assert_eq!(instrument_id, InstrumentId::from("ES20261218 P5000.XCME"));
1529    }
1530
1531    #[rstest]
1532    #[case("ESZ6", SecurityType::Future)]
1533    #[case("ESZ26", SecurityType::Future)]
1534    #[case("ESZ2026", SecurityType::Future)]
1535    #[case("ESZ26 P5000", SecurityType::FuturesOption)]
1536    fn test_futures_id_keeps_its_symbol_as_the_contract_local_symbol_outbound(
1537        #[case] symbol: &str,
1538        #[case] security_type: SecurityType,
1539    ) {
1540        let instrument_id = InstrumentId::new(NautilusSymbol::from(symbol), Venue::from("XCME"));
1541
1542        let contract = Symbology::new(SymbologyMethod::Simplified)
1543            .contract(instrument_id, None)
1544            .unwrap();
1545
1546        assert_eq!(contract.security_type, security_type);
1547        assert_eq!(contract.local_symbol.as_str(), symbol);
1548        assert_eq!(contract.exchange.as_str(), "CME");
1549        assert_eq!(contract.currency.as_str(), "USD");
1550        assert!(contract.symbol.as_str().is_empty());
1551        assert!(contract.trading_class.is_empty());
1552        assert!(contract.last_trade_date_or_contract_month.is_empty());
1553        assert_eq!(contract.right, None);
1554        assert_eq!(contract.strike, 0.0);
1555    }
1556
1557    #[rstest]
1558    fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1559        let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1560
1561        let contract = Symbology::new(SymbologyMethod::Simplified)
1562            .contract(instrument_id, None)
1563            .unwrap();
1564
1565        assert_eq!(contract.security_type, SecurityType::Option);
1566        assert_eq!(contract.exchange.as_str(), "EUREX");
1567        assert_eq!(contract.symbol.as_str(), "OESX");
1568        assert_eq!(contract.trading_class.as_str(), "OESX");
1569        assert_eq!(
1570            contract.last_trade_date_or_contract_month.as_str(),
1571            "20260213"
1572        );
1573        assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1574        assert_eq!(contract.strike, 4775.0);
1575    }
1576
1577    #[rstest]
1578    fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1579        let instrument_id = InstrumentId::from("AAPL  230217P00155000.SMART");
1580
1581        let contract = Symbology::new(SymbologyMethod::Simplified)
1582            .contract(instrument_id, None)
1583            .unwrap();
1584
1585        assert_eq!(contract.security_type, SecurityType::Option);
1586        assert_eq!(contract.exchange.as_str(), "SMART");
1587        assert_eq!(contract.symbol.as_str(), "AAPL");
1588        assert_eq!(contract.local_symbol.as_str(), "AAPL  230217P00155000");
1589        assert_eq!(
1590            contract.last_trade_date_or_contract_month.as_str(),
1591            "230217"
1592        );
1593        assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1594        assert_eq!(contract.strike, 155.0);
1595    }
1596
1597    #[rstest]
1598    fn test_possible_exchanges_for_opra_routes_to_smart() {
1599        assert_eq!(
1600            possible_exchanges_for_venue("OPRA"),
1601            vec!["SMART".to_string()]
1602        );
1603    }
1604
1605    #[rstest]
1606    fn test_opra_occ_option_default_contract_routes_to_smart() {
1607        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1608
1609        let contract = Symbology::new(SymbologyMethod::Simplified)
1610            .contract(instrument_id, None)
1611            .unwrap();
1612
1613        assert_eq!(contract.security_type, SecurityType::Option);
1614        assert_eq!(contract.exchange.as_str(), "SMART");
1615        assert!(contract.symbol.as_str().is_empty());
1616        assert_eq!(contract.currency.as_str(), "USD");
1617        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1618        assert!(contract.last_trade_date_or_contract_month.is_empty());
1619        assert!(contract.right.is_none());
1620        assert_eq!(contract.strike, 0.0);
1621    }
1622
1623    #[rstest]
1624    fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1625        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1626        let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1627
1628        assert_eq!(exchanges, vec!["SMART".to_string()]);
1629
1630        let contract = Symbology::new(SymbologyMethod::Simplified)
1631            .contract(instrument_id, exchanges.first().map(String::as_str))
1632            .unwrap();
1633
1634        assert_eq!(contract.security_type, SecurityType::Option);
1635        assert_eq!(contract.exchange.as_str(), "SMART");
1636        assert!(contract.symbol.as_str().is_empty());
1637        assert_eq!(contract.currency.as_str(), "USD");
1638        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1639        assert!(contract.last_trade_date_or_contract_month.is_empty());
1640        assert!(contract.right.is_none());
1641        assert_eq!(contract.strike, 0.0);
1642    }
1643
1644    #[rstest]
1645    fn test_opra_raw_option_default_contract_routes_to_smart() {
1646        let instrument_id = InstrumentId::from("SPY   240319P00511000=OPT.OPRA");
1647
1648        let contract = Symbology::new(SymbologyMethod::Raw)
1649            .contract(instrument_id, None)
1650            .unwrap();
1651
1652        assert_eq!(contract.security_type, SecurityType::Option);
1653        assert_eq!(contract.exchange.as_str(), "SMART");
1654        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1655    }
1656
1657    #[rstest]
1658    #[case("SPY   240319P00511000.OPRA")]
1659    #[case("SPY   240319P00511000=OPT.OPRA")]
1660    fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1661        let instrument_id = InstrumentId::from(value);
1662
1663        let method = if value.contains("=OPT") {
1664            SymbologyMethod::Raw
1665        } else {
1666            SymbologyMethod::Simplified
1667        };
1668
1669        let contract = Symbology::new(method)
1670            .contract(instrument_id, Some("CBOE"))
1671            .unwrap();
1672
1673        assert_eq!(contract.security_type, SecurityType::Option);
1674        assert_eq!(contract.exchange.as_str(), "CBOE");
1675    }
1676
1677    #[rstest]
1678    #[case("AAPL.OPRA")]
1679    #[case("SPY   abcdefP00511000.OPRA")]
1680    #[case("P SPY 20240319 511.OPRA")]
1681    fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1682        let instrument_id = InstrumentId::from(value);
1683
1684        let result = Symbology::new(SymbologyMethod::Simplified).contract(instrument_id, None);
1685
1686        assert!(result.is_err());
1687    }
1688
1689    #[rstest]
1690    fn test_opra_route_does_not_create_reverse_smart_mapping() {
1691        assert_eq!(exchange_to_mic_venue("SMART"), None);
1692    }
1693
1694    #[rstest]
1695    fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1696        let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1697
1698        let contract = Symbology::new(SymbologyMethod::Simplified)
1699            .contract(instrument_id, None)
1700            .unwrap();
1701
1702        assert_eq!(contract.security_type, SecurityType::Future);
1703        assert_eq!(contract.exchange.as_str(), "EUREX");
1704        assert_eq!(contract.symbol.as_str(), "ESTX50");
1705        assert_eq!(contract.trading_class.as_str(), "FESX");
1706        assert_eq!(
1707            contract.last_trade_date_or_contract_month.as_str(),
1708            "20240315"
1709        );
1710    }
1711
1712    #[rstest]
1713    fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1714        let instrument_id = InstrumentId::from("YMM6.XCBT");
1715
1716        let contract = Symbology::new(SymbologyMethod::Simplified)
1717            .contract(instrument_id, None)
1718            .unwrap();
1719
1720        assert_eq!(contract.security_type, SecurityType::Future);
1721        assert_eq!(contract.exchange.as_str(), "CBOT");
1722        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1723        assert!(contract.symbol.as_str().is_empty());
1724        assert!(contract.last_trade_date_or_contract_month.is_empty());
1725    }
1726
1727    #[rstest]
1728    fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1729        let instrument_id = InstrumentId::from("OESXH6.XEUR");
1730
1731        let contract = Symbology::new(SymbologyMethod::Simplified)
1732            .contract(instrument_id, None)
1733            .unwrap();
1734
1735        assert_eq!(contract.security_type, SecurityType::Future);
1736        assert_eq!(contract.exchange.as_str(), "DTB");
1737        assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1738        assert!(contract.symbol.as_str().is_empty());
1739        assert!(contract.last_trade_date_or_contract_month.is_empty());
1740    }
1741
1742    #[rstest]
1743    fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1744        let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1745
1746        let contract = Symbology::new(SymbologyMethod::Simplified)
1747            .contract(instrument_id, None)
1748            .unwrap();
1749
1750        assert_eq!(contract.security_type, SecurityType::FuturesOption);
1751        assert_eq!(contract.exchange.as_str(), "CBOT");
1752        assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1753        assert!(contract.symbol.as_str().is_empty());
1754        assert!(contract.last_trade_date_or_contract_month.is_empty());
1755        assert_eq!(contract.right, None);
1756        assert_eq!(contract.strike, 0.0);
1757    }
1758
1759    #[rstest]
1760    fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1761        let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1762
1763        let contract = Symbology::new(SymbologyMethod::Simplified)
1764            .contract(instrument_id, None)
1765            .unwrap();
1766
1767        assert_eq!(contract.security_type, SecurityType::CFD);
1768        assert_eq!(contract.exchange.as_str(), "SMART");
1769        assert_eq!(contract.symbol.as_str(), "EUR");
1770        assert_eq!(contract.currency.as_str(), "USD");
1771        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1772    }
1773
1774    #[rstest]
1775    #[case(
1776        SymbologyMethod::Simplified,
1777        SecurityType::CFD,
1778        "IBDE30",
1779        "EUR",
1780        "IBDE30.IBCFD"
1781    )]
1782    #[case(
1783        SymbologyMethod::Raw,
1784        SecurityType::CFD,
1785        "IBDE30",
1786        "EUR",
1787        "IBDE30=CFD.IBCFD"
1788    )]
1789    #[case(
1790        SymbologyMethod::Simplified,
1791        SecurityType::Commodity,
1792        "XAUUSD",
1793        "GBP",
1794        "XAUUSD.IBCMDTY"
1795    )]
1796    #[case(
1797        SymbologyMethod::Raw,
1798        SecurityType::Commodity,
1799        "XAUUSD",
1800        "GBP",
1801        "XAUUSD=CMDTY.IBCMDTY"
1802    )]
1803    fn test_non_cash_pseudo_venue_round_trip_defers_currency_to_contract_details(
1804        #[case] method: SymbologyMethod,
1805        #[case] security_type: SecurityType,
1806        #[case] symbol: &str,
1807        #[case] currency: &str,
1808        #[case] expected_instrument_id: &str,
1809    ) {
1810        let contract = Contract {
1811            symbol: Symbol::from(symbol),
1812            security_type: security_type.clone(),
1813            exchange: Exchange::from("SMART"),
1814            currency: Currency::from(currency),
1815            local_symbol: symbol.to_string(),
1816            ..Default::default()
1817        };
1818
1819        let symbology = Symbology::new(method);
1820
1821        let instrument_id = symbology.instrument_id(&contract, None).unwrap();
1822        let unresolved = symbology.contract(instrument_id, None).unwrap();
1823        let round_trip_id = symbology.instrument_id(&unresolved, None).unwrap();
1824
1825        assert_eq!(instrument_id, InstrumentId::from(expected_instrument_id));
1826        assert_eq!(unresolved.security_type, security_type);
1827        assert_eq!(unresolved.exchange.as_str(), "SMART");
1828        assert!(unresolved.currency.as_str().is_empty());
1829        assert_eq!(round_trip_id, instrument_id);
1830    }
1831
1832    #[rstest]
1833    fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1834        let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1835
1836        let contract = Symbology::new(SymbologyMethod::Simplified)
1837            .contract(instrument_id, None)
1838            .unwrap();
1839
1840        assert_eq!(contract.security_type, SecurityType::Crypto);
1841        assert_eq!(contract.exchange.as_str(), "PAXOS");
1842        assert_eq!(contract.symbol.as_str(), "DOGE");
1843        assert_eq!(contract.currency.as_str(), "USD");
1844        assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1845    }
1846
1847    #[rstest]
1848    fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1849        // ZEROHASH is one of IB's crypto venues (alongside PAXOS).
1850        let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1851
1852        let contract = Symbology::new(SymbologyMethod::Simplified)
1853            .contract(instrument_id, None)
1854            .unwrap();
1855
1856        assert_eq!(contract.security_type, SecurityType::Crypto);
1857        assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1858        assert_eq!(contract.symbol.as_str(), "BTC");
1859        assert_eq!(contract.currency.as_str(), "USD");
1860        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1861    }
1862
1863    #[rstest]
1864    fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1865        // Contract -> InstrumentId derives the venue from the ZEROHASH exchange.
1866        let contract = Contract {
1867            symbol: Symbol::from("BTC"),
1868            security_type: SecurityType::Crypto,
1869            exchange: Exchange::from("ZEROHASH"),
1870            currency: Currency::from("USD"),
1871            local_symbol: "BTC.USD".to_string(),
1872            ..Default::default()
1873        };
1874
1875        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1876            .instrument_id(&contract, None)
1877            .unwrap();
1878
1879        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1880    }
1881
1882    #[rstest]
1883    fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1884        // Contract -> InstrumentId falls back to PAXOS when no exchange is set,
1885        // preserving backwards compatibility.
1886        let contract = Contract {
1887            symbol: Symbol::from("DOGE"),
1888            security_type: SecurityType::Crypto,
1889            currency: Currency::from("USD"),
1890            local_symbol: "DOGE.USD".to_string(),
1891            ..Default::default()
1892        };
1893
1894        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1895            .instrument_id(&contract, None)
1896            .unwrap();
1897
1898        assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1899    }
1900
1901    #[rstest]
1902    fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1903        // PAXOS remains a live IB crypto venue alongside ZEROHASH.
1904        let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1905
1906        let contract = Symbology::new(SymbologyMethod::Simplified)
1907            .contract(instrument_id, None)
1908            .unwrap();
1909
1910        assert_eq!(contract.security_type, SecurityType::Crypto);
1911        assert_eq!(contract.exchange.as_str(), "PAXOS");
1912        assert_eq!(contract.symbol.as_str(), "BTC");
1913        assert_eq!(contract.currency.as_str(), "USD");
1914        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1915    }
1916
1917    #[rstest]
1918    fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1919        // A PAXOS contract derives the PAXOS venue from its exchange (not via the
1920        // fallback), proving both venues are honored when explicitly set.
1921        let contract = Contract {
1922            symbol: Symbol::from("BTC"),
1923            security_type: SecurityType::Crypto,
1924            exchange: Exchange::from("PAXOS"),
1925            currency: Currency::from("USD"),
1926            local_symbol: "BTC.USD".to_string(),
1927            ..Default::default()
1928        };
1929
1930        let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1931            .instrument_id(&contract, None)
1932            .unwrap();
1933
1934        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1935    }
1936
1937    #[rstest]
1938    fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1939        let instrument_id = InstrumentId::from("ES.XCME");
1940
1941        let contract = Symbology::new(SymbologyMethod::Simplified)
1942            .contract(instrument_id, None)
1943            .unwrap();
1944
1945        assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1946        assert_eq!(contract.exchange.as_str(), "CME");
1947        assert_eq!(contract.symbol.as_str(), "ES");
1948    }
1949
1950    #[rstest]
1951    fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1952        let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1953
1954        let contract = Symbology::new(SymbologyMethod::Simplified)
1955            .contract(instrument_id, None)
1956            .unwrap();
1957
1958        assert_eq!(contract.security_type, SecurityType::Stock);
1959        assert_eq!(contract.exchange.as_str(), "SMART");
1960        assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1961        assert_eq!(contract.local_symbol.as_str(), "AAPL");
1962        assert!(contract.symbol.as_str().is_empty());
1963    }
1964
1965    #[rstest]
1966    fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1967        let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1968
1969        let contract = Symbology::new(SymbologyMethod::Simplified)
1970            .contract(instrument_id, None)
1971            .unwrap();
1972
1973        assert_eq!(contract.security_type, SecurityType::ForexPair);
1974        assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1975        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1976        assert!(contract.symbol.as_str().is_empty());
1977    }
1978
1979    #[rstest]
1980    fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1981        let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1982
1983        let contract = Symbology::new(SymbologyMethod::Simplified)
1984            .contract(instrument_id, Some("CBOT"))
1985            .unwrap();
1986
1987        assert_eq!(contract.security_type, SecurityType::Future);
1988        assert_eq!(contract.exchange.as_str(), "CBOT");
1989        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1990        assert!(contract.symbol.as_str().is_empty());
1991        assert!(contract.last_trade_date_or_contract_month.is_empty());
1992    }
1993
1994    #[rstest]
1995    fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1996        let instrument_id = InstrumentId::from("IUSA.IBIS");
1997
1998        let contract = Symbology::new(SymbologyMethod::Simplified)
1999            .contract(instrument_id, Some("IBIS"))
2000            .unwrap();
2001
2002        assert_eq!(contract.security_type, SecurityType::Stock);
2003        assert_eq!(contract.symbol.as_str(), "IUSA");
2004        assert_eq!(contract.exchange.as_str(), "SMART");
2005        assert_eq!(contract.primary_exchange.as_str(), "IBIS");
2006        assert!(contract.currency.as_str().is_empty());
2007        assert!(contract.local_symbol.is_empty());
2008    }
2009}