Skip to main content

nautilus_interactive_brokers/providers/
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//! Instrument parsing utilities for converting IB ContractDetails to Nautilus instruments.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use ibapi::contracts::SecurityType;
22use nautilus_core::{UnixNanos, time::get_atomic_clock_realtime};
23use nautilus_model::{
24    enums::AssetClass,
25    identifiers::{InstrumentId, Symbol},
26    instruments::{
27        Cfd, Commodity, CryptoPerpetual, CurrencyPair, Equity, FuturesContract, FuturesSpread,
28        IndexInstrument, InstrumentAny, OptionContract, OptionSpread,
29    },
30    types::{Currency, Price, Quantity},
31};
32use rust_decimal::Decimal;
33use ustr::Ustr;
34
35use crate::common::{
36    contract_to_params,
37    enums::{IbOptionRight, IbSecurityType},
38};
39
40/// Convert tick size to precision value.
41#[must_use]
42pub fn tick_size_to_precision(tick_size: f64) -> u8 {
43    if tick_size <= 0.0 {
44        return 8; // Default precision for zero or negative tick sizes
45    }
46
47    // Count decimal places
48    let s = format!("{:.10}", tick_size);
49    let s = s.trim_end_matches('0');
50    let parts: Vec<&str> = s.split('.').collect();
51
52    if parts.len() == 2 {
53        parts[1].len().min(8) as u8
54    } else {
55        0
56    }
57}
58
59/// Convert timestamp string to UnixNanos.
60///
61/// Handles formats like "20230101" or "20230101 00:00:00 UTC".
62///
63/// # Errors
64///
65/// Returns an error if the timestamp cannot be parsed.
66pub fn expiry_timestring_to_unix_nanos(
67    expiry: &str,
68    details: Option<&ibapi::contracts::ContractDetails>,
69) -> anyhow::Result<UnixNanos> {
70    if expiry.is_empty() {
71        anyhow::bail!("Empty expiry string");
72    }
73
74    // Parse timestamp string - Most contract expirations are %Y%m%d format
75    // Some exchanges have expirations in %Y%m%d %H:%M:%S %Z
76    let dt = if expiry.len() == 8 {
77        // Format: YYYYMMDD
78        let year = &expiry[0..4];
79        let month = &expiry[4..6];
80        let day = &expiry[6..8];
81        let date = time::Date::from_calendar_date(
82            year.parse()?,
83            time::Month::try_from(month.parse::<u8>()?)?,
84            day.parse()?,
85        )?;
86
87        // If we have trading hours, try to extract the last trade time
88        // Trading hours format: "20240411:0000-20240411:1800;..."
89        let mut expiry_time = time::Time::MIDNIGHT;
90
91        if let Some(details) = details {
92            if !details.trading_hours.is_empty()
93                && !details.trading_hours.contains(&"CLOSED".to_string())
94            {
95                // Find the session for this date
96                let expiry_str: &str = expiry;
97                for session in &details.trading_hours {
98                    if session.as_str().starts_with(expiry_str) && session.as_str().contains('-') {
99                        let parts: Vec<&str> = session.as_str().split('-').collect();
100                        if let Some(end_part) = parts.get(1) {
101                            let inner_parts: Vec<&str> = end_part.split(':').collect();
102                            if let Some(time_part) = inner_parts.get(1) {
103                                if time_part.len() >= 4 {
104                                    let hour = time_part
105                                        .get(0..2)
106                                        .and_then(|s: &str| s.parse::<u8>().ok())
107                                        .unwrap_or(0);
108                                    let minute = time_part
109                                        .get(2..4)
110                                        .and_then(|s: &str| s.parse::<u8>().ok())
111                                        .unwrap_or(0);
112                                    expiry_time = time::Time::from_hms(hour, minute, 0)
113                                        .unwrap_or(time::Time::MIDNIGHT);
114                                }
115                            }
116                        }
117                        break;
118                    }
119                }
120            }
121        }
122        time::PrimitiveDateTime::new(date, expiry_time)
123    } else {
124        // Format: YYYYMMDD HH:MM:SS TZ
125        let parts: Vec<&str> = expiry.split(' ').collect();
126        if parts.len() >= 3 {
127            let date_part = parts[0];
128            let time_part = parts[1];
129            let year = &date_part[0..4];
130            let month = &date_part[4..6];
131            let day = &date_part[6..8];
132
133            let time_parts: Vec<&str> = time_part.split(':').collect();
134            let hour = time_parts.first().unwrap_or(&"0").parse::<u8>()?;
135            let minute = time_parts.get(1).unwrap_or(&"0").parse::<u8>()?;
136            let second = time_parts.get(2).unwrap_or(&"0").parse::<u8>()?;
137
138            let date = time::Date::from_calendar_date(
139                year.parse()?,
140                time::Month::try_from(month.parse::<u8>()?)?,
141                day.parse()?,
142            )?;
143            let time_obj = time::Time::from_hms(hour, minute, second)?;
144            time::PrimitiveDateTime::new(date, time_obj)
145        } else {
146            anyhow::bail!("Invalid expiry format: {}", expiry);
147        }
148    };
149
150    // Treat the parsed expiry timestamp as UTC. NautilusTrader expects IB timestamps
151    // to be configured and interpreted in UTC.
152    let offset_dt = dt.assume_utc();
153    let nanos = offset_dt.unix_timestamp_nanos();
154    Ok(UnixNanos::new(nanos as u64))
155}
156
157/// Parse an IB ContractDetails to a Nautilus instrument.
158///
159/// # Errors
160///
161/// Returns an error if parsing fails.
162pub fn parse_ib_contract_to_instrument(
163    details: &ibapi::contracts::ContractDetails,
164    instrument_id: InstrumentId,
165) -> anyhow::Result<InstrumentAny> {
166    let sec_type = &details.contract.security_type;
167
168    match sec_type {
169        SecurityType::Stock => Ok(parse_equity_contract(details, instrument_id)),
170        SecurityType::ForexPair => Ok(parse_forex_contract(details, instrument_id)),
171        SecurityType::Crypto => Ok(parse_crypto_contract(details, instrument_id)),
172        SecurityType::Future | SecurityType::ContinuousFuture => {
173            Ok(parse_futures_contract(details, instrument_id))
174        }
175        SecurityType::Option => parse_option_contract(details, instrument_id),
176        SecurityType::FuturesOption => parse_option_contract(details, instrument_id), // FOP uses same parsing as OPT
177        SecurityType::Index => Ok(parse_index_contract(details, instrument_id)),
178        SecurityType::CFD => Ok(parse_cfd_contract(details, instrument_id)),
179        SecurityType::Commodity => Ok(parse_commodity_contract(details, instrument_id)),
180        SecurityType::Bond => Ok(parse_bond_contract(details, instrument_id)),
181        _ => anyhow::bail!("Unsupported security type: {:?}", sec_type),
182    }
183}
184
185fn ib_contract_info(details: &ibapi::contracts::ContractDetails) -> nautilus_core::Params {
186    let mut info = nautilus_core::Params::new();
187    let mut contract = serde_json::Map::new();
188
189    let contract_params = contract_to_params(&details.contract);
190    for (key, value) in &contract_params {
191        contract.insert(key.clone(), value.clone());
192    }
193
194    info.insert("contract".to_string(), serde_json::Value::Object(contract));
195    info.insert(
196        "priceMagnifier".to_string(),
197        serde_json::Value::from(details.price_magnifier),
198    );
199    info
200}
201
202fn ib_contract_info_for_contract(contract: &ibapi::contracts::Contract) -> nautilus_core::Params {
203    let mut info = nautilus_core::Params::new();
204    let mut contract_map = serde_json::Map::new();
205    let contract_params = contract_to_params(contract);
206
207    for (key, value) in &contract_params {
208        contract_map.insert(key.clone(), value.clone());
209    }
210
211    info.insert(
212        "contract".to_string(),
213        serde_json::Value::Object(contract_map),
214    );
215    info
216}
217
218fn sec_type_to_asset_class(sec_type: &str) -> AssetClass {
219    match IbSecurityType::from_str(sec_type).ok() {
220        Some(IbSecurityType::Stock) => AssetClass::Equity,
221        Some(IbSecurityType::Index) => AssetClass::Index,
222        Some(IbSecurityType::ForexPair) => AssetClass::FX,
223        Some(IbSecurityType::Bond) => AssetClass::Debt,
224        Some(IbSecurityType::Commodity) => AssetClass::Commodity,
225        Some(IbSecurityType::Future) => AssetClass::Index,
226        _ => AssetClass::Equity,
227    }
228}
229
230/// Parse equity contract (STK).
231fn parse_equity_contract(
232    details: &ibapi::contracts::ContractDetails,
233    instrument_id: InstrumentId,
234) -> InstrumentAny {
235    let price_precision = tick_size_to_precision(details.min_tick);
236    let timestamp = get_atomic_clock_realtime().get_time_ns();
237
238    let instrument = Equity::builder()
239        .instrument_id(instrument_id)
240        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
241        .currency(Currency::from(details.contract.currency.to_string()))
242        .price_precision(price_precision)
243        .price_increment(Price::new(details.min_tick, price_precision))
244        // Standard lot size for stocks
245        .lot_size(Quantity::new(100.0, 0))
246        .info(ib_contract_info(details))
247        .ts_event(timestamp)
248        .ts_init(timestamp)
249        .build()
250        .unwrap();
251
252    InstrumentAny::from(instrument)
253}
254
255/// Parse forex contract (CASH).
256fn parse_forex_contract(
257    details: &ibapi::contracts::ContractDetails,
258    instrument_id: InstrumentId,
259) -> InstrumentAny {
260    let price_precision = tick_size_to_precision(details.min_tick);
261    let size_precision = tick_size_to_precision(details.min_size);
262    let timestamp = get_atomic_clock_realtime().get_time_ns();
263
264    let instrument = CurrencyPair::builder()
265        .instrument_id(instrument_id)
266        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
267        .base_currency(Currency::from(details.contract.symbol.to_string()))
268        .quote_currency(Currency::from(details.contract.currency.to_string()))
269        .price_precision(price_precision)
270        .size_precision(size_precision)
271        .price_increment(Price::new(details.min_tick, price_precision))
272        .size_increment(Quantity::new(details.size_increment, size_precision))
273        .info(ib_contract_info(details))
274        .ts_event(timestamp)
275        .ts_init(timestamp)
276        .build()
277        .unwrap();
278
279    InstrumentAny::from(instrument)
280}
281
282/// Parse crypto contract (CRYPTO).
283fn parse_crypto_contract(
284    details: &ibapi::contracts::ContractDetails,
285    instrument_id: InstrumentId,
286) -> InstrumentAny {
287    let price_precision = tick_size_to_precision(details.min_tick);
288    let size_precision = tick_size_to_precision(details.min_size);
289    let timestamp = get_atomic_clock_realtime().get_time_ns();
290
291    let instrument = CryptoPerpetual::builder()
292        .instrument_id(instrument_id)
293        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
294        .base_currency(Currency::from(details.contract.symbol.to_string()))
295        .quote_currency(Currency::from(details.contract.currency.to_string()))
296        .settlement_currency(Currency::from(details.contract.currency.to_string()))
297        .is_inverse(true)
298        .price_precision(price_precision)
299        .size_precision(size_precision)
300        .price_increment(Price::new(details.min_tick, price_precision))
301        .size_increment(Quantity::new(details.size_increment, size_precision))
302        .min_quantity(Quantity::new(details.min_size, size_precision))
303        .info(ib_contract_info(details))
304        .ts_event(timestamp)
305        .ts_init(timestamp)
306        .build()
307        .unwrap();
308
309    InstrumentAny::from(instrument)
310}
311
312fn parse_contract_multiplier(multiplier: &str, default: f64) -> Quantity {
313    if multiplier.is_empty() {
314        return Quantity::new(default, 0);
315    }
316
317    Quantity::from_str(multiplier).unwrap_or_else(|e| {
318        tracing::warn!(
319            "Failed to parse IB contract multiplier '{multiplier}', using default {default}: {e}"
320        );
321        Quantity::new(default, 0)
322    })
323}
324
325/// Parse futures contract (FUT).
326fn parse_futures_contract(
327    details: &ibapi::contracts::ContractDetails,
328    instrument_id: InstrumentId,
329) -> InstrumentAny {
330    let price_precision = tick_size_to_precision(details.min_tick);
331    let timestamp = get_atomic_clock_realtime().get_time_ns();
332
333    // Parse expiration
334    let expiration_ns = if !details
335        .contract
336        .last_trade_date_or_contract_month
337        .is_empty()
338    {
339        expiry_timestring_to_unix_nanos(
340            &details.contract.last_trade_date_or_contract_month,
341            Some(details),
342        )
343        .unwrap_or_else(|_| UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000))
344    // Default to +90 days on error
345    } else {
346        UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000) // Default to +90 days if empty
347    };
348
349    let ninety_days_ns: u64 = 90 * 24 * 60 * 60 * 1_000_000_000;
350    let activation_ns = expiration_ns
351        .checked_sub(ninety_days_ns)
352        .unwrap_or(UnixNanos::from(0)); // -90 days or 0 if underflow
353
354    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 1.0);
355
356    let raw_symbol = if matches!(
357        details.contract.security_type,
358        SecurityType::ContinuousFuture
359    ) && !details.contract.symbol.as_str().is_empty()
360    {
361        details.contract.symbol.as_str()
362    } else {
363        details.contract.local_symbol.as_str()
364    };
365
366    let instrument = FuturesContract::builder()
367        .instrument_id(instrument_id)
368        .raw_symbol(Symbol::from(raw_symbol))
369        .asset_class(sec_type_to_asset_class(
370            details.under_security_type.as_str(),
371        ))
372        .underlying(Ustr::from(details.under_symbol.as_str()))
373        .activation_ns(activation_ns)
374        .expiration_ns(expiration_ns)
375        .currency(Currency::from(details.contract.currency.to_string()))
376        .price_precision(price_precision)
377        .price_increment(Price::new(details.min_tick, price_precision))
378        .multiplier(multiplier)
379        .lot_size(Quantity::new(1.0, 0))
380        .info(ib_contract_info(details))
381        .ts_event(timestamp)
382        .ts_init(timestamp)
383        .build()
384        .unwrap();
385
386    InstrumentAny::from(instrument)
387}
388
389/// Parse option contract (OPT).
390fn parse_option_contract(
391    details: &ibapi::contracts::ContractDetails,
392    instrument_id: InstrumentId,
393) -> anyhow::Result<InstrumentAny> {
394    let price_precision = tick_size_to_precision(details.min_tick);
395    let timestamp = get_atomic_clock_realtime().get_time_ns();
396
397    // Parse expiration
398    let expiration_ns = if !details
399        .contract
400        .last_trade_date_or_contract_month
401        .is_empty()
402    {
403        expiry_timestring_to_unix_nanos(
404            &details.contract.last_trade_date_or_contract_month,
405            Some(details),
406        )
407        .unwrap_or_else(|_| UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000))
408    // Default to +90 days on error
409    } else {
410        UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000) // Default to +90 days if empty
411    };
412
413    let ninety_days_ns: u64 = 90 * 24 * 60 * 60 * 1_000_000_000;
414    let activation_ns = expiration_ns
415        .checked_sub(ninety_days_ns)
416        .unwrap_or(UnixNanos::from(0)); // -90 days or 0 if underflow
417
418    // Parse option kind (CALL or PUT)
419    let option_kind = details
420        .contract
421        .right
422        .map(|right| IbOptionRight::from_str(right.as_str()))
423        .transpose()?
424        .context("Option contract missing right")?
425        .option_kind();
426
427    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 100.0);
428    let asset_class = sec_type_to_asset_class(details.under_security_type.as_str());
429    let underlying =
430        if details.under_security_type == "IND" && !details.under_symbol.starts_with('^') {
431            format!("^{}", details.under_symbol)
432        } else {
433            details.under_symbol.clone()
434        };
435
436    let instrument = OptionContract::builder()
437        .instrument_id(instrument_id)
438        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
439        .asset_class(asset_class)
440        .underlying(Ustr::from(underlying.as_str()))
441        .option_kind(option_kind)
442        .strike_price(Price::new(details.contract.strike, price_precision))
443        .currency(Currency::from(details.contract.currency.to_string()))
444        .activation_ns(activation_ns)
445        .expiration_ns(expiration_ns)
446        .price_precision(price_precision)
447        .price_increment(Price::new(details.min_tick, price_precision))
448        .multiplier(multiplier)
449        .lot_size(multiplier)
450        .info(ib_contract_info(details))
451        .ts_event(timestamp)
452        .ts_init(timestamp)
453        .build()
454        .unwrap();
455
456    Ok(InstrumentAny::from(instrument))
457}
458
459#[allow(clippy::items_after_test_module)]
460#[cfg(test)]
461mod tests {
462    use ibapi::contracts::{
463        Contract, ContractDetails, Currency, Exchange, OptionRight, SecurityType, Symbol,
464    };
465    use nautilus_model::{
466        enums::AssetClass,
467        identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue},
468        instruments::{Instrument, InstrumentAny},
469        types::{Price, Quantity},
470    };
471    use rstest::rstest;
472    use ustr::Ustr;
473
474    use super::{
475        parse_contract_multiplier, parse_ib_contract_to_instrument,
476        parse_option_spread_instrument_id,
477    };
478
479    #[rstest]
480    fn test_parse_option_contract_prefixes_index_underlying() {
481        let details = ContractDetails {
482            contract: Contract {
483                symbol: Symbol::from("SPXW"),
484                security_type: SecurityType::Option,
485                exchange: Exchange::from("SMART"),
486                currency: Currency::from("USD"),
487                local_symbol: "SPXW  260313P06630000".to_string(),
488                last_trade_date_or_contract_month: "20260313".to_string(),
489                right: Some(OptionRight::Put),
490                strike: 6630.0,
491                multiplier: "100".to_string(),
492                ..Default::default()
493            },
494            min_tick: 0.05,
495            under_symbol: "SPX".to_string(),
496            under_security_type: "IND".to_string(),
497            ..Default::default()
498        };
499        let instrument_id = InstrumentId::new(
500            NautilusSymbol::from("SPXW  260313P06630000"),
501            Venue::from("SMART"),
502        );
503
504        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
505
506        let InstrumentAny::OptionContract(option) = instrument else {
507            panic!("expected option contract");
508        };
509
510        assert_eq!(option.asset_class(), AssetClass::Index);
511        assert_eq!(option.underlying(), Some(Ustr::from("^SPX")));
512    }
513
514    #[rstest]
515    fn test_parse_contract_preserves_price_magnifier_in_info() {
516        let details = ContractDetails {
517            contract: Contract {
518                symbol: Symbol::from("AAPL"),
519                security_type: SecurityType::Stock,
520                exchange: Exchange::from("SMART"),
521                primary_exchange: Exchange::from("NASDAQ"),
522                currency: Currency::from("USD"),
523                local_symbol: String::from("AAPL"),
524                ..Default::default()
525            },
526            min_tick: 0.01,
527            price_magnifier: 100,
528            ..Default::default()
529        };
530        let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("XNAS"));
531
532        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
533        let InstrumentAny::Equity(equity) = instrument else {
534            panic!("expected equity");
535        };
536
537        assert_eq!(
538            equity.info.unwrap().get("priceMagnifier"),
539            Some(&serde_json::Value::from(100))
540        );
541    }
542
543    #[rstest]
544    #[case("100", 100.0)]
545    #[case("", 1.0)]
546    #[case("not-a-number", 1.0)]
547    fn test_parse_contract_multiplier_uses_quantity_parser(
548        #[case] multiplier: &str,
549        #[case] expected: f64,
550    ) {
551        assert_eq!(
552            parse_contract_multiplier(multiplier, 1.0),
553            Quantity::new(expected, 0)
554        );
555    }
556
557    #[rstest]
558    fn test_parse_continuous_future_contract_uses_symbol_as_raw_symbol() {
559        let details = ContractDetails {
560            contract: Contract {
561                symbol: Symbol::from("ES"),
562                security_type: SecurityType::ContinuousFuture,
563                exchange: Exchange::from("CME"),
564                currency: Currency::from("USD"),
565                local_symbol: String::new(),
566                multiplier: "50".to_string(),
567                ..Default::default()
568            },
569            min_tick: 0.25,
570            under_symbol: "ES".to_string(),
571            under_security_type: "IND".to_string(),
572            ..Default::default()
573        };
574        let instrument_id = InstrumentId::new(NautilusSymbol::from("ES"), Venue::from("CME"));
575
576        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
577
578        let InstrumentAny::FuturesContract(future) = instrument else {
579            panic!("expected futures contract");
580        };
581
582        assert_eq!(future.raw_symbol().as_str(), "ES");
583    }
584
585    #[rstest]
586    fn test_parse_option_spread_uses_minimum_leg_tick() {
587        let leg1 = ContractDetails {
588            contract: Contract {
589                symbol: Symbol::from("SPY"),
590                security_type: SecurityType::Option,
591                exchange: Exchange::from("SMART"),
592                currency: Currency::from("USD"),
593                local_symbol: "SPY   260120C00400000".to_string(),
594                multiplier: "100".to_string(),
595                ..Default::default()
596            },
597            min_tick: 0.05,
598            under_symbol: "SPY".to_string(),
599            ..Default::default()
600        };
601        let leg2 = ContractDetails {
602            contract: Contract {
603                symbol: Symbol::from("SPY"),
604                security_type: SecurityType::Option,
605                exchange: Exchange::from("SMART"),
606                currency: Currency::from("USD"),
607                local_symbol: "SPY   260120C00410000".to_string(),
608                multiplier: "100".to_string(),
609                ..Default::default()
610            },
611            min_tick: 0.01,
612            under_symbol: "SPY".to_string(),
613            ..Default::default()
614        };
615        let instrument_id =
616            InstrumentId::from("(1)SPY   260120C00400000_((-1))SPY   260120C00410000.SMART");
617
618        let spread = parse_option_spread_instrument_id(
619            instrument_id,
620            &[(&leg1, 1), (&leg2, -1)],
621            None,
622            None,
623        )
624        .unwrap();
625
626        assert_eq!(spread.price_precision(), 2);
627        assert_eq!(spread.price_increment(), Price::from("0.01"));
628    }
629}
630
631/// Parse index contract (IND).
632///
633/// Note: Indices are typically not directly tradable. This creates a CurrencyPair
634/// representation as a placeholder until IndexInstrument type is available.
635fn parse_index_contract(
636    details: &ibapi::contracts::ContractDetails,
637    instrument_id: InstrumentId,
638) -> InstrumentAny {
639    let price_precision = tick_size_to_precision(details.min_tick);
640    let size_precision = tick_size_to_precision(details.min_size);
641    let timestamp = get_atomic_clock_realtime().get_time_ns();
642
643    let instrument = IndexInstrument::builder()
644        .instrument_id(instrument_id)
645        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
646        .currency(Currency::from(details.contract.currency.to_string()))
647        .price_precision(price_precision)
648        .size_precision(size_precision)
649        .price_increment(Price::new(details.min_tick, price_precision))
650        .size_increment(Quantity::new(details.size_increment, size_precision))
651        .info(ib_contract_info(details))
652        .ts_event(timestamp)
653        .ts_init(timestamp)
654        .build()
655        .unwrap();
656
657    InstrumentAny::from(instrument)
658}
659
660/// Parse a spread instrument ID into an OptionSpread instrument.
661///
662/// This implements the same logic as Python's `parse_spread_instrument_id`.
663/// Uses contract details from the first leg to determine spread properties.
664///
665/// # Errors
666///
667/// Returns an error if parsing fails.
668pub fn parse_spread_instrument_id(
669    instrument_id: InstrumentId,
670    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
671    timestamp_ns: Option<UnixNanos>,
672) -> anyhow::Result<OptionSpread> {
673    if leg_contract_details.is_empty() {
674        anyhow::bail!("leg_contract_details must be provided");
675    }
676
677    // Use contract details from first leg
678    let (first_details, _) = leg_contract_details[0];
679    let first_contract = &first_details.contract;
680
681    // Extract properties from the first leg contract details
682    let currency = Currency::from(first_contract.currency.to_string());
683    let underlying = if !first_details.under_symbol.is_empty() {
684        Ustr::from(first_details.under_symbol.as_str())
685    } else {
686        Ustr::from(first_contract.symbol.as_str())
687    };
688
689    // Parse multiplier
690    let multiplier_str = first_contract.multiplier.to_string();
691    let multiplier =
692        Quantity::from_str(&multiplier_str).unwrap_or_else(|_| Quantity::new(100.0, 0)); // Default to 100 for options
693
694    // Determine asset class based on security type
695    let asset_class = match first_contract.security_type {
696        ibapi::contracts::SecurityType::FuturesOption => AssetClass::Index, // Futures options
697        _ => AssetClass::Equity,                                            // Equity options
698    };
699
700    // Calculate price precision and increment from the finest leg tick.
701    let min_tick = leg_contract_details
702        .iter()
703        .map(|(details, _)| details.min_tick)
704        .fold(first_details.min_tick, f64::min);
705    let price_precision = tick_size_to_precision(min_tick);
706    let price_increment = Price::new(min_tick, price_precision);
707
708    // Use provided timestamp or current time
709    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
710
711    // For options spreads, lot size equals multiplier (same as individual option contracts)
712    let lot_size = multiplier;
713
714    // Create the spread instrument
715    let spread = OptionSpread::builder()
716        .instrument_id(instrument_id)
717        .raw_symbol(Symbol::from(instrument_id.symbol.as_str()))
718        .asset_class(asset_class)
719        .underlying(underlying)
720        .strategy_type(Ustr::from("SPREAD"))
721        // activation_ns (spreads don't have single activation dates)
722        .activation_ns(UnixNanos::new(0))
723        // expiration_ns (spreads don't have single expiration dates)
724        .expiration_ns(UnixNanos::new(0))
725        .currency(currency)
726        .price_precision(price_precision)
727        .price_increment(price_increment)
728        .multiplier(multiplier)
729        .lot_size(lot_size)
730        .margin_init(Decimal::ZERO)
731        .margin_maint(Decimal::ZERO)
732        .maker_fee(Decimal::ZERO)
733        .taker_fee(Decimal::ZERO)
734        .ts_event(timestamp)
735        .ts_init(timestamp)
736        .build()?;
737
738    Ok(spread)
739}
740
741pub fn parse_option_spread_instrument_id(
742    instrument_id: InstrumentId,
743    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
744    bag_contract: Option<&ibapi::contracts::Contract>,
745    timestamp_ns: Option<UnixNanos>,
746) -> anyhow::Result<OptionSpread> {
747    let mut spread = parse_spread_instrument_id(instrument_id, leg_contract_details, timestamp_ns)?;
748    spread.info = bag_contract.map(ib_contract_info_for_contract);
749    Ok(spread)
750}
751
752pub fn parse_futures_spread_instrument_id(
753    instrument_id: InstrumentId,
754    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
755    bag_contract: Option<&ibapi::contracts::Contract>,
756    timestamp_ns: Option<UnixNanos>,
757) -> anyhow::Result<FuturesSpread> {
758    if leg_contract_details.is_empty() {
759        anyhow::bail!("leg_contract_details must be provided");
760    }
761
762    let (first_details, _) = leg_contract_details[0];
763    let first_contract = &first_details.contract;
764    let currency = Currency::from(first_contract.currency.to_string());
765    let underlying = if !first_details.under_symbol.is_empty() {
766        Ustr::from(first_details.under_symbol.as_str())
767    } else {
768        Ustr::from(first_contract.symbol.as_str())
769    };
770    let multiplier = Quantity::from_str(&first_contract.multiplier.to_string())
771        .unwrap_or_else(|_| Quantity::new(1.0, 0));
772    let min_tick = leg_contract_details
773        .iter()
774        .map(|(details, _)| details.min_tick)
775        .fold(first_details.min_tick, f64::min);
776    let price_precision = tick_size_to_precision(min_tick);
777    let price_increment = Price::new(min_tick, price_precision);
778    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
779
780    Ok(FuturesSpread::builder()
781        .instrument_id(instrument_id)
782        .raw_symbol(Symbol::from(instrument_id.symbol.as_str()))
783        .asset_class(AssetClass::Index)
784        .underlying(underlying)
785        .strategy_type(Ustr::from("SPREAD"))
786        .activation_ns(UnixNanos::new(0))
787        .expiration_ns(UnixNanos::new(0))
788        .currency(currency)
789        .price_precision(price_precision)
790        .price_increment(price_increment)
791        .multiplier(multiplier)
792        .lot_size(Quantity::new(1.0, 0))
793        .margin_init(Decimal::ZERO)
794        .margin_maint(Decimal::ZERO)
795        .maker_fee(Decimal::ZERO)
796        .taker_fee(Decimal::ZERO)
797        .maybe_info(bag_contract.map(ib_contract_info_for_contract))
798        .ts_event(timestamp)
799        .ts_init(timestamp)
800        .build()?)
801}
802
803pub fn parse_spread_instrument_any(
804    instrument_id: InstrumentId,
805    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
806    bag_contract: Option<&ibapi::contracts::Contract>,
807    timestamp_ns: Option<UnixNanos>,
808) -> anyhow::Result<InstrumentAny> {
809    let has_future = leg_contract_details.iter().any(|(details, _)| {
810        matches!(
811            details.contract.security_type,
812            SecurityType::Future | SecurityType::ContinuousFuture
813        )
814    });
815
816    if has_future {
817        Ok(InstrumentAny::from(parse_futures_spread_instrument_id(
818            instrument_id,
819            leg_contract_details,
820            bag_contract,
821            timestamp_ns,
822        )?))
823    } else {
824        Ok(InstrumentAny::from(parse_option_spread_instrument_id(
825            instrument_id,
826            leg_contract_details,
827            bag_contract,
828            timestamp_ns,
829        )?))
830    }
831}
832
833/// Parse CFD contract (CFD).
834fn parse_cfd_contract(
835    details: &ibapi::contracts::ContractDetails,
836    instrument_id: InstrumentId,
837) -> InstrumentAny {
838    let price_precision = tick_size_to_precision(details.min_tick);
839    let size_precision = tick_size_to_precision(details.min_size);
840    let timestamp = get_atomic_clock_realtime().get_time_ns();
841
842    let base_currency = details
843        .contract
844        .local_symbol
845        .contains('.')
846        .then(|| Currency::from(details.contract.symbol.to_string()));
847
848    let instrument = Cfd::builder()
849        .instrument_id(instrument_id)
850        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
851        .asset_class(sec_type_to_asset_class(
852            details.under_security_type.as_str(),
853        ))
854        .maybe_base_currency(base_currency)
855        .quote_currency(Currency::from(details.contract.currency.to_string()))
856        .price_precision(price_precision)
857        .size_precision(size_precision)
858        .price_increment(Price::new(details.min_tick, price_precision))
859        .size_increment(Quantity::new(details.size_increment, size_precision))
860        .info(ib_contract_info(details))
861        .ts_event(timestamp)
862        .ts_init(timestamp)
863        .build()
864        .unwrap();
865
866    InstrumentAny::from(instrument)
867}
868
869/// Parse commodity contract (CMDTY).
870fn parse_commodity_contract(
871    details: &ibapi::contracts::ContractDetails,
872    instrument_id: InstrumentId,
873) -> InstrumentAny {
874    let price_precision = tick_size_to_precision(details.min_tick);
875    let size_precision = tick_size_to_precision(details.min_size);
876    let timestamp = get_atomic_clock_realtime().get_time_ns();
877
878    let instrument = Commodity::builder()
879        .instrument_id(instrument_id)
880        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
881        .asset_class(AssetClass::Commodity)
882        .quote_currency(Currency::from(details.contract.currency.to_string()))
883        .price_precision(price_precision)
884        .size_precision(size_precision)
885        .price_increment(Price::new(details.min_tick, price_precision))
886        .size_increment(Quantity::new(details.size_increment, size_precision))
887        .info(ib_contract_info(details))
888        .ts_event(timestamp)
889        .ts_init(timestamp)
890        .build()
891        .unwrap();
892
893    InstrumentAny::from(instrument)
894}
895
896/// Parse bond contract (BOND).
897fn parse_bond_contract(
898    details: &ibapi::contracts::ContractDetails,
899    instrument_id: InstrumentId,
900) -> InstrumentAny {
901    // Use Equity as a placeholder until Bond type is available in Rust model
902    // Note: This is a limitation of the current Nautilus Rust model, not the IB adapter
903    let price_precision = tick_size_to_precision(details.min_tick);
904    let timestamp = get_atomic_clock_realtime().get_time_ns();
905
906    // ISIN could be extracted from `security_id` if needed
907    let instrument = Equity::builder()
908        .instrument_id(instrument_id)
909        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
910        .currency(Currency::from(details.contract.currency.to_string()))
911        .price_precision(price_precision)
912        .price_increment(Price::new(details.min_tick, price_precision))
913        // Standard lot size for bonds
914        .lot_size(Quantity::new(1.0, 0))
915        .info(ib_contract_info(details))
916        .ts_event(timestamp)
917        .ts_init(timestamp)
918        .build()
919        .unwrap();
920
921    InstrumentAny::from(instrument)
922}