Skip to main content

nautilus_interactive_brokers/common/
parse.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//! Parsing utilities for converting Interactive Brokers data to Nautilus types.
17
18use std::{collections::HashMap, str::FromStr, sync::LazyLock};
19
20use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
21use nautilus_core::UnixNanos;
22use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, TradeId, Venue};
23
24use crate::common::enums::{IbOptionRight, IbSecurityType};
25
26fn ib_option_right_to_option_right(right: IbOptionRight) -> OptionRight {
27    match right {
28        IbOptionRight::Call => OptionRight::Call,
29        IbOptionRight::Put => OptionRight::Put,
30    }
31}
32
33/// Generate a unique trade ID for Interactive Brokers trades.
34///
35/// This format matches the Python adapter: "{secs}-{price}-{size}"
36pub fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
37    let ts_secs = ts_event.as_i64() / 1_000_000_000;
38    TradeId::new(format!("{ts_secs}-{price}-{size}"))
39}
40
41/// Convert an IB Contract to an InstrumentId using simplified symbology.
42///
43/// This implements IB_SIMPLIFIED symbology: clean, readable symbols.
44/// For example:
45/// - STK: "AAPL" -> "AAPL.SMART"
46/// - CASH: "EUR.USD" -> "EUR/USD.IDEALPRO"
47/// - FUT: "ESM23" -> "ESM23.GLOBEX"
48/// - OPT: "AAPL230120C00150000" -> "AAPL230120C00150000.SMART"
49/// - IND: "SPX" -> "^SPX.SMART"
50///
51/// # Errors
52///
53/// Returns an error if the instrument ID cannot be constructed.
54pub fn ib_contract_to_instrument_id_simplified(
55    contract: &Contract,
56    venue: Option<Venue>,
57) -> anyhow::Result<InstrumentId> {
58    let venue = venue.unwrap_or_else(|| {
59        // For Index and Future, use contract exchange when set (e.g. ESTX50 -> EUREX, FESX -> EUREX).
60        match contract.security_type {
61            SecurityType::Index => {
62                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
63                    Venue::from(contract.exchange.as_str())
64                } else {
65                    Venue::from("SMART")
66                }
67            }
68            SecurityType::Future => {
69                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
70                    Venue::from(contract.exchange.as_str())
71                } else {
72                    Venue::from("GLOBEX")
73                }
74            }
75            SecurityType::ForexPair => Venue::from("IDEALPRO"),
76            SecurityType::Crypto => Venue::from("PAXOS"),
77            SecurityType::Stock => Venue::from("SMART"),
78            SecurityType::Option | SecurityType::FuturesOption => {
79                if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
80                    Venue::from(contract.exchange.as_str())
81                } else {
82                    Venue::from("SMART")
83                }
84            }
85            SecurityType::CFD => Venue::from("SMART"),
86            SecurityType::Commodity => Venue::from("SMART"),
87            SecurityType::Bond => Venue::from("SMART"),
88            _ => Venue::from("SMART"),
89        }
90    });
91
92    let symbol = match contract.security_type {
93        SecurityType::Stock => {
94            // STK: Use localSymbol with spaces replaced by hyphens, fallback to symbol
95            let symbol_str = if contract.local_symbol.is_empty() {
96                contract.symbol.as_str().to_string()
97            } else {
98                contract.local_symbol.as_str().replace(' ', "-")
99            };
100            NautilusSymbol::from(symbol_str.as_str())
101        }
102        SecurityType::Index => {
103            // IND: Prefix with ^
104            let base = if contract.local_symbol.is_empty() {
105                contract.symbol.as_str()
106            } else {
107                contract.local_symbol.as_str()
108            };
109            NautilusSymbol::from(format!("^{base}").as_str())
110        }
111        SecurityType::Option => {
112            // OPT: Preserve OCC 6-character root padding when present.
113            let symbol_str = if contract.local_symbol.is_empty() {
114                format!(
115                    "{} {} {} {}",
116                    contract.right.map_or("", |right| right.as_str()),
117                    contract.trading_class.as_str(),
118                    contract.last_trade_date_or_contract_month.as_str(),
119                    format_option_strike(contract.strike),
120                )
121            } else {
122                normalize_option_symbol(contract.local_symbol.as_str())
123            };
124            NautilusSymbol::from(symbol_str.as_str())
125        }
126        SecurityType::ForexPair | SecurityType::Crypto => {
127            // CASH/CRYPTO: Replace dots with slashes (e.g., "EUR.USD" -> "EUR/USD")
128            let symbol_str = if contract.local_symbol.is_empty() {
129                format!(
130                    "{}/{}",
131                    contract.symbol.as_str(),
132                    contract.currency.as_str()
133                )
134            } else {
135                contract.local_symbol.as_str().replace('.', "/")
136            };
137            NautilusSymbol::from(symbol_str.as_str())
138        }
139        SecurityType::Future => {
140            // FUT: Use localSymbol if available; else symbol + trading_class + expiry (e.g. ESTX50 FESX 20240315).
141            if contract.local_symbol.is_empty() {
142                if !contract.trading_class.is_empty()
143                    && !contract.last_trade_date_or_contract_month.is_empty()
144                {
145                    let symbol_str = format!(
146                        "{} {} {}",
147                        contract.symbol.as_str(),
148                        contract.trading_class.as_str(),
149                        contract.last_trade_date_or_contract_month.as_str()
150                    );
151                    NautilusSymbol::from(symbol_str.as_str())
152                } else if !contract.last_trade_date_or_contract_month.is_empty() {
153                    let expiry = contract.last_trade_date_or_contract_month.as_str();
154                    let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
155                    NautilusSymbol::from(symbol_str.as_str())
156                } else {
157                    NautilusSymbol::from(contract.symbol.as_str())
158                }
159            } else {
160                NautilusSymbol::from(contract.local_symbol.as_str())
161            }
162        }
163        SecurityType::FuturesOption => {
164            // FOP: Preserve IB local symbol spacing, matching Python simplified symbology.
165            if contract.local_symbol.is_empty() {
166                // Fallback construction
167                let expiry = contract.last_trade_date_or_contract_month.as_str();
168                let right = contract.right.map_or("P", |right| right.as_str());
169                let strike_str = format!("{}", contract.strike as i64);
170                let symbol_str = format!(
171                    "{}{} {}{}",
172                    contract.symbol.as_str(),
173                    expiry,
174                    right,
175                    strike_str
176                );
177                NautilusSymbol::from(symbol_str.as_str())
178            } else {
179                NautilusSymbol::from(contract.local_symbol.as_str())
180            }
181        }
182        SecurityType::CFD => {
183            // CFD: If localSymbol matches EUR.USD pattern, convert to EUR/USD, else use symbol with spaces as hyphens
184            if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
185                let cash_like = contract.local_symbol.as_str().replace('.', "/");
186                NautilusSymbol::from(cash_like.as_str())
187            } else {
188                let symbol_str = contract.symbol.as_str().replace(' ', "-");
189                NautilusSymbol::from(symbol_str.as_str())
190            }
191        }
192        SecurityType::Commodity => {
193            // CMDTY: Replace spaces with hyphens
194            let symbol_str = contract.symbol.as_str().replace(' ', "-");
195            NautilusSymbol::from(symbol_str.as_str())
196        }
197        SecurityType::Bond => {
198            // BOND: Use localSymbol or symbol
199            let symbol_str = if contract.local_symbol.is_empty() {
200                contract.symbol.as_str()
201            } else {
202                contract.local_symbol.as_str()
203            };
204            NautilusSymbol::from(symbol_str)
205        }
206        _ => {
207            // Default: use localSymbol or symbol
208            let symbol_str = if contract.local_symbol.is_empty() {
209                contract.symbol.as_str()
210            } else {
211                contract.local_symbol.as_str()
212            };
213            NautilusSymbol::from(symbol_str)
214        }
215    };
216
217    Ok(InstrumentId::new(symbol, venue))
218}
219
220/// Convert an IB Contract to an InstrumentId using raw symbology.
221///
222/// This implements IB_RAW symbology: preserves IB raw format with security type suffix.
223/// For example:
224/// - "AAPL=STK.SMART"
225/// - "EUR.USD=CASH.IDEALPRO"
226/// - "ESM23=FUT.GLOBEX"
227///
228/// # Errors
229///
230/// Returns an error if the instrument ID cannot be constructed.
231pub fn ib_contract_to_instrument_id_raw(
232    contract: &Contract,
233    venue: Option<Venue>,
234) -> anyhow::Result<InstrumentId> {
235    let venue = venue.unwrap_or_else(|| match contract.security_type {
236        SecurityType::ForexPair => Venue::from("IDEALPRO"),
237        SecurityType::Crypto => Venue::from("PAXOS"),
238        SecurityType::Stock => Venue::from("SMART"),
239        SecurityType::Option => Venue::from("SMART"),
240        SecurityType::FuturesOption => Venue::from("SMART"),
241        SecurityType::Future => Venue::from("GLOBEX"),
242        SecurityType::Index => Venue::from("SMART"),
243        SecurityType::CFD => Venue::from("SMART"),
244        SecurityType::Commodity => Venue::from("SMART"),
245        SecurityType::Bond => Venue::from("SMART"),
246        _ => Venue::from("SMART"),
247    });
248
249    let local_symbol = if contract.local_symbol.is_empty() {
250        contract.symbol.as_str()
251    } else {
252        contract.local_symbol.as_str()
253    };
254
255    let sec_type_str = IbSecurityType::try_from(&contract.security_type).map_or_else(
256        |_| "OTHER".to_string(),
257        |security_type| security_type.to_string(),
258    );
259
260    let symbol_str = format!("{local_symbol}={sec_type_str}");
261    let symbol = NautilusSymbol::from(symbol_str.as_str());
262    Ok(InstrumentId::new(symbol, venue))
263}
264
265/// Convert an IB Contract to an InstrumentId (simple version using contract fields).
266///
267/// This is a convenience wrapper that uses simplified symbology by default.
268/// For more accurate mapping, use the instrument provider which has contract details.
269///
270/// # Errors
271///
272/// Returns an error if the instrument ID cannot be constructed.
273pub fn ib_contract_to_instrument_id_simple(contract: &Contract) -> anyhow::Result<InstrumentId> {
274    ib_contract_to_instrument_id_simplified(contract, None)
275}
276
277/// Venue to IB exchange mappings.
278/// Maps MIC venue codes to lists of IB exchange codes used by Interactive Brokers.
279pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
280    LazyLock::new(|| {
281        let mut map = HashMap::new();
282        // ICE Endex
283        map.insert("NDEX", vec!["ENDEX"]);
284        // CME Group Exchanges
285        map.insert("XCME", vec!["CME"]);
286        map.insert("XCEC", vec!["CME"]);
287        map.insert("XFXS", vec!["CME"]);
288        // Chicago Board of Trade Segments
289        map.insert("XCBT", vec!["CBOT"]);
290        map.insert("CBCM", vec!["CBOT"]);
291        // New York Mercantile Exchange Segments
292        map.insert("XNYM", vec!["NYMEX"]);
293        map.insert("NYUM", vec!["NYMEX"]);
294        // ICE Futures US (formerly NYBOT)
295        map.insert("IFUS", vec!["NYBOT"]);
296        // GLBX, Name used by databento
297        map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
298        // US Major Exchanges & Index Venues
299        map.insert("XNAS", vec!["NASDAQ"]);
300        map.insert("XNYS", vec!["NYSE"]);
301        map.insert("ARCX", vec!["ARCA"]);
302        map.insert("BATS", vec!["BATS"]);
303        map.insert("IEXG", vec!["IEX"]);
304        map.insert("XCBO", vec!["CBOE"]);
305        map.insert("XCBF", vec!["CFE"]);
306        // Canadian Exchanges
307        map.insert("XTSE", vec!["TSX"]);
308        // ICE Europe Exchanges
309        map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
310        // European Exchanges
311        map.insert("XLON", vec!["LSE"]);
312        map.insert("XPAR", vec!["SBF"]);
313        map.insert("XETR", vec!["IBIS"]);
314        map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
315        map.insert("XAMS", vec!["AEB"]);
316        map.insert("XBRU", vec!["EBS"]);
317        map.insert("XBRD", vec!["BELFOX"]);
318        map.insert("XLIS", vec!["BVLP"]);
319        map.insert("XDUB", vec!["IRE"]);
320        map.insert("XOSL", vec!["OSL"]);
321        map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
322        map.insert("XSVX", vec!["VRTX"]);
323        map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
324        map.insert("XMAD", vec!["MDRD", "BME"]);
325        map.insert("DXEX", vec!["BATEEN"]);
326        map.insert("XWBO", vec!["WBAG"]);
327        map.insert("XBUD", vec!["BUX"]);
328        map.insert("XPRA", vec!["PRA"]);
329        map.insert("XWAR", vec!["WSE"]);
330        map.insert("XIST", vec!["ISE"]);
331        // Nasdaq Nordic Exchanges
332        map.insert("XSTO", vec!["SFB"]);
333        map.insert("XCSE", vec!["KFB"]);
334        map.insert("XHEL", vec!["HMB"]);
335        map.insert("XICE", vec!["ISB"]);
336        // Asia-Pacific Exchanges
337        map.insert("XASX", vec!["ASX"]);
338        map.insert("XHKG", vec!["SEHK"]);
339        map.insert("XHKF", vec!["HKFE"]);
340        map.insert("XSES", vec!["SGX"]);
341        map.insert("XOSE", vec!["OSE.JPN"]);
342        map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
343        map.insert("XKRX", vec!["KSE", "KRX"]);
344        map.insert("XTAI", vec!["TASE", "TWSE"]);
345        map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
346        map.insert("XSHE", vec!["SEHKSZSE"]);
347        map.insert("XNSE", vec!["NSE"]);
348        map.insert("XBOM", vec!["BSE"]);
349        // Other Derivatives Exchanges
350        map.insert("XSFE", vec!["SNFE"]);
351        map.insert("XMEX", vec!["MEXDER"]);
352        // African, Middle Eastern, South American Exchanges
353        map.insert("XJSE", vec!["JSE"]);
354        map.insert("XBOG", vec!["BVC"]);
355        map.insert("XTAE", vec!["TASE"]);
356        map.insert("BVMF", vec!["BVMF"]);
357        map
358    });
359
360#[must_use]
361pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
362    if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
363        return exchanges
364            .iter()
365            .map(|exchange| (*exchange).to_string())
366            .collect();
367    }
368
369    vec![venue.to_string()]
370}
371
372/// Venue lists for different asset classes
373const VENUES_CASH: &[&str] = &["IDEALPRO"];
374const VENUES_CRYPTO: &[&str] = &["PAXOS"];
375const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
376const VENUES_FUT: &[&str] = &[
377    "BELFOX",
378    "GLOBEX",
379    "CBOT",
380    "CFE",
381    "CME",
382    "COMEX",
383    "CBOE",
384    "DTB",
385    "EUREX",
386    "HKFE",
387    "ICE",
388    "ICEEU",
389    "ICEEUSOFT",
390    "IDEM",
391    "IPE",
392    "KCBT",
393    "MEXDER",
394    "MGE",
395    "NYBOT",
396    "NYMEX",
397    "OSE.JPN",
398    "SNFE",
399    "SOFFEX",
400    "VRTX",
401    "CMECRYPTO",
402    "NYMEXMETALS",
403    "NYMEXNG",
404    "NYMEXENERGY",
405    "CMEPRECIOUS",
406    "CMECURRENCY",
407    "CMEINDEX",
408    "CMEWEATHER",
409    "CMEINTEREST",
410    "CMEFLOOR",
411    "CBOTFLOOR",
412    "NYMEXFLOOR",
413    "NYBOTFLOOR",
414    "CFEFLOOR",
415    "CMEOPTIONS",
416    "CBOTOPTIONS",
417    "NYMEXOPTIONS",
418    "NYBOTOPTIONS",
419    "ECBOT",
420];
421const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
422const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
423
424fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
425    venues.contains(&venue_str)
426        || VENUE_MEMBERS
427            .get(venue_str)
428            .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
429}
430
431/// 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)
432/// This constant is kept for potential future use in more complex parsing scenarios.
433#[allow(dead_code)]
434const FUTURES_MONTH_CODES: &[(char, &str)] = &[
435    ('F', "01"),
436    ('G', "02"),
437    ('H', "03"),
438    ('J', "04"),
439    ('K', "05"),
440    ('M', "06"),
441    ('N', "07"),
442    ('Q', "08"),
443    ('U', "09"),
444    ('V', "10"),
445    ('X', "11"),
446    ('Z', "12"),
447];
448
449/// Determine venue from contract using provider configuration.
450///
451/// This implements the same logic as Python's `determine_venue_from_contract`:
452/// 1. Check symbol-specific venue mapping first (prefix matching)
453/// 2. Use VENUE_MEMBERS mapping if convert_exchange_to_mic_venue is enabled
454/// 3. Fall back to exchange
455pub fn determine_venue_from_contract(
456    contract: &Contract,
457    symbol_to_mic_venue: &std::collections::HashMap<String, String>,
458    convert_exchange_to_mic_venue: bool,
459    valid_exchanges: Option<&str>,
460) -> String {
461    if matches!(contract.security_type, SecurityType::CFD) {
462        return "IBCFD".to_string();
463    }
464
465    if matches!(contract.security_type, SecurityType::Commodity) {
466        return "IBCMDTY".to_string();
467    }
468
469    if !symbol_to_mic_venue.is_empty() {
470        let symbol = contract.symbol.as_str();
471        for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
472            if symbol.starts_with(symbol_prefix) {
473                return symbol_venue.clone();
474            }
475        }
476    }
477
478    // Use the exchange from the contract (primaryExchange if exchange is SMART)
479    let mut exchange = if contract.exchange.as_str() == "SMART"
480        && !contract.primary_exchange.as_str().is_empty()
481        && contract.primary_exchange.as_str() != "SMART"
482    {
483        contract.primary_exchange.as_str().to_string()
484    } else {
485        contract.exchange.as_str().to_string()
486    };
487
488    if exchange == "SMART"
489        && let Some(valid_exchanges) = valid_exchanges
490    {
491        let parts: Vec<&str> = valid_exchanges
492            .split(',')
493            .map(str::trim)
494            .filter(|part| !part.is_empty())
495            .collect();
496
497        if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
498            exchange = (*chosen).to_string();
499        } else if let Some(first) = parts.first() {
500            exchange = (*first).to_string();
501        }
502    }
503
504    if convert_exchange_to_mic_venue {
505        if let Some(venue) = exchange_to_mic_venue(&exchange) {
506            return venue;
507        }
508    }
509
510    exchange
511}
512
513/// Convert an Interactive Brokers exchange code to a MIC venue when known.
514#[must_use]
515pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
516    VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
517        exchanges
518            .contains(&exchange)
519            .then(|| (*venue_member).to_string())
520    })
521}
522
523/// Convert a NautilusTrader `InstrumentId` to an Interactive Brokers `Contract`.
524///
525/// This function handles all instrument types:
526/// - Stocks (STK)
527/// - Options (OPT)
528/// - Futures (FUT, CONTFUT)
529/// - Futures Options (FOP)
530/// - Forex (CASH)
531/// - Crypto (CRYPTO)
532/// - CFDs (CFD)
533/// - Commodities (CMDTY)
534/// - Indices (IND)
535/// - Option Spreads (BAG) - requires contract details map
536///
537/// # Errors
538///
539/// Returns an error if the conversion fails (e.g., unsupported instrument type, invalid format).
540pub fn instrument_id_to_ib_contract(
541    instrument_id: InstrumentId,
542    exchange: Option<&str>,
543) -> anyhow::Result<Contract> {
544    let venue_str = instrument_id.venue.to_string();
545    let derived_exchange = VENUE_MEMBERS
546        .get(venue_str.as_str())
547        .and_then(|exchanges| exchanges.first().copied())
548        .or_else(|| {
549            if venue_matches(venue_str.as_str(), VENUES_CASH)
550                || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
551                || venue_matches(venue_str.as_str(), VENUES_OPT)
552                || venue_matches(venue_str.as_str(), VENUES_FUT)
553            {
554                Some(venue_str.as_str())
555            } else {
556                None
557            }
558        })
559        .unwrap_or("SMART");
560    let exchange_str = exchange.unwrap_or(derived_exchange);
561    let symbol_str = instrument_id.symbol.as_str();
562
563    if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
564        return Ok(contract);
565    }
566
567    // Handle spreads (BAG contracts) - requires contract details, so we skip for now
568    // This should be handled by the instrument provider which has access to contract details
569    // if symbol_str.contains(":") {
570    //     return create_bag_contract(instrument_id, exchange_str);
571    // }
572
573    // Handle Forex (CASH)
574    if venue_matches(venue_str.as_str(), VENUES_CASH)
575        && let Some(captures) = parse_cash_symbol(symbol_str)
576    {
577        return Ok(Contract {
578            contract_id: 0,
579            symbol: Symbol::from(&captures.base),
580            security_type: SecurityType::ForexPair,
581            exchange: Exchange::from(exchange_str),
582            currency: Currency::from(&captures.quote),
583            local_symbol: format!("{}.{}", captures.base, captures.quote),
584            ..Default::default()
585        });
586    }
587
588    // Handle Crypto
589    if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
590        && let Some(captures) = parse_crypto_symbol(symbol_str)
591    {
592        return Ok(Contract {
593            contract_id: 0,
594            symbol: Symbol::from(&captures.base),
595            security_type: SecurityType::Crypto,
596            exchange: Exchange::from(exchange_str),
597            currency: Currency::from(&captures.quote),
598            local_symbol: format!("{}.{}", captures.base, captures.quote),
599            ..Default::default()
600        });
601    }
602
603    // Handle Options (OPT)
604    if venue_matches(venue_str.as_str(), VENUES_OPT) {
605        if let Some(opt) = parse_option_symbol(symbol_str) {
606            return Ok(Contract {
607                contract_id: 0,
608                symbol: Symbol::from(&opt.symbol),
609                security_type: SecurityType::Option,
610                exchange: Exchange::from(exchange_str),
611                currency: Currency::from("USD"), // Will be resolved from contract details
612                local_symbol: opt.local_symbol,
613                last_trade_date_or_contract_month: opt.expiry,
614                strike: opt.strike_value,
615                right: Some(opt.right),
616                ..Default::default()
617            });
618        }
619
620        if let Some(opt) = parse_named_option_symbol(symbol_str) {
621            return Ok(Contract {
622                contract_id: 0,
623                symbol: Symbol::from(&opt.trading_class),
624                security_type: SecurityType::Option,
625                exchange: Exchange::from(exchange_str),
626                currency: Currency::from("USD"),
627                trading_class: opt.trading_class,
628                last_trade_date_or_contract_month: opt.expiry,
629                strike: opt.strike_value,
630                right: Some(opt.right),
631                ..Default::default()
632            });
633        }
634    }
635
636    // Handle Futures and Futures Options
637    if venue_matches(venue_str.as_str(), VENUES_FUT) {
638        if let Some(fut) = parse_named_futures_symbol(symbol_str) {
639            return Ok(Contract {
640                contract_id: 0,
641                symbol: Symbol::from(&fut.underlying),
642                security_type: SecurityType::Future,
643                exchange: Exchange::from(exchange_str),
644                currency: Currency::from("USD"),
645                trading_class: fut.trading_class,
646                last_trade_date_or_contract_month: fut.expiry,
647                ..Default::default()
648            });
649        }
650
651        // Check for continuous futures (underlying only, no expiry)
652        // IB uses FUT with no expiry date to represent continuous futures
653        if let Some(underlying) = parse_futures_underlying(symbol_str) {
654            return Ok(Contract {
655                contract_id: 0,
656                symbol: Symbol::from(&underlying),
657                security_type: SecurityType::ContinuousFuture,
658                exchange: Exchange::from(exchange_str),
659                currency: Currency::from("USD"), // Will be resolved from contract details
660                ..Default::default()
661            });
662        }
663
664        // Check for Futures Options (FOP)
665        if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
666            return Ok(Contract {
667                contract_id: 0,
668                security_type: SecurityType::FuturesOption,
669                exchange: Exchange::from(exchange_str),
670                currency: Currency::from("USD"),
671                local_symbol,
672                ..Default::default()
673            });
674        }
675
676        // Check for regular Futures (FUT)
677        if let Some(fut) = parse_futures_symbol(symbol_str) {
678            return Ok(Contract {
679                contract_id: 0,
680                security_type: SecurityType::Future,
681                exchange: Exchange::from(exchange_str),
682                currency: Currency::from("USD"),
683                local_symbol: fut.local_symbol,
684                ..Default::default()
685            });
686        }
687    }
688
689    // Handle CFDs
690    if venue_matches(venue_str.as_str(), VENUES_CFD) {
691        if let Some(captures) =
692            parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
693        {
694            return Ok(Contract {
695                contract_id: 0,
696                symbol: Symbol::from(&captures.base),
697                security_type: SecurityType::CFD,
698                exchange: Exchange::from("SMART"),
699                currency: Currency::from(&captures.quote),
700                local_symbol: format!("{}.{}", captures.base, captures.quote),
701                ..Default::default()
702            });
703        } else {
704            // CFD with space-separated symbol
705            let symbol_clean = symbol_str.replace('-', " ");
706            return Ok(Contract {
707                contract_id: 0,
708                symbol: Symbol::from(&symbol_clean),
709                security_type: SecurityType::CFD,
710                exchange: Exchange::from("SMART"),
711                currency: Currency::from("USD"),
712                ..Default::default()
713            });
714        }
715    }
716
717    // Handle Commodities
718    if VENUES_CMDTY.contains(&venue_str.as_str()) {
719        let symbol_clean = symbol_str.replace('-', " ");
720        return Ok(Contract {
721            contract_id: 0,
722            symbol: Symbol::from(&symbol_clean),
723            security_type: SecurityType::Commodity,
724            exchange: Exchange::from("SMART"),
725            currency: Currency::from("USD"),
726            ..Default::default()
727        });
728    }
729
730    // Handle Indices (symbols starting with ^)
731    if let Some(local_symbol) = symbol_str.strip_prefix('^') {
732        return Ok(Contract {
733            contract_id: 0,
734            symbol: Symbol::from(local_symbol),
735            security_type: SecurityType::Index,
736            exchange: Exchange::from(exchange_str),
737            currency: Currency::from("USD"),
738            local_symbol: local_symbol.into(),
739            ..Default::default()
740        });
741    }
742
743    // Default to Stock (STK)
744    let symbol_clean = symbol_str.replace('-', " ");
745    Ok(Contract {
746        contract_id: 0,
747        symbol: Symbol::from(&symbol_clean),
748        security_type: SecurityType::Stock,
749        exchange: Exchange::from("SMART"),
750        currency: Currency::from(""), // Will be resolved from contract details
751        primary_exchange: Exchange::from(exchange_str),
752        ..Default::default()
753    })
754}
755
756fn instrument_id_to_ib_contract_raw(
757    instrument_id: &InstrumentId,
758    exchange: Option<&str>,
759) -> Option<Contract> {
760    let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
761
762    let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
763    let exchange_str = exchange.unwrap_or(venue_exchange.as_str());
764    let security_type = IbSecurityType::from_str(sec_type_code)
765        .ok()
766        .map(IbSecurityType::ibapi_security_type)?;
767
768    let contract = match security_type {
769        SecurityType::Stock => Contract {
770            contract_id: 0,
771            security_type,
772            exchange: Exchange::from("SMART"),
773            primary_exchange: Exchange::from(exchange_str),
774            local_symbol: local_symbol.to_string(),
775            ..Default::default()
776        },
777        SecurityType::CFD | SecurityType::Commodity => Contract {
778            contract_id: 0,
779            security_type,
780            exchange: Exchange::from("SMART"),
781            local_symbol: local_symbol.to_string(),
782            ..Default::default()
783        },
784        SecurityType::Index => Contract {
785            contract_id: 0,
786            security_type,
787            exchange: Exchange::from(exchange_str),
788            local_symbol: local_symbol.to_string(),
789            ..Default::default()
790        },
791        _ => Contract {
792            contract_id: 0,
793            security_type,
794            exchange: Exchange::from(exchange_str),
795            local_symbol: local_symbol.to_string(),
796            ..Default::default()
797        },
798    };
799
800    Some(contract)
801}
802
803/// Currency pair captures
804struct CurrencyPair {
805    base: String,
806    quote: String,
807}
808
809/// Parse cash/forex symbol like "EUR/USD"
810fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
811    if let Some((base, quote)) = symbol.split_once('/')
812        && base.len() == 3
813        && quote.len() == 3
814    {
815        return Some(CurrencyPair {
816            base: base.to_string(),
817            quote: quote.to_string(),
818        });
819    }
820    None
821}
822
823/// Parse crypto symbol like "BTC/USD".
824fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
825    if let Some((base, quote)) = symbol.split_once('/')
826        && !base.is_empty()
827        && base.chars().all(|ch| ch.is_ascii_uppercase())
828        && quote.len() == 3
829        && quote.chars().all(|ch| ch.is_ascii_uppercase())
830    {
831        return Some(CurrencyPair {
832            base: base.to_string(),
833            quote: quote.to_string(),
834        });
835    }
836    None
837}
838
839/// Parse CFD cash symbol like "EUR.USD"
840fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
841    if let Some((base, quote)) = symbol.split_once('.')
842        && base.len() == 3
843        && quote.len() == 3
844    {
845        return Some(CurrencyPair {
846            base: base.to_string(),
847            quote: quote.to_string(),
848        });
849    }
850    None
851}
852
853/// Option symbol captures
854struct OptionSymbol {
855    symbol: String,
856    expiry: String,
857    right: OptionRight,
858    local_symbol: String,
859    strike_value: f64,
860}
861
862/// Parse option symbol like "AAPL230120C00150000" (6-char symbol, 6-char expiry YYMMDD, 1-char right, 8-char strike)
863fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
864    // Pattern: SYMBOL + YYMMDD + C/P + STRIKE (8 digits, could have decimal)
865    // Minimum: 6 (symbol) + 6 (date) + 1 (right) + 8 (strike) = 21 chars
866    if symbol.len() < 21 {
867        return None;
868    }
869
870    // Try to match: 6-char symbol, 6-char date, 1-char right (C/P), remainder is strike
871    let symbol_part = symbol[..6.min(symbol.len())].trim();
872    let remaining = &symbol[6.min(symbol.len())..];
873
874    if remaining.len() < 15 {
875        return None;
876    }
877
878    let expiry = &remaining[..6];
879    let right_char = remaining.chars().nth(6)?;
880    let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
881
882    let strike_str = &remaining[7..];
883    if strike_str.len() < 8 {
884        return None;
885    }
886
887    // Strike is typically 8 digits with possible decimal
888    let strike_value = if strike_str.contains('.') {
889        strike_str.parse().ok()?
890    } else {
891        // 8-digit integer strike, divide by 1000 for typical option strikes
892        let strike_int: i32 = strike_str.parse().ok()?;
893        strike_int as f64 / 1000.0
894    };
895
896    Some(OptionSymbol {
897        symbol: symbol_part.to_string(),
898        expiry: expiry.to_string(),
899        right: ib_option_right_to_option_right(right),
900        local_symbol: symbol.to_string(),
901        strike_value,
902    })
903}
904
905/// Named option symbol captures for formats like "C OESX 20260213 4775".
906struct NamedOptionSymbol {
907    trading_class: String,
908    expiry: String,
909    right: OptionRight,
910    strike_value: f64,
911}
912
913/// Named futures symbol captures for formats like "ESTX50 FESX 20240315".
914struct NamedFuturesSymbol {
915    underlying: String,
916    trading_class: String,
917    expiry: String,
918}
919
920fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
921    let parts: Vec<&str> = symbol.split_whitespace().collect();
922    if parts.len() != 3 {
923        return None;
924    }
925
926    let expiry = parts[2];
927    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
928        return None;
929    }
930
931    Some(NamedFuturesSymbol {
932        underlying: parts[0].to_string(),
933        trading_class: parts[1].to_string(),
934        expiry: expiry.to_string(),
935    })
936}
937
938fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
939    let parts: Vec<&str> = symbol.split_whitespace().collect();
940    if !(parts.len() == 4 || parts.len() == 5) {
941        return None;
942    }
943
944    let right = IbOptionRight::from_str(parts[0]).ok()?;
945
946    let expiry = parts[2];
947    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
948        return None;
949    }
950
951    Some(NamedOptionSymbol {
952        trading_class: parts[1].to_string(),
953        expiry: expiry.to_string(),
954        right: ib_option_right_to_option_right(right),
955        strike_value: parts[3].parse::<f64>().ok()?,
956    })
957}
958
959fn normalize_option_symbol(local_symbol: &str) -> String {
960    if local_symbol.len() >= 15 {
961        let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
962        let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
963            && matches!(suffix.chars().nth(6), Some('C' | 'P'))
964            && suffix[7..].chars().all(|c| c.is_ascii_digit());
965
966        if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
967            return format!("{:<6}{}", root.trim_end(), suffix);
968        }
969    }
970
971    local_symbol.to_string()
972}
973
974fn format_option_strike(strike: f64) -> String {
975    if strike.fract() == 0.0 {
976        format!("{strike:.0}")
977    } else {
978        format!("{strike}")
979    }
980}
981
982/// Futures symbol captures
983struct FuturesSymbol {
984    local_symbol: String,
985}
986
987/// Parse futures underlying (continuous) - just the symbol without expiry
988fn parse_futures_underlying(symbol: &str) -> Option<String> {
989    // If it's just 1-3 characters, it's likely an underlying
990    if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
991        Some(symbol.to_string())
992    } else {
993        None
994    }
995}
996
997fn is_futures_month_code(ch: char) -> bool {
998    matches!(
999        ch,
1000        'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
1001    )
1002}
1003
1004fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1005    for (month_pos, month_char) in symbol.char_indices().rev() {
1006        if !is_futures_month_code(month_char) {
1007            continue;
1008        }
1009
1010        let remaining = &symbol[month_pos + month_char.len_utf8()..];
1011        if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
1012            continue;
1013        }
1014
1015        let year = match remaining.len() {
1016            1 | 2 => remaining.to_string(),
1017            4 => remaining[remaining.len() - 2..].to_string(),
1018            _ => continue,
1019        };
1020
1021        if month_pos == 0 {
1022            continue;
1023        }
1024
1025        return Some((month_pos, month_char, year));
1026    }
1027
1028    None
1029}
1030
1031/// Parse futures symbol like "YMM6", "ESM23", or "ESM2023"
1032fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
1033    parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
1034        local_symbol: symbol.to_string(),
1035    })
1036}
1037
1038/// Parse futures option symbol like "YMM6 C4500", "ESM23 C4500", or "ESM2023 C4500"
1039fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
1040    let (futures_symbol, rest) = symbol.split_once(' ')?;
1041    let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
1042    let _fut_symbol = &futures_symbol[..month_pos];
1043
1044    // Parse right and strike
1045    let right_char = rest.chars().next()?;
1046    IbOptionRight::from_str(&right_char.to_string()).ok()?;
1047
1048    let strike_str = &rest[1..];
1049    strike_str.parse::<f64>().ok()?;
1050
1051    Some(symbol.to_string())
1052}
1053
1054/// Check if an instrument ID represents a spread.
1055///
1056/// This checks if the symbol contains the spread format pattern: `(ratio)symbol_` or `((ratio))symbol_`
1057#[must_use]
1058pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
1059    let symbol_str = instrument_id.symbol.as_str();
1060    // Check if symbol contains spread pattern: (ratio) or ((ratio))
1061    symbol_str.contains('(') && symbol_str.contains('_')
1062}
1063
1064/// Create a spread instrument ID from leg tuples.
1065///
1066/// This implements the same logic as Python's `InstrumentId.new_spread`:
1067/// - Creates a symbol string like `(1)SYMBOL1_((2))SYMBOL2`
1068/// - Positive ratios: `(ratio)SYMBOL`
1069/// - Negative ratios: `((abs(ratio)))SYMBOL`
1070/// - Sorts legs alphabetically by symbol
1071/// - All legs must have the same venue
1072///
1073/// # Errors
1074///
1075/// Returns an error if:
1076/// - Less than 2 legs provided
1077/// - Any ratio is zero
1078/// - Venues don't match across legs
1079pub fn create_spread_instrument_id(
1080    leg_tuples: &[(InstrumentId, i32)],
1081) -> anyhow::Result<InstrumentId> {
1082    if leg_tuples.len() < 2 {
1083        anyhow::bail!("instrument_ratios list needs to have at least 2 legs");
1084    }
1085
1086    let first_venue = leg_tuples[0].0.venue;
1087
1088    for (instrument_id, ratio) in leg_tuples {
1089        if *ratio == 0 {
1090            anyhow::bail!("ratio cannot be zero");
1091        }
1092
1093        if instrument_id.venue != first_venue {
1094            anyhow::bail!(
1095                "All venues must match. Expected {}, was {}",
1096                first_venue,
1097                instrument_id.venue
1098            );
1099        }
1100    }
1101
1102    let mut sorted_ratios = leg_tuples.to_vec();
1103    sorted_ratios.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1104
1105    let symbol_parts = sorted_ratios
1106        .iter()
1107        .map(|(instrument_id, ratio)| {
1108            if *ratio > 0 {
1109                format!("({}){}", ratio, instrument_id.symbol.as_str())
1110            } else {
1111                format!("(({})){}", ratio.abs(), instrument_id.symbol.as_str())
1112            }
1113        })
1114        .collect::<Vec<_>>();
1115
1116    let composite_symbol = symbol_parts.join("_");
1117    let symbol = NautilusSymbol::from(composite_symbol.as_str());
1118
1119    Ok(InstrumentId::new(symbol, first_venue))
1120}
1121
1122/// Parse a spread instrument ID back into leg tuples.
1123///
1124/// This implements the same logic as Python's `InstrumentId.to_list()`:
1125/// - Parses symbol string like `(1)SYMBOL1_((2))SYMBOL2`
1126/// - Positive ratios: `(ratio)SYMBOL`
1127/// - Negative ratios: `((abs(ratio)))SYMBOL`
1128/// - Returns sorted list of (instrument_id, ratio) tuples
1129///
1130/// # Errors
1131///
1132/// Returns an error if the symbol format is invalid.
1133pub fn parse_spread_instrument_id_to_legs(
1134    instrument_id: &InstrumentId,
1135) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
1136    let symbol_str = instrument_id.symbol.as_str();
1137    let venue = instrument_id.venue;
1138
1139    let components: Vec<&str> = symbol_str.split('_').collect();
1140    let mut result = Vec::new();
1141
1142    for component in components {
1143        if component.is_empty() {
1144            continue;
1145        }
1146
1147        // Check for negative ratio: ((ratio))symbol
1148        if let Some(rest) = component.strip_prefix("((")
1149            && let Some(pos) = rest.find("))")
1150        {
1151            let ratio_str = &rest[..pos];
1152            let symbol_value = &rest[pos + 2..];
1153
1154            if let Ok(ratio) = ratio_str.parse::<i32>() {
1155                let leg_instrument_id =
1156                    InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1157                result.push((leg_instrument_id, -ratio));
1158                continue;
1159            }
1160        }
1161
1162        // Check for positive ratio: (ratio)symbol
1163        if let Some(rest) = component.strip_prefix('(')
1164            && let Some(pos) = rest.find(')')
1165        {
1166            let ratio_str = &rest[..pos];
1167            let symbol_value = &rest[pos + 1..];
1168
1169            if let Ok(ratio) = ratio_str.parse::<i32>() {
1170                let leg_instrument_id =
1171                    InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1172                result.push((leg_instrument_id, ratio));
1173                continue;
1174            }
1175        }
1176
1177        anyhow::bail!("Invalid spread symbol format for component: {component}");
1178    }
1179
1180    // Sort result alphabetically by symbol
1181    result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1182
1183    Ok(result)
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1189    use nautilus_model::identifiers::InstrumentId;
1190    use rstest::rstest;
1191
1192    use super::{ib_contract_to_instrument_id_simplified, instrument_id_to_ib_contract};
1193
1194    #[rstest]
1195    fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1196        let contract = Contract {
1197            symbol: Symbol::from("SPXW"),
1198            security_type: SecurityType::Option,
1199            exchange: Exchange::from("SMART"),
1200            currency: Currency::from("USD"),
1201            local_symbol: "SPXW260313P06630000".to_string(),
1202            last_trade_date_or_contract_month: "260313".to_string(),
1203            right: Some(OptionRight::Put),
1204            strike: 6630.0,
1205            ..Default::default()
1206        };
1207
1208        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1209
1210        assert_eq!(
1211            instrument_id,
1212            InstrumentId::from("SPXW  260313P06630000.SMART")
1213        );
1214    }
1215
1216    #[rstest]
1217    fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1218        let contract = Contract {
1219            symbol: Symbol::from("OESX"),
1220            security_type: SecurityType::Option,
1221            exchange: Exchange::from("EUREX"),
1222            currency: Currency::from("EUR"),
1223            trading_class: "OESX".to_string(),
1224            local_symbol: String::new(),
1225            last_trade_date_or_contract_month: "20260213".to_string(),
1226            right: Some(OptionRight::Call),
1227            strike: 4775.0,
1228            ..Default::default()
1229        };
1230
1231        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1232
1233        assert_eq!(
1234            instrument_id,
1235            InstrumentId::from("C OESX 20260213 4775.EUREX")
1236        );
1237    }
1238
1239    #[rstest]
1240    fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1241        let contract = Contract {
1242            symbol: Symbol::from("EX2"),
1243            security_type: SecurityType::FuturesOption,
1244            exchange: Exchange::from("NYBOT"),
1245            currency: Currency::from("USD"),
1246            local_symbol: "EX2G3 P4080".to_string(),
1247            ..Default::default()
1248        };
1249
1250        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1251
1252        assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1253    }
1254
1255    #[rstest]
1256    fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1257        let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1258
1259        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1260
1261        assert_eq!(contract.security_type, SecurityType::Option);
1262        assert_eq!(contract.exchange.as_str(), "EUREX");
1263        assert_eq!(contract.symbol.as_str(), "OESX");
1264        assert_eq!(contract.trading_class.as_str(), "OESX");
1265        assert_eq!(
1266            contract.last_trade_date_or_contract_month.as_str(),
1267            "20260213"
1268        );
1269        assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1270        assert_eq!(contract.strike, 4775.0);
1271    }
1272
1273    #[rstest]
1274    fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1275        let instrument_id = InstrumentId::from("AAPL  230217P00155000.SMART");
1276
1277        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1278
1279        assert_eq!(contract.security_type, SecurityType::Option);
1280        assert_eq!(contract.exchange.as_str(), "SMART");
1281        assert_eq!(contract.symbol.as_str(), "AAPL");
1282        assert_eq!(contract.local_symbol.as_str(), "AAPL  230217P00155000");
1283        assert_eq!(
1284            contract.last_trade_date_or_contract_month.as_str(),
1285            "230217"
1286        );
1287        assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1288        assert_eq!(contract.strike, 155.0);
1289    }
1290
1291    #[rstest]
1292    fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1293        let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1294
1295        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1296
1297        assert_eq!(contract.security_type, SecurityType::Future);
1298        assert_eq!(contract.exchange.as_str(), "EUREX");
1299        assert_eq!(contract.symbol.as_str(), "ESTX50");
1300        assert_eq!(contract.trading_class.as_str(), "FESX");
1301        assert_eq!(
1302            contract.last_trade_date_or_contract_month.as_str(),
1303            "20240315"
1304        );
1305    }
1306
1307    #[rstest]
1308    fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1309        let instrument_id = InstrumentId::from("YMM6.XCBT");
1310
1311        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1312
1313        assert_eq!(contract.security_type, SecurityType::Future);
1314        assert_eq!(contract.exchange.as_str(), "CBOT");
1315        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1316        assert!(contract.symbol.as_str().is_empty());
1317        assert!(contract.last_trade_date_or_contract_month.is_empty());
1318    }
1319
1320    #[rstest]
1321    fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1322        let instrument_id = InstrumentId::from("OESXH6.XEUR");
1323
1324        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1325
1326        assert_eq!(contract.security_type, SecurityType::Future);
1327        assert_eq!(contract.exchange.as_str(), "DTB");
1328        assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1329    }
1330
1331    #[rstest]
1332    fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1333        let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1334
1335        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1336
1337        assert_eq!(contract.security_type, SecurityType::FuturesOption);
1338        assert_eq!(contract.exchange.as_str(), "CBOT");
1339        assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1340    }
1341
1342    #[rstest]
1343    fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1344        let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1345
1346        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1347
1348        assert_eq!(contract.security_type, SecurityType::CFD);
1349        assert_eq!(contract.exchange.as_str(), "SMART");
1350        assert_eq!(contract.symbol.as_str(), "EUR");
1351        assert_eq!(contract.currency.as_str(), "USD");
1352        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1353    }
1354
1355    #[rstest]
1356    fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1357        let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1358
1359        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1360
1361        assert_eq!(contract.security_type, SecurityType::Crypto);
1362        assert_eq!(contract.exchange.as_str(), "PAXOS");
1363        assert_eq!(contract.symbol.as_str(), "DOGE");
1364        assert_eq!(contract.currency.as_str(), "USD");
1365        assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1366    }
1367
1368    #[rstest]
1369    fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1370        let instrument_id = InstrumentId::from("ES.XCME");
1371
1372        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1373
1374        assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1375        assert_eq!(contract.exchange.as_str(), "CME");
1376        assert_eq!(contract.symbol.as_str(), "ES");
1377    }
1378
1379    #[rstest]
1380    fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1381        let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1382
1383        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1384
1385        assert_eq!(contract.security_type, SecurityType::Stock);
1386        assert_eq!(contract.exchange.as_str(), "SMART");
1387        assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1388        assert_eq!(contract.local_symbol.as_str(), "AAPL");
1389        assert!(contract.symbol.as_str().is_empty());
1390    }
1391
1392    #[rstest]
1393    fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1394        let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1395
1396        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1397
1398        assert_eq!(contract.security_type, SecurityType::ForexPair);
1399        assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1400        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1401        assert!(contract.symbol.as_str().is_empty());
1402    }
1403
1404    #[rstest]
1405    fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1406        let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1407
1408        let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
1409
1410        assert_eq!(contract.security_type, SecurityType::Future);
1411        assert_eq!(contract.exchange.as_str(), "CBOT");
1412        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1413    }
1414
1415    #[rstest]
1416    fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1417        let instrument_id = InstrumentId::from("IUSA.IBIS");
1418
1419        let contract = instrument_id_to_ib_contract(instrument_id, Some("IBIS")).unwrap();
1420
1421        assert_eq!(contract.security_type, SecurityType::Stock);
1422        assert_eq!(contract.symbol.as_str(), "IUSA");
1423        assert_eq!(contract.exchange.as_str(), "SMART");
1424        assert_eq!(contract.primary_exchange.as_str(), "IBIS");
1425        assert!(contract.currency.as_str().is_empty());
1426        assert!(contract.local_symbol.is_empty());
1427    }
1428}