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