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 => derive_crypto_venue(contract),
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 => derive_crypto_venue(contract),
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/// Returns `true` if the contract is a cryptocurrency contract.
361///
362/// Centralizes the crypto check used to gate crypto-specific request handling
363/// (e.g. the `AGGTRADES` `whatToShow` rule - see
364/// [`crate::data::convert::price_type_to_ib_what_to_show_for_security`]).
365#[must_use]
366pub fn is_crypto_contract(contract: &Contract) -> bool {
367    matches!(contract.security_type, SecurityType::Crypto)
368}
369
370/// Derives the venue for a crypto contract.
371///
372/// IB routes crypto to multiple venues; both PAXOS and ZEROHASH are live (which one
373/// applies depends on the account/region). Uses the contract's actual exchange when
374/// set (e.g. ZEROHASH or PAXOS), falling back to PAXOS when it is unspecified.
375#[must_use]
376pub fn derive_crypto_venue(contract: &Contract) -> Venue {
377    if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
378        Venue::from(contract.exchange.as_str())
379    } else {
380        Venue::from("PAXOS")
381    }
382}
383
384#[must_use]
385pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
386    if venue == "OPRA" {
387        return vec!["SMART".to_string()];
388    }
389
390    if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
391        return exchanges
392            .iter()
393            .map(|exchange| (*exchange).to_string())
394            .collect();
395    }
396
397    vec![venue.to_string()]
398}
399
400/// Venue lists for different asset classes
401const VENUES_CASH: &[&str] = &["IDEALPRO"];
402// IB routes crypto to both PAXOS and ZEROHASH (which one applies depends on the
403// account/region); accept both.
404const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
405const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
406const VENUES_FUT: &[&str] = &[
407    "BELFOX",
408    "GLOBEX",
409    "CBOT",
410    "CFE",
411    "CME",
412    "COMEX",
413    "CBOE",
414    "DTB",
415    "EUREX",
416    "HKFE",
417    "ICE",
418    "ICEEU",
419    "ICEEUSOFT",
420    "IDEM",
421    "IPE",
422    "KCBT",
423    "MEXDER",
424    "MGE",
425    "NYBOT",
426    "NYMEX",
427    "OSE.JPN",
428    "SNFE",
429    "SOFFEX",
430    "VRTX",
431    "CMECRYPTO",
432    "NYMEXMETALS",
433    "NYMEXNG",
434    "NYMEXENERGY",
435    "CMEPRECIOUS",
436    "CMECURRENCY",
437    "CMEINDEX",
438    "CMEWEATHER",
439    "CMEINTEREST",
440    "CMEFLOOR",
441    "CBOTFLOOR",
442    "NYMEXFLOOR",
443    "NYBOTFLOOR",
444    "CFEFLOOR",
445    "CMEOPTIONS",
446    "CBOTOPTIONS",
447    "NYMEXOPTIONS",
448    "NYBOTOPTIONS",
449    "ECBOT",
450];
451const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
452const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
453
454fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
455    venues.contains(&venue_str)
456        || VENUE_MEMBERS
457            .get(venue_str)
458            .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
459}
460
461fn is_option_venue(venue: &str) -> bool {
462    venue == "OPRA" || venue_matches(venue, VENUES_OPT)
463}
464
465fn is_canonical_occ_option_symbol(symbol: &str) -> bool {
466    let bytes = symbol.as_bytes();
467    if bytes.len() != 21 || !symbol.is_ascii() {
468        return false;
469    }
470
471    let root = &bytes[..6];
472    let root_len = root.iter().position(|byte| *byte == b' ').unwrap_or(6);
473
474    root_len > 0
475        && root[..root_len].iter().all(u8::is_ascii_graphic)
476        && root[root_len..].iter().all(|byte| *byte == b' ')
477        && bytes[6..12].iter().all(u8::is_ascii_digit)
478        && matches!(bytes[12], b'C' | b'P')
479        && bytes[13..].iter().all(u8::is_ascii_digit)
480}
481
482/// 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)
483/// This constant is kept for potential future use in more complex parsing scenarios.
484#[allow(dead_code)]
485const FUTURES_MONTH_CODES: &[(char, &str)] = &[
486    ('F', "01"),
487    ('G', "02"),
488    ('H', "03"),
489    ('J', "04"),
490    ('K', "05"),
491    ('M', "06"),
492    ('N', "07"),
493    ('Q', "08"),
494    ('U', "09"),
495    ('V', "10"),
496    ('X', "11"),
497    ('Z', "12"),
498];
499
500/// Determine venue from contract using provider configuration.
501///
502/// This implements the same logic as Python's `determine_venue_from_contract`:
503/// 1. Check symbol-specific venue mapping first (prefix matching)
504/// 2. Use VENUE_MEMBERS mapping if convert_exchange_to_mic_venue is enabled
505/// 3. Fall back to exchange
506pub fn determine_venue_from_contract(
507    contract: &Contract,
508    symbol_to_mic_venue: &std::collections::HashMap<String, String>,
509    convert_exchange_to_mic_venue: bool,
510    valid_exchanges: Option<&str>,
511) -> String {
512    if matches!(contract.security_type, SecurityType::CFD) {
513        return "IBCFD".to_string();
514    }
515
516    if matches!(contract.security_type, SecurityType::Commodity) {
517        return "IBCMDTY".to_string();
518    }
519
520    if !symbol_to_mic_venue.is_empty() {
521        let symbol = contract.symbol.as_str();
522        for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
523            if symbol.starts_with(symbol_prefix) {
524                return symbol_venue.clone();
525            }
526        }
527    }
528
529    // Use the exchange from the contract (primaryExchange if exchange is SMART)
530    let mut exchange = if contract.exchange.as_str() == "SMART"
531        && !contract.primary_exchange.as_str().is_empty()
532        && contract.primary_exchange.as_str() != "SMART"
533    {
534        contract.primary_exchange.as_str().to_string()
535    } else {
536        contract.exchange.as_str().to_string()
537    };
538
539    if exchange == "SMART"
540        && let Some(valid_exchanges) = valid_exchanges
541    {
542        let parts: Vec<&str> = valid_exchanges
543            .split(',')
544            .map(str::trim)
545            .filter(|part| !part.is_empty())
546            .collect();
547
548        if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
549            exchange = (*chosen).to_string();
550        } else if let Some(first) = parts.first() {
551            exchange = (*first).to_string();
552        }
553    }
554
555    if convert_exchange_to_mic_venue {
556        if let Some(venue) = exchange_to_mic_venue(&exchange) {
557            return venue;
558        }
559    }
560
561    exchange
562}
563
564/// Convert an Interactive Brokers exchange code to a MIC venue when known.
565#[must_use]
566pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
567    VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
568        exchanges
569            .contains(&exchange)
570            .then(|| (*venue_member).to_string())
571    })
572}
573
574/// Convert a NautilusTrader `InstrumentId` to an Interactive Brokers `Contract`.
575///
576/// This function handles all instrument types:
577/// - Stocks (STK)
578/// - Options (OPT)
579/// - Futures (FUT, CONTFUT)
580/// - Futures Options (FOP)
581/// - Forex (CASH)
582/// - Crypto (CRYPTO)
583/// - CFDs (CFD)
584/// - Commodities (CMDTY)
585/// - Indices (IND)
586/// - Option Spreads (BAG) - requires contract details map
587///
588/// # Errors
589///
590/// Returns an error if the conversion fails (e.g., unsupported instrument type, invalid format).
591pub fn instrument_id_to_ib_contract(
592    instrument_id: InstrumentId,
593    exchange: Option<&str>,
594) -> anyhow::Result<Contract> {
595    let venue_str = instrument_id.venue.to_string();
596    let derived_exchange = if venue_str == "OPRA" {
597        "SMART"
598    } else {
599        VENUE_MEMBERS
600            .get(venue_str.as_str())
601            .and_then(|exchanges| exchanges.first().copied())
602            .or_else(|| {
603                if venue_matches(venue_str.as_str(), VENUES_CASH)
604                    || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
605                    || venue_matches(venue_str.as_str(), VENUES_OPT)
606                    || venue_matches(venue_str.as_str(), VENUES_FUT)
607                {
608                    Some(venue_str.as_str())
609                } else {
610                    None
611                }
612            })
613            .unwrap_or("SMART")
614    };
615    let exchange_str = exchange.unwrap_or(derived_exchange);
616    let symbol_str = instrument_id.symbol.as_str();
617
618    if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
619        return Ok(contract);
620    }
621
622    // Handle spreads (BAG contracts) - requires contract details, so we skip for now
623    // This should be handled by the instrument provider which has access to contract details
624    // if symbol_str.contains(":") {
625    //     return create_bag_contract(instrument_id, exchange_str);
626    // }
627
628    // Handle Forex (CASH)
629    if venue_matches(venue_str.as_str(), VENUES_CASH)
630        && let Some(captures) = parse_cash_symbol(symbol_str)
631    {
632        return Ok(Contract {
633            contract_id: 0,
634            symbol: Symbol::from(&captures.base),
635            security_type: SecurityType::ForexPair,
636            exchange: Exchange::from(exchange_str),
637            currency: Currency::from(&captures.quote),
638            local_symbol: format!("{}.{}", captures.base, captures.quote),
639            ..Default::default()
640        });
641    }
642
643    // Handle Crypto
644    if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
645        && let Some(captures) = parse_crypto_symbol(symbol_str)
646    {
647        return Ok(Contract {
648            contract_id: 0,
649            symbol: Symbol::from(&captures.base),
650            security_type: SecurityType::Crypto,
651            exchange: Exchange::from(exchange_str),
652            currency: Currency::from(&captures.quote),
653            local_symbol: format!("{}.{}", captures.base, captures.quote),
654            ..Default::default()
655        });
656    }
657
658    // Handle Options (OPT)
659    if is_option_venue(venue_str.as_str()) {
660        if venue_str == "OPRA" {
661            if !is_canonical_occ_option_symbol(symbol_str) {
662                anyhow::bail!("Invalid OPRA option symbol: {symbol_str}");
663            }
664
665            return Ok(Contract {
666                contract_id: 0,
667                security_type: SecurityType::Option,
668                exchange: Exchange::from(exchange_str),
669                currency: Currency::from("USD"),
670                local_symbol: symbol_str.to_string(),
671                ..Default::default()
672            });
673        }
674
675        if let Some(opt) = parse_option_symbol(symbol_str) {
676            return Ok(Contract {
677                contract_id: 0,
678                symbol: Symbol::from(&opt.symbol),
679                security_type: SecurityType::Option,
680                exchange: Exchange::from(exchange_str),
681                currency: Currency::from("USD"), // Will be resolved from contract details
682                local_symbol: opt.local_symbol,
683                last_trade_date_or_contract_month: opt.expiry,
684                strike: opt.strike_value,
685                right: Some(opt.right),
686                ..Default::default()
687            });
688        }
689
690        if let Some(opt) = parse_named_option_symbol(symbol_str) {
691            return Ok(Contract {
692                contract_id: 0,
693                symbol: Symbol::from(&opt.trading_class),
694                security_type: SecurityType::Option,
695                exchange: Exchange::from(exchange_str),
696                currency: Currency::from("USD"),
697                trading_class: opt.trading_class,
698                last_trade_date_or_contract_month: opt.expiry,
699                strike: opt.strike_value,
700                right: Some(opt.right),
701                ..Default::default()
702            });
703        }
704    }
705
706    // Handle Futures and Futures Options
707    if venue_matches(venue_str.as_str(), VENUES_FUT) {
708        if let Some(fut) = parse_named_futures_symbol(symbol_str) {
709            return Ok(Contract {
710                contract_id: 0,
711                symbol: Symbol::from(&fut.underlying),
712                security_type: SecurityType::Future,
713                exchange: Exchange::from(exchange_str),
714                currency: Currency::from("USD"),
715                trading_class: fut.trading_class,
716                last_trade_date_or_contract_month: fut.expiry,
717                ..Default::default()
718            });
719        }
720
721        // Check for continuous futures (underlying only, no expiry)
722        // IB uses FUT with no expiry date to represent continuous futures
723        if let Some(underlying) = parse_futures_underlying(symbol_str) {
724            return Ok(Contract {
725                contract_id: 0,
726                symbol: Symbol::from(&underlying),
727                security_type: SecurityType::ContinuousFuture,
728                exchange: Exchange::from(exchange_str),
729                currency: Currency::from("USD"), // Will be resolved from contract details
730                ..Default::default()
731            });
732        }
733
734        // Check for Futures Options (FOP)
735        if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
736            return Ok(Contract {
737                contract_id: 0,
738                security_type: SecurityType::FuturesOption,
739                exchange: Exchange::from(exchange_str),
740                currency: Currency::from("USD"),
741                local_symbol,
742                ..Default::default()
743            });
744        }
745
746        // Check for regular Futures (FUT)
747        if let Some(fut) = parse_futures_symbol(symbol_str) {
748            return Ok(Contract {
749                contract_id: 0,
750                security_type: SecurityType::Future,
751                exchange: Exchange::from(exchange_str),
752                currency: Currency::from("USD"),
753                local_symbol: fut.local_symbol,
754                ..Default::default()
755            });
756        }
757    }
758
759    // Handle CFDs
760    if venue_matches(venue_str.as_str(), VENUES_CFD) {
761        if let Some(captures) =
762            parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
763        {
764            return Ok(Contract {
765                contract_id: 0,
766                symbol: Symbol::from(&captures.base),
767                security_type: SecurityType::CFD,
768                exchange: Exchange::from("SMART"),
769                currency: Currency::from(&captures.quote),
770                local_symbol: format!("{}.{}", captures.base, captures.quote),
771                ..Default::default()
772            });
773        } else {
774            // CFD with space-separated symbol
775            let symbol_clean = symbol_str.replace('-', " ");
776            return Ok(Contract {
777                contract_id: 0,
778                symbol: Symbol::from(&symbol_clean),
779                security_type: SecurityType::CFD,
780                exchange: Exchange::from("SMART"),
781                currency: Currency::from("USD"),
782                ..Default::default()
783            });
784        }
785    }
786
787    // Handle Commodities
788    if VENUES_CMDTY.contains(&venue_str.as_str()) {
789        let symbol_clean = symbol_str.replace('-', " ");
790        return Ok(Contract {
791            contract_id: 0,
792            symbol: Symbol::from(&symbol_clean),
793            security_type: SecurityType::Commodity,
794            exchange: Exchange::from("SMART"),
795            currency: Currency::from("USD"),
796            ..Default::default()
797        });
798    }
799
800    // Handle Indices (symbols starting with ^)
801    if let Some(local_symbol) = symbol_str.strip_prefix('^') {
802        return Ok(Contract {
803            contract_id: 0,
804            symbol: Symbol::from(local_symbol),
805            security_type: SecurityType::Index,
806            exchange: Exchange::from(exchange_str),
807            currency: Currency::from("USD"),
808            local_symbol: local_symbol.into(),
809            ..Default::default()
810        });
811    }
812
813    // Default to Stock (STK)
814    let symbol_clean = symbol_str.replace('-', " ");
815    Ok(Contract {
816        contract_id: 0,
817        symbol: Symbol::from(&symbol_clean),
818        security_type: SecurityType::Stock,
819        exchange: Exchange::from("SMART"),
820        currency: Currency::from(""), // Will be resolved from contract details
821        primary_exchange: Exchange::from(exchange_str),
822        ..Default::default()
823    })
824}
825
826fn instrument_id_to_ib_contract_raw(
827    instrument_id: &InstrumentId,
828    exchange: Option<&str>,
829) -> Option<Contract> {
830    let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
831
832    let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
833    let security_type = IbSecurityType::from_str(sec_type_code)
834        .ok()
835        .map(IbSecurityType::ibapi_security_type)?;
836    let default_exchange =
837        if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
838            "SMART"
839        } else {
840            venue_exchange.as_str()
841        };
842    let exchange_str = exchange.unwrap_or(default_exchange);
843
844    let contract = match security_type {
845        SecurityType::Stock => Contract {
846            contract_id: 0,
847            security_type,
848            exchange: Exchange::from("SMART"),
849            primary_exchange: Exchange::from(exchange_str),
850            local_symbol: local_symbol.to_string(),
851            ..Default::default()
852        },
853        SecurityType::CFD | SecurityType::Commodity => Contract {
854            contract_id: 0,
855            security_type,
856            exchange: Exchange::from("SMART"),
857            local_symbol: local_symbol.to_string(),
858            ..Default::default()
859        },
860        SecurityType::Index => Contract {
861            contract_id: 0,
862            security_type,
863            exchange: Exchange::from(exchange_str),
864            local_symbol: local_symbol.to_string(),
865            ..Default::default()
866        },
867        _ => Contract {
868            contract_id: 0,
869            security_type,
870            exchange: Exchange::from(exchange_str),
871            local_symbol: local_symbol.to_string(),
872            ..Default::default()
873        },
874    };
875
876    Some(contract)
877}
878
879/// Currency pair captures
880struct CurrencyPair {
881    base: String,
882    quote: String,
883}
884
885/// Parse cash/forex symbol like "EUR/USD"
886fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
887    if let Some((base, quote)) = symbol.split_once('/')
888        && base.len() == 3
889        && quote.len() == 3
890    {
891        return Some(CurrencyPair {
892            base: base.to_string(),
893            quote: quote.to_string(),
894        });
895    }
896    None
897}
898
899/// Parse crypto symbol like "BTC/USD".
900fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
901    if let Some((base, quote)) = symbol.split_once('/')
902        && !base.is_empty()
903        && base.chars().all(|ch| ch.is_ascii_uppercase())
904        && quote.len() == 3
905        && quote.chars().all(|ch| ch.is_ascii_uppercase())
906    {
907        return Some(CurrencyPair {
908            base: base.to_string(),
909            quote: quote.to_string(),
910        });
911    }
912    None
913}
914
915/// Parse CFD cash symbol like "EUR.USD"
916fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
917    if let Some((base, quote)) = symbol.split_once('.')
918        && base.len() == 3
919        && quote.len() == 3
920    {
921        return Some(CurrencyPair {
922            base: base.to_string(),
923            quote: quote.to_string(),
924        });
925    }
926    None
927}
928
929/// Option symbol captures
930struct OptionSymbol {
931    symbol: String,
932    expiry: String,
933    right: OptionRight,
934    local_symbol: String,
935    strike_value: f64,
936}
937
938/// Parse option symbol like "AAPL230120C00150000" (6-char symbol, 6-char expiry YYMMDD, 1-char right, 8-char strike)
939fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
940    // Pattern: SYMBOL + YYMMDD + C/P + STRIKE (8 digits, could have decimal)
941    // Minimum: 6 (symbol) + 6 (date) + 1 (right) + 8 (strike) = 21 chars
942    if symbol.len() < 21 {
943        return None;
944    }
945
946    // Try to match: 6-char symbol, 6-char date, 1-char right (C/P), remainder is strike
947    let symbol_part = symbol[..6.min(symbol.len())].trim();
948    let remaining = &symbol[6.min(symbol.len())..];
949
950    if remaining.len() < 15 {
951        return None;
952    }
953
954    let expiry = &remaining[..6];
955    let right_char = remaining.chars().nth(6)?;
956    let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
957
958    let strike_str = &remaining[7..];
959    if strike_str.len() < 8 {
960        return None;
961    }
962
963    // Strike is typically 8 digits with possible decimal
964    let strike_value = if strike_str.contains('.') {
965        strike_str.parse().ok()?
966    } else {
967        // 8-digit integer strike, divide by 1000 for typical option strikes
968        let strike_int: i32 = strike_str.parse().ok()?;
969        strike_int as f64 / 1000.0
970    };
971
972    Some(OptionSymbol {
973        symbol: symbol_part.to_string(),
974        expiry: expiry.to_string(),
975        right: ib_option_right_to_option_right(right),
976        local_symbol: symbol.to_string(),
977        strike_value,
978    })
979}
980
981/// Named option symbol captures for formats like "C OESX 20260213 4775".
982struct NamedOptionSymbol {
983    trading_class: String,
984    expiry: String,
985    right: OptionRight,
986    strike_value: f64,
987}
988
989/// Named futures symbol captures for formats like "ESTX50 FESX 20240315".
990struct NamedFuturesSymbol {
991    underlying: String,
992    trading_class: String,
993    expiry: String,
994}
995
996fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
997    let parts: Vec<&str> = symbol.split_whitespace().collect();
998    if parts.len() != 3 {
999        return None;
1000    }
1001
1002    let expiry = parts[2];
1003    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1004        return None;
1005    }
1006
1007    Some(NamedFuturesSymbol {
1008        underlying: parts[0].to_string(),
1009        trading_class: parts[1].to_string(),
1010        expiry: expiry.to_string(),
1011    })
1012}
1013
1014fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1015    let parts: Vec<&str> = symbol.split_whitespace().collect();
1016    if !(parts.len() == 4 || parts.len() == 5) {
1017        return None;
1018    }
1019
1020    let right = IbOptionRight::from_str(parts[0]).ok()?;
1021
1022    let expiry = parts[2];
1023    if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1024        return None;
1025    }
1026
1027    Some(NamedOptionSymbol {
1028        trading_class: parts[1].to_string(),
1029        expiry: expiry.to_string(),
1030        right: ib_option_right_to_option_right(right),
1031        strike_value: parts[3].parse::<f64>().ok()?,
1032    })
1033}
1034
1035fn normalize_option_symbol(local_symbol: &str) -> String {
1036    if local_symbol.len() >= 15 {
1037        let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1038        let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1039            && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1040            && suffix[7..].chars().all(|c| c.is_ascii_digit());
1041
1042        if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1043            return format!("{:<6}{}", root.trim_end(), suffix);
1044        }
1045    }
1046
1047    local_symbol.to_string()
1048}
1049
1050fn format_option_strike(strike: f64) -> String {
1051    if strike.fract() == 0.0 {
1052        format!("{strike:.0}")
1053    } else {
1054        format!("{strike}")
1055    }
1056}
1057
1058/// Futures symbol captures
1059struct FuturesSymbol {
1060    local_symbol: String,
1061}
1062
1063/// Parse futures underlying (continuous) - just the symbol without expiry
1064fn parse_futures_underlying(symbol: &str) -> Option<String> {
1065    // If it's just 1-3 characters, it's likely an underlying
1066    if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
1067        Some(symbol.to_string())
1068    } else {
1069        None
1070    }
1071}
1072
1073fn is_futures_month_code(ch: char) -> bool {
1074    matches!(
1075        ch,
1076        'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
1077    )
1078}
1079
1080fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1081    for (month_pos, month_char) in symbol.char_indices().rev() {
1082        if !is_futures_month_code(month_char) {
1083            continue;
1084        }
1085
1086        let remaining = &symbol[month_pos + month_char.len_utf8()..];
1087        if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
1088            continue;
1089        }
1090
1091        let year = match remaining.len() {
1092            1 | 2 => remaining.to_string(),
1093            4 => remaining[remaining.len() - 2..].to_string(),
1094            _ => continue,
1095        };
1096
1097        if month_pos == 0 {
1098            continue;
1099        }
1100
1101        return Some((month_pos, month_char, year));
1102    }
1103
1104    None
1105}
1106
1107/// Parse futures symbol like "YMM6", "ESM23", or "ESM2023"
1108fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
1109    parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
1110        local_symbol: symbol.to_string(),
1111    })
1112}
1113
1114/// Parse futures option symbol like "YMM6 C4500", "ESM23 C4500", or "ESM2023 C4500"
1115fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
1116    let (futures_symbol, rest) = symbol.split_once(' ')?;
1117    let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
1118    let _fut_symbol = &futures_symbol[..month_pos];
1119
1120    // Parse right and strike
1121    let right_char = rest.chars().next()?;
1122    IbOptionRight::from_str(&right_char.to_string()).ok()?;
1123
1124    let strike_str = &rest[1..];
1125    strike_str.parse::<f64>().ok()?;
1126
1127    Some(symbol.to_string())
1128}
1129
1130/// Check if an instrument ID represents a spread.
1131///
1132/// This checks if the symbol contains the spread format pattern: `(ratio)symbol_` or `((ratio))symbol_`
1133#[must_use]
1134pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
1135    let symbol_str = instrument_id.symbol.as_str();
1136    // Check if symbol contains spread pattern: (ratio) or ((ratio))
1137    symbol_str.contains('(') && symbol_str.contains('_')
1138}
1139
1140/// Create a spread instrument ID from leg tuples.
1141///
1142/// This implements the same logic as Python's `InstrumentId.new_spread`:
1143/// - Creates a symbol string like `(1)SYMBOL1_((2))SYMBOL2`
1144/// - Positive ratios: `(ratio)SYMBOL`
1145/// - Negative ratios: `((abs(ratio)))SYMBOL`
1146/// - Sorts legs alphabetically by symbol
1147/// - All legs must have the same venue
1148///
1149/// # Errors
1150///
1151/// Returns an error if:
1152/// - Less than 2 legs provided
1153/// - Any ratio is zero
1154/// - Venues don't match across legs
1155pub fn create_spread_instrument_id(
1156    leg_tuples: &[(InstrumentId, i32)],
1157) -> anyhow::Result<InstrumentId> {
1158    if leg_tuples.len() < 2 {
1159        anyhow::bail!("instrument_ratios list needs to have at least 2 legs");
1160    }
1161
1162    let first_venue = leg_tuples[0].0.venue;
1163
1164    for (instrument_id, ratio) in leg_tuples {
1165        if *ratio == 0 {
1166            anyhow::bail!("ratio cannot be zero");
1167        }
1168
1169        if instrument_id.venue != first_venue {
1170            anyhow::bail!(
1171                "All venues must match. Expected {}, was {}",
1172                first_venue,
1173                instrument_id.venue
1174            );
1175        }
1176    }
1177
1178    let mut sorted_ratios = leg_tuples.to_vec();
1179    sorted_ratios.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1180
1181    let symbol_parts = sorted_ratios
1182        .iter()
1183        .map(|(instrument_id, ratio)| {
1184            if *ratio > 0 {
1185                format!("({}){}", ratio, instrument_id.symbol.as_str())
1186            } else {
1187                format!("(({})){}", ratio.abs(), instrument_id.symbol.as_str())
1188            }
1189        })
1190        .collect::<Vec<_>>();
1191
1192    let composite_symbol = symbol_parts.join("_");
1193    let symbol = NautilusSymbol::from(composite_symbol.as_str());
1194
1195    Ok(InstrumentId::new(symbol, first_venue))
1196}
1197
1198/// Parse a spread instrument ID back into leg tuples.
1199///
1200/// This implements the same logic as Python's `InstrumentId.to_list()`:
1201/// - Parses symbol string like `(1)SYMBOL1_((2))SYMBOL2`
1202/// - Positive ratios: `(ratio)SYMBOL`
1203/// - Negative ratios: `((abs(ratio)))SYMBOL`
1204/// - Returns sorted list of (instrument_id, ratio) tuples
1205///
1206/// # Errors
1207///
1208/// Returns an error if the symbol format is invalid.
1209pub fn parse_spread_instrument_id_to_legs(
1210    instrument_id: &InstrumentId,
1211) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
1212    let symbol_str = instrument_id.symbol.as_str();
1213    let venue = instrument_id.venue;
1214
1215    let components: Vec<&str> = symbol_str.split('_').collect();
1216    let mut result = Vec::new();
1217
1218    for component in components {
1219        if component.is_empty() {
1220            continue;
1221        }
1222
1223        // Check for negative ratio: ((ratio))symbol
1224        if let Some(rest) = component.strip_prefix("((")
1225            && let Some(pos) = rest.find("))")
1226        {
1227            let ratio_str = &rest[..pos];
1228            let symbol_value = &rest[pos + 2..];
1229
1230            if let Ok(ratio) = ratio_str.parse::<i32>() {
1231                let leg_instrument_id =
1232                    InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1233                result.push((leg_instrument_id, -ratio));
1234                continue;
1235            }
1236        }
1237
1238        // Check for positive ratio: (ratio)symbol
1239        if let Some(rest) = component.strip_prefix('(')
1240            && let Some(pos) = rest.find(')')
1241        {
1242            let ratio_str = &rest[..pos];
1243            let symbol_value = &rest[pos + 1..];
1244
1245            if let Ok(ratio) = ratio_str.parse::<i32>() {
1246                let leg_instrument_id =
1247                    InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1248                result.push((leg_instrument_id, ratio));
1249                continue;
1250            }
1251        }
1252
1253        anyhow::bail!("Invalid spread symbol format for component: {component}");
1254    }
1255
1256    // Sort result alphabetically by symbol
1257    result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1258
1259    Ok(result)
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264    use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1265    use nautilus_model::identifiers::InstrumentId;
1266    use rstest::rstest;
1267
1268    use super::{
1269        exchange_to_mic_venue, ib_contract_to_instrument_id_simplified,
1270        instrument_id_to_ib_contract, possible_exchanges_for_venue,
1271    };
1272
1273    #[rstest]
1274    fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1275        let contract = Contract {
1276            symbol: Symbol::from("SPXW"),
1277            security_type: SecurityType::Option,
1278            exchange: Exchange::from("SMART"),
1279            currency: Currency::from("USD"),
1280            local_symbol: "SPXW260313P06630000".to_string(),
1281            last_trade_date_or_contract_month: "260313".to_string(),
1282            right: Some(OptionRight::Put),
1283            strike: 6630.0,
1284            ..Default::default()
1285        };
1286
1287        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1288
1289        assert_eq!(
1290            instrument_id,
1291            InstrumentId::from("SPXW  260313P06630000.SMART")
1292        );
1293    }
1294
1295    #[rstest]
1296    fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1297        let contract = Contract {
1298            symbol: Symbol::from("OESX"),
1299            security_type: SecurityType::Option,
1300            exchange: Exchange::from("EUREX"),
1301            currency: Currency::from("EUR"),
1302            trading_class: "OESX".to_string(),
1303            local_symbol: String::new(),
1304            last_trade_date_or_contract_month: "20260213".to_string(),
1305            right: Some(OptionRight::Call),
1306            strike: 4775.0,
1307            ..Default::default()
1308        };
1309
1310        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1311
1312        assert_eq!(
1313            instrument_id,
1314            InstrumentId::from("C OESX 20260213 4775.EUREX")
1315        );
1316    }
1317
1318    #[rstest]
1319    fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1320        let contract = Contract {
1321            symbol: Symbol::from("EX2"),
1322            security_type: SecurityType::FuturesOption,
1323            exchange: Exchange::from("NYBOT"),
1324            currency: Currency::from("USD"),
1325            local_symbol: "EX2G3 P4080".to_string(),
1326            ..Default::default()
1327        };
1328
1329        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1330
1331        assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1332    }
1333
1334    #[rstest]
1335    fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1336        let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1337
1338        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1339
1340        assert_eq!(contract.security_type, SecurityType::Option);
1341        assert_eq!(contract.exchange.as_str(), "EUREX");
1342        assert_eq!(contract.symbol.as_str(), "OESX");
1343        assert_eq!(contract.trading_class.as_str(), "OESX");
1344        assert_eq!(
1345            contract.last_trade_date_or_contract_month.as_str(),
1346            "20260213"
1347        );
1348        assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1349        assert_eq!(contract.strike, 4775.0);
1350    }
1351
1352    #[rstest]
1353    fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1354        let instrument_id = InstrumentId::from("AAPL  230217P00155000.SMART");
1355
1356        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1357
1358        assert_eq!(contract.security_type, SecurityType::Option);
1359        assert_eq!(contract.exchange.as_str(), "SMART");
1360        assert_eq!(contract.symbol.as_str(), "AAPL");
1361        assert_eq!(contract.local_symbol.as_str(), "AAPL  230217P00155000");
1362        assert_eq!(
1363            contract.last_trade_date_or_contract_month.as_str(),
1364            "230217"
1365        );
1366        assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1367        assert_eq!(contract.strike, 155.0);
1368    }
1369
1370    #[rstest]
1371    fn test_possible_exchanges_for_opra_routes_to_smart() {
1372        assert_eq!(
1373            possible_exchanges_for_venue("OPRA"),
1374            vec!["SMART".to_string()]
1375        );
1376    }
1377
1378    #[rstest]
1379    fn test_opra_occ_option_default_contract_routes_to_smart() {
1380        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1381
1382        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1383
1384        assert_eq!(contract.security_type, SecurityType::Option);
1385        assert_eq!(contract.exchange.as_str(), "SMART");
1386        assert!(contract.symbol.as_str().is_empty());
1387        assert_eq!(contract.currency.as_str(), "USD");
1388        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1389        assert!(contract.last_trade_date_or_contract_month.is_empty());
1390        assert!(contract.right.is_none());
1391        assert_eq!(contract.strike, 0.0);
1392    }
1393
1394    #[rstest]
1395    fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1396        let instrument_id = InstrumentId::from("SPY   240319P00511000.OPRA");
1397        let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1398
1399        assert_eq!(exchanges, vec!["SMART".to_string()]);
1400
1401        let contract =
1402            instrument_id_to_ib_contract(instrument_id, exchanges.first().map(String::as_str))
1403                .unwrap();
1404
1405        assert_eq!(contract.security_type, SecurityType::Option);
1406        assert_eq!(contract.exchange.as_str(), "SMART");
1407        assert!(contract.symbol.as_str().is_empty());
1408        assert_eq!(contract.currency.as_str(), "USD");
1409        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1410        assert!(contract.last_trade_date_or_contract_month.is_empty());
1411        assert!(contract.right.is_none());
1412        assert_eq!(contract.strike, 0.0);
1413    }
1414
1415    #[rstest]
1416    fn test_opra_raw_option_default_contract_routes_to_smart() {
1417        let instrument_id = InstrumentId::from("SPY   240319P00511000=OPT.OPRA");
1418
1419        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1420
1421        assert_eq!(contract.security_type, SecurityType::Option);
1422        assert_eq!(contract.exchange.as_str(), "SMART");
1423        assert_eq!(contract.local_symbol.as_str(), "SPY   240319P00511000");
1424    }
1425
1426    #[rstest]
1427    #[case("SPY   240319P00511000.OPRA")]
1428    #[case("SPY   240319P00511000=OPT.OPRA")]
1429    fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1430        let instrument_id = InstrumentId::from(value);
1431
1432        let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOE")).unwrap();
1433
1434        assert_eq!(contract.security_type, SecurityType::Option);
1435        assert_eq!(contract.exchange.as_str(), "CBOE");
1436    }
1437
1438    #[rstest]
1439    #[case("AAPL.OPRA")]
1440    #[case("SPY   abcdefP00511000.OPRA")]
1441    #[case("P SPY 20240319 511.OPRA")]
1442    fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1443        let instrument_id = InstrumentId::from(value);
1444
1445        let result = instrument_id_to_ib_contract(instrument_id, None);
1446
1447        assert!(result.is_err());
1448    }
1449
1450    #[rstest]
1451    fn test_opra_route_does_not_create_reverse_smart_mapping() {
1452        assert_eq!(exchange_to_mic_venue("SMART"), None);
1453    }
1454
1455    #[rstest]
1456    fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1457        let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1458
1459        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1460
1461        assert_eq!(contract.security_type, SecurityType::Future);
1462        assert_eq!(contract.exchange.as_str(), "EUREX");
1463        assert_eq!(contract.symbol.as_str(), "ESTX50");
1464        assert_eq!(contract.trading_class.as_str(), "FESX");
1465        assert_eq!(
1466            contract.last_trade_date_or_contract_month.as_str(),
1467            "20240315"
1468        );
1469    }
1470
1471    #[rstest]
1472    fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1473        let instrument_id = InstrumentId::from("YMM6.XCBT");
1474
1475        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1476
1477        assert_eq!(contract.security_type, SecurityType::Future);
1478        assert_eq!(contract.exchange.as_str(), "CBOT");
1479        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1480        assert!(contract.symbol.as_str().is_empty());
1481        assert!(contract.last_trade_date_or_contract_month.is_empty());
1482    }
1483
1484    #[rstest]
1485    fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1486        let instrument_id = InstrumentId::from("OESXH6.XEUR");
1487
1488        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1489
1490        assert_eq!(contract.security_type, SecurityType::Future);
1491        assert_eq!(contract.exchange.as_str(), "DTB");
1492        assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1493    }
1494
1495    #[rstest]
1496    fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1497        let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1498
1499        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1500
1501        assert_eq!(contract.security_type, SecurityType::FuturesOption);
1502        assert_eq!(contract.exchange.as_str(), "CBOT");
1503        assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1504    }
1505
1506    #[rstest]
1507    fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1508        let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1509
1510        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1511
1512        assert_eq!(contract.security_type, SecurityType::CFD);
1513        assert_eq!(contract.exchange.as_str(), "SMART");
1514        assert_eq!(contract.symbol.as_str(), "EUR");
1515        assert_eq!(contract.currency.as_str(), "USD");
1516        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1517    }
1518
1519    #[rstest]
1520    fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1521        let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1522
1523        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1524
1525        assert_eq!(contract.security_type, SecurityType::Crypto);
1526        assert_eq!(contract.exchange.as_str(), "PAXOS");
1527        assert_eq!(contract.symbol.as_str(), "DOGE");
1528        assert_eq!(contract.currency.as_str(), "USD");
1529        assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1530    }
1531
1532    #[rstest]
1533    fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1534        // ZEROHASH is one of IB's crypto venues (alongside PAXOS).
1535        let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1536
1537        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1538
1539        assert_eq!(contract.security_type, SecurityType::Crypto);
1540        assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1541        assert_eq!(contract.symbol.as_str(), "BTC");
1542        assert_eq!(contract.currency.as_str(), "USD");
1543        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1544    }
1545
1546    #[rstest]
1547    fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1548        // Contract -> InstrumentId derives the venue from the ZEROHASH exchange.
1549        let contract = Contract {
1550            symbol: Symbol::from("BTC"),
1551            security_type: SecurityType::Crypto,
1552            exchange: Exchange::from("ZEROHASH"),
1553            currency: Currency::from("USD"),
1554            local_symbol: "BTC.USD".to_string(),
1555            ..Default::default()
1556        };
1557
1558        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1559
1560        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1561    }
1562
1563    #[rstest]
1564    fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1565        // Contract -> InstrumentId falls back to PAXOS when no exchange is set,
1566        // preserving backwards compatibility.
1567        let contract = Contract {
1568            symbol: Symbol::from("DOGE"),
1569            security_type: SecurityType::Crypto,
1570            currency: Currency::from("USD"),
1571            local_symbol: "DOGE.USD".to_string(),
1572            ..Default::default()
1573        };
1574
1575        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1576
1577        assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1578    }
1579
1580    #[rstest]
1581    fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1582        // PAXOS remains a live IB crypto venue alongside ZEROHASH.
1583        let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1584
1585        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1586
1587        assert_eq!(contract.security_type, SecurityType::Crypto);
1588        assert_eq!(contract.exchange.as_str(), "PAXOS");
1589        assert_eq!(contract.symbol.as_str(), "BTC");
1590        assert_eq!(contract.currency.as_str(), "USD");
1591        assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1592    }
1593
1594    #[rstest]
1595    fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1596        // A PAXOS contract derives the PAXOS venue from its exchange (not via the
1597        // fallback), proving both venues are honored when explicitly set.
1598        let contract = Contract {
1599            symbol: Symbol::from("BTC"),
1600            security_type: SecurityType::Crypto,
1601            exchange: Exchange::from("PAXOS"),
1602            currency: Currency::from("USD"),
1603            local_symbol: "BTC.USD".to_string(),
1604            ..Default::default()
1605        };
1606
1607        let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1608
1609        assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1610    }
1611
1612    #[rstest]
1613    fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1614        let instrument_id = InstrumentId::from("ES.XCME");
1615
1616        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1617
1618        assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1619        assert_eq!(contract.exchange.as_str(), "CME");
1620        assert_eq!(contract.symbol.as_str(), "ES");
1621    }
1622
1623    #[rstest]
1624    fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1625        let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1626
1627        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1628
1629        assert_eq!(contract.security_type, SecurityType::Stock);
1630        assert_eq!(contract.exchange.as_str(), "SMART");
1631        assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1632        assert_eq!(contract.local_symbol.as_str(), "AAPL");
1633        assert!(contract.symbol.as_str().is_empty());
1634    }
1635
1636    #[rstest]
1637    fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1638        let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1639
1640        let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1641
1642        assert_eq!(contract.security_type, SecurityType::ForexPair);
1643        assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1644        assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1645        assert!(contract.symbol.as_str().is_empty());
1646    }
1647
1648    #[rstest]
1649    fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1650        let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1651
1652        let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
1653
1654        assert_eq!(contract.security_type, SecurityType::Future);
1655        assert_eq!(contract.exchange.as_str(), "CBOT");
1656        assert_eq!(contract.local_symbol.as_str(), "YMM6");
1657    }
1658
1659    #[rstest]
1660    fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1661        let instrument_id = InstrumentId::from("IUSA.IBIS");
1662
1663        let contract = instrument_id_to_ib_contract(instrument_id, Some("IBIS")).unwrap();
1664
1665        assert_eq!(contract.security_type, SecurityType::Stock);
1666        assert_eq!(contract.symbol.as_str(), "IUSA");
1667        assert_eq!(contract.exchange.as_str(), "SMART");
1668        assert_eq!(contract.primary_exchange.as_str(), "IBIS");
1669        assert!(contract.currency.as_str().is_empty());
1670        assert!(contract.local_symbol.is_empty());
1671    }
1672}