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::new(
239        instrument_id,
240        Symbol::from(details.contract.local_symbol.as_str()),
241        None, // isin
242        Currency::from(details.contract.currency.to_string()),
243        price_precision,
244        Price::new(details.min_tick, price_precision),
245        Some(Quantity::new(100.0, 0)),   // Standard lot size for stocks
246        None,                            // max_quantity
247        None,                            // min_quantity
248        None,                            // max_price
249        None,                            // min_price
250        None,                            // margin_init
251        None,                            // margin_maint
252        None,                            // maker_fee
253        None,                            // taker_fee
254        None,                            // tick_scheme
255        Some(ib_contract_info(details)), // info
256        timestamp,
257        timestamp,
258    );
259
260    InstrumentAny::from(instrument)
261}
262
263/// Parse forex contract (CASH).
264fn parse_forex_contract(
265    details: &ibapi::contracts::ContractDetails,
266    instrument_id: InstrumentId,
267) -> InstrumentAny {
268    let price_precision = tick_size_to_precision(details.min_tick);
269    let size_precision = tick_size_to_precision(details.min_size);
270    let timestamp = get_atomic_clock_realtime().get_time_ns();
271
272    let instrument = CurrencyPair::new(
273        instrument_id,
274        Symbol::from(details.contract.local_symbol.as_str()),
275        Currency::from(details.contract.symbol.to_string()),
276        Currency::from(details.contract.currency.to_string()),
277        price_precision,
278        size_precision,
279        Price::new(details.min_tick, price_precision),
280        Quantity::new(details.size_increment, size_precision),
281        None,                            // multiplier
282        None,                            // lot_size
283        None,                            // max_quantity
284        None,                            // min_quantity
285        None,                            // max_notional
286        None,                            // min_notional
287        None,                            // max_price
288        None,                            // min_price
289        None,                            // margin_init
290        None,                            // margin_maint
291        None,                            // maker_fee
292        None,                            // taker_fee
293        None,                            // tick_scheme
294        Some(ib_contract_info(details)), // info
295        timestamp,
296        timestamp,
297    );
298
299    InstrumentAny::from(instrument)
300}
301
302/// Parse crypto contract (CRYPTO).
303fn parse_crypto_contract(
304    details: &ibapi::contracts::ContractDetails,
305    instrument_id: InstrumentId,
306) -> InstrumentAny {
307    let price_precision = tick_size_to_precision(details.min_tick);
308    let size_precision = tick_size_to_precision(details.min_size);
309    let timestamp = get_atomic_clock_realtime().get_time_ns();
310
311    let instrument = CryptoPerpetual::new(
312        instrument_id,
313        Symbol::from(details.contract.local_symbol.as_str()),
314        Currency::from(details.contract.symbol.to_string()),
315        Currency::from(details.contract.currency.to_string()),
316        Currency::from(details.contract.currency.to_string()),
317        true, // is_inverse
318        price_precision,
319        size_precision,
320        Price::new(details.min_tick, price_precision),
321        Quantity::new(details.size_increment, size_precision),
322        None, // multiplier
323        None, // lot_size
324        None, // max_quantity
325        Some(Quantity::new(details.min_size, size_precision)),
326        None,                            // max_notional
327        None,                            // min_notional
328        None,                            // max_price
329        None,                            // min_price
330        None,                            // margin_init
331        None,                            // margin_maint
332        None,                            // maker_fee
333        None,                            // taker_fee
334        None,                            // tick_scheme
335        Some(ib_contract_info(details)), // info
336        timestamp,
337        timestamp,
338    );
339
340    InstrumentAny::from(instrument)
341}
342
343fn parse_contract_multiplier(multiplier: &str, default: f64) -> Quantity {
344    if multiplier.is_empty() {
345        return Quantity::new(default, 0);
346    }
347
348    Quantity::from_str(multiplier).unwrap_or_else(|e| {
349        tracing::warn!(
350            "Failed to parse IB contract multiplier '{multiplier}', using default {default}: {e}"
351        );
352        Quantity::new(default, 0)
353    })
354}
355
356/// Parse futures contract (FUT).
357fn parse_futures_contract(
358    details: &ibapi::contracts::ContractDetails,
359    instrument_id: InstrumentId,
360) -> InstrumentAny {
361    let price_precision = tick_size_to_precision(details.min_tick);
362    let timestamp = get_atomic_clock_realtime().get_time_ns();
363
364    // Parse expiration
365    let expiration_ns = if !details
366        .contract
367        .last_trade_date_or_contract_month
368        .is_empty()
369    {
370        expiry_timestring_to_unix_nanos(
371            &details.contract.last_trade_date_or_contract_month,
372            Some(details),
373        )
374        .unwrap_or_else(|_| UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000))
375    // Default to +90 days on error
376    } else {
377        UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000) // Default to +90 days if empty
378    };
379
380    let ninety_days_ns: u64 = 90 * 24 * 60 * 60 * 1_000_000_000;
381    let activation_ns = expiration_ns
382        .checked_sub(ninety_days_ns)
383        .unwrap_or(UnixNanos::from(0)); // -90 days or 0 if underflow
384
385    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 1.0);
386
387    let raw_symbol = if matches!(
388        details.contract.security_type,
389        SecurityType::ContinuousFuture
390    ) && !details.contract.symbol.as_str().is_empty()
391    {
392        details.contract.symbol.as_str()
393    } else {
394        details.contract.local_symbol.as_str()
395    };
396
397    let instrument = FuturesContract::new(
398        instrument_id,
399        Symbol::from(raw_symbol),
400        sec_type_to_asset_class(details.under_security_type.as_str()),
401        None, // exchange
402        Ustr::from(details.under_symbol.as_str()),
403        activation_ns,
404        expiration_ns,
405        Currency::from(details.contract.currency.to_string()),
406        price_precision,
407        Price::new(details.min_tick, price_precision),
408        multiplier,
409        Quantity::new(1.0, 0),
410        None,                            // max_quantity
411        None,                            // min_quantity
412        None,                            // max_price
413        None,                            // min_price
414        None,                            // margin_init
415        None,                            // margin_maint
416        None,                            // maker_fee
417        None,                            // taker_fee
418        None,                            // tick_scheme
419        Some(ib_contract_info(details)), // info
420        timestamp,
421        timestamp,
422    );
423
424    InstrumentAny::from(instrument)
425}
426
427/// Parse option contract (OPT).
428fn parse_option_contract(
429    details: &ibapi::contracts::ContractDetails,
430    instrument_id: InstrumentId,
431) -> anyhow::Result<InstrumentAny> {
432    let price_precision = tick_size_to_precision(details.min_tick);
433    let timestamp = get_atomic_clock_realtime().get_time_ns();
434
435    // Parse expiration
436    let expiration_ns = if !details
437        .contract
438        .last_trade_date_or_contract_month
439        .is_empty()
440    {
441        expiry_timestring_to_unix_nanos(
442            &details.contract.last_trade_date_or_contract_month,
443            Some(details),
444        )
445        .unwrap_or_else(|_| UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000))
446    // Default to +90 days on error
447    } else {
448        UnixNanos::from(timestamp.as_u64() + 90 * 24 * 60 * 60 * 1_000_000_000) // Default to +90 days if empty
449    };
450
451    let ninety_days_ns: u64 = 90 * 24 * 60 * 60 * 1_000_000_000;
452    let activation_ns = expiration_ns
453        .checked_sub(ninety_days_ns)
454        .unwrap_or(UnixNanos::from(0)); // -90 days or 0 if underflow
455
456    // Parse option kind (CALL or PUT)
457    let option_kind = details
458        .contract
459        .right
460        .map(|right| IbOptionRight::from_str(right.as_str()))
461        .transpose()?
462        .context("Option contract missing right")?
463        .option_kind();
464
465    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 100.0);
466    let asset_class = sec_type_to_asset_class(details.under_security_type.as_str());
467    let underlying =
468        if details.under_security_type == "IND" && !details.under_symbol.starts_with('^') {
469            format!("^{}", details.under_symbol)
470        } else {
471            details.under_symbol.clone()
472        };
473
474    let instrument = OptionContract::new(
475        instrument_id,
476        Symbol::from(details.contract.local_symbol.as_str()),
477        asset_class,
478        None, // exchange
479        Ustr::from(underlying.as_str()),
480        option_kind,
481        Price::new(details.contract.strike, price_precision),
482        Currency::from(details.contract.currency.to_string()),
483        activation_ns,
484        expiration_ns,
485        price_precision,
486        Price::new(details.min_tick, price_precision),
487        multiplier,
488        multiplier,
489        None,                            // max_quantity
490        None,                            // min_quantity
491        None,                            // max_price
492        None,                            // min_price
493        None,                            // margin_init
494        None,                            // margin_maint
495        None,                            // maker_fee
496        None,                            // taker_fee
497        None,                            // tick_scheme
498        Some(ib_contract_info(details)), // info
499        timestamp,
500        timestamp,
501    );
502
503    Ok(InstrumentAny::from(instrument))
504}
505
506#[allow(clippy::items_after_test_module)]
507#[cfg(test)]
508mod tests {
509    use ibapi::contracts::{
510        Contract, ContractDetails, Currency, Exchange, OptionRight, SecurityType, Symbol,
511    };
512    use nautilus_model::{
513        enums::AssetClass,
514        identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue},
515        instruments::{Instrument, InstrumentAny},
516        types::{Price, Quantity},
517    };
518    use rstest::rstest;
519    use ustr::Ustr;
520
521    use super::{
522        parse_contract_multiplier, parse_ib_contract_to_instrument,
523        parse_option_spread_instrument_id,
524    };
525
526    #[rstest]
527    fn test_parse_option_contract_prefixes_index_underlying() {
528        let details = ContractDetails {
529            contract: Contract {
530                symbol: Symbol::from("SPXW"),
531                security_type: SecurityType::Option,
532                exchange: Exchange::from("SMART"),
533                currency: Currency::from("USD"),
534                local_symbol: "SPXW  260313P06630000".to_string(),
535                last_trade_date_or_contract_month: "20260313".to_string(),
536                right: Some(OptionRight::Put),
537                strike: 6630.0,
538                multiplier: "100".to_string(),
539                ..Default::default()
540            },
541            min_tick: 0.05,
542            under_symbol: "SPX".to_string(),
543            under_security_type: "IND".to_string(),
544            ..Default::default()
545        };
546        let instrument_id = InstrumentId::new(
547            NautilusSymbol::from("SPXW  260313P06630000"),
548            Venue::from("SMART"),
549        );
550
551        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
552
553        let InstrumentAny::OptionContract(option) = instrument else {
554            panic!("expected option contract");
555        };
556
557        assert_eq!(option.asset_class(), AssetClass::Index);
558        assert_eq!(option.underlying(), Some(Ustr::from("^SPX")));
559    }
560
561    #[rstest]
562    fn test_parse_contract_preserves_price_magnifier_in_info() {
563        let details = ContractDetails {
564            contract: Contract {
565                symbol: Symbol::from("AAPL"),
566                security_type: SecurityType::Stock,
567                exchange: Exchange::from("SMART"),
568                primary_exchange: Exchange::from("NASDAQ"),
569                currency: Currency::from("USD"),
570                local_symbol: String::from("AAPL"),
571                ..Default::default()
572            },
573            min_tick: 0.01,
574            price_magnifier: 100,
575            ..Default::default()
576        };
577        let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("XNAS"));
578
579        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
580        let InstrumentAny::Equity(equity) = instrument else {
581            panic!("expected equity");
582        };
583
584        assert_eq!(
585            equity.info.unwrap().get("priceMagnifier"),
586            Some(&serde_json::Value::from(100))
587        );
588    }
589
590    #[rstest]
591    #[case("100", 100.0)]
592    #[case("", 1.0)]
593    #[case("not-a-number", 1.0)]
594    fn test_parse_contract_multiplier_uses_quantity_parser(
595        #[case] multiplier: &str,
596        #[case] expected: f64,
597    ) {
598        assert_eq!(
599            parse_contract_multiplier(multiplier, 1.0),
600            Quantity::new(expected, 0)
601        );
602    }
603
604    #[rstest]
605    fn test_parse_continuous_future_contract_uses_symbol_as_raw_symbol() {
606        let details = ContractDetails {
607            contract: Contract {
608                symbol: Symbol::from("ES"),
609                security_type: SecurityType::ContinuousFuture,
610                exchange: Exchange::from("CME"),
611                currency: Currency::from("USD"),
612                local_symbol: String::new(),
613                multiplier: "50".to_string(),
614                ..Default::default()
615            },
616            min_tick: 0.25,
617            under_symbol: "ES".to_string(),
618            under_security_type: "IND".to_string(),
619            ..Default::default()
620        };
621        let instrument_id = InstrumentId::new(NautilusSymbol::from("ES"), Venue::from("CME"));
622
623        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
624
625        let InstrumentAny::FuturesContract(future) = instrument else {
626            panic!("expected futures contract");
627        };
628
629        assert_eq!(future.raw_symbol().as_str(), "ES");
630    }
631
632    #[rstest]
633    fn test_parse_option_spread_uses_minimum_leg_tick() {
634        let leg1 = ContractDetails {
635            contract: Contract {
636                symbol: Symbol::from("SPY"),
637                security_type: SecurityType::Option,
638                exchange: Exchange::from("SMART"),
639                currency: Currency::from("USD"),
640                local_symbol: "SPY   260120C00400000".to_string(),
641                multiplier: "100".to_string(),
642                ..Default::default()
643            },
644            min_tick: 0.05,
645            under_symbol: "SPY".to_string(),
646            ..Default::default()
647        };
648        let leg2 = ContractDetails {
649            contract: Contract {
650                symbol: Symbol::from("SPY"),
651                security_type: SecurityType::Option,
652                exchange: Exchange::from("SMART"),
653                currency: Currency::from("USD"),
654                local_symbol: "SPY   260120C00410000".to_string(),
655                multiplier: "100".to_string(),
656                ..Default::default()
657            },
658            min_tick: 0.01,
659            under_symbol: "SPY".to_string(),
660            ..Default::default()
661        };
662        let instrument_id =
663            InstrumentId::from("(1)SPY   260120C00400000_((-1))SPY   260120C00410000.SMART");
664
665        let spread = parse_option_spread_instrument_id(
666            instrument_id,
667            &[(&leg1, 1), (&leg2, -1)],
668            None,
669            None,
670        )
671        .unwrap();
672
673        assert_eq!(spread.price_precision(), 2);
674        assert_eq!(spread.price_increment(), Price::from("0.01"));
675    }
676}
677
678/// Parse index contract (IND).
679///
680/// Note: Indices are typically not directly tradable. This creates a CurrencyPair
681/// representation as a placeholder until IndexInstrument type is available.
682fn parse_index_contract(
683    details: &ibapi::contracts::ContractDetails,
684    instrument_id: InstrumentId,
685) -> InstrumentAny {
686    let price_precision = tick_size_to_precision(details.min_tick);
687    let size_precision = tick_size_to_precision(details.min_size);
688    let timestamp = get_atomic_clock_realtime().get_time_ns();
689
690    let instrument = IndexInstrument::new(
691        instrument_id,
692        Symbol::from(details.contract.local_symbol.as_str()),
693        Currency::from(details.contract.currency.to_string()),
694        price_precision,
695        size_precision,
696        Price::new(details.min_tick, price_precision),
697        Quantity::new(details.size_increment, size_precision),
698        None,
699        Some(ib_contract_info(details)), // info
700        timestamp,
701        timestamp,
702    );
703
704    InstrumentAny::from(instrument)
705}
706
707/// Parse a spread instrument ID into an OptionSpread instrument.
708///
709/// This implements the same logic as Python's `parse_spread_instrument_id`.
710/// Uses contract details from the first leg to determine spread properties.
711///
712/// # Errors
713///
714/// Returns an error if parsing fails.
715pub fn parse_spread_instrument_id(
716    instrument_id: InstrumentId,
717    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
718    timestamp_ns: Option<UnixNanos>,
719) -> anyhow::Result<OptionSpread> {
720    if leg_contract_details.is_empty() {
721        anyhow::bail!("leg_contract_details must be provided");
722    }
723
724    // Use contract details from first leg
725    let (first_details, _) = leg_contract_details[0];
726    let first_contract = &first_details.contract;
727
728    // Extract properties from the first leg contract details
729    let currency = Currency::from(first_contract.currency.to_string());
730    let underlying = if !first_details.under_symbol.is_empty() {
731        Ustr::from(first_details.under_symbol.as_str())
732    } else {
733        Ustr::from(first_contract.symbol.as_str())
734    };
735
736    // Parse multiplier
737    let multiplier_str = first_contract.multiplier.to_string();
738    let multiplier =
739        Quantity::from_str(&multiplier_str).unwrap_or_else(|_| Quantity::new(100.0, 0)); // Default to 100 for options
740
741    // Determine asset class based on security type
742    let asset_class = match first_contract.security_type {
743        ibapi::contracts::SecurityType::FuturesOption => AssetClass::Index, // Futures options
744        _ => AssetClass::Equity,                                            // Equity options
745    };
746
747    // Calculate price precision and increment from the finest leg tick.
748    let min_tick = leg_contract_details
749        .iter()
750        .map(|(details, _)| details.min_tick)
751        .fold(first_details.min_tick, f64::min);
752    let price_precision = tick_size_to_precision(min_tick);
753    let price_increment = Price::new(min_tick, price_precision);
754
755    // Use provided timestamp or current time
756    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
757
758    // For options spreads, lot size equals multiplier (same as individual option contracts)
759    let lot_size = multiplier;
760
761    // Create the spread instrument
762    let spread = OptionSpread::new_checked(
763        instrument_id,
764        Symbol::from(instrument_id.symbol.as_str()), // raw_symbol
765        asset_class,
766        None, // exchange (optional)
767        underlying,
768        Ustr::from("SPREAD"), // strategy_type
769        UnixNanos::new(0),    // activation_ns (spreads don't have single activation dates)
770        UnixNanos::new(0),    // expiration_ns (spreads don't have single expiration dates)
771        currency,
772        price_precision,
773        price_increment,
774        multiplier,
775        lot_size,
776        None,                // max_quantity
777        None,                // min_quantity
778        None,                // max_price
779        None,                // min_price
780        Some(Decimal::ZERO), // margin_init
781        Some(Decimal::ZERO), // margin_maint
782        Some(Decimal::ZERO), // maker_fee
783        Some(Decimal::ZERO), // taker_fee
784        None,                // tick_scheme
785        None,                // info
786        timestamp,
787        timestamp,
788    )?;
789
790    Ok(spread)
791}
792
793pub fn parse_option_spread_instrument_id(
794    instrument_id: InstrumentId,
795    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
796    bag_contract: Option<&ibapi::contracts::Contract>,
797    timestamp_ns: Option<UnixNanos>,
798) -> anyhow::Result<OptionSpread> {
799    let mut spread = parse_spread_instrument_id(instrument_id, leg_contract_details, timestamp_ns)?;
800    spread.info = bag_contract.map(ib_contract_info_for_contract);
801    Ok(spread)
802}
803
804pub fn parse_futures_spread_instrument_id(
805    instrument_id: InstrumentId,
806    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
807    bag_contract: Option<&ibapi::contracts::Contract>,
808    timestamp_ns: Option<UnixNanos>,
809) -> anyhow::Result<FuturesSpread> {
810    if leg_contract_details.is_empty() {
811        anyhow::bail!("leg_contract_details must be provided");
812    }
813
814    let (first_details, _) = leg_contract_details[0];
815    let first_contract = &first_details.contract;
816    let currency = Currency::from(first_contract.currency.to_string());
817    let underlying = if !first_details.under_symbol.is_empty() {
818        Ustr::from(first_details.under_symbol.as_str())
819    } else {
820        Ustr::from(first_contract.symbol.as_str())
821    };
822    let multiplier = Quantity::from_str(&first_contract.multiplier.to_string())
823        .unwrap_or_else(|_| Quantity::new(1.0, 0));
824    let min_tick = leg_contract_details
825        .iter()
826        .map(|(details, _)| details.min_tick)
827        .fold(first_details.min_tick, f64::min);
828    let price_precision = tick_size_to_precision(min_tick);
829    let price_increment = Price::new(min_tick, price_precision);
830    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
831
832    Ok(FuturesSpread::new_checked(
833        instrument_id,
834        Symbol::from(instrument_id.symbol.as_str()),
835        AssetClass::Index,
836        None,
837        underlying,
838        Ustr::from("SPREAD"),
839        UnixNanos::new(0),
840        UnixNanos::new(0),
841        currency,
842        price_precision,
843        price_increment,
844        multiplier,
845        Quantity::new(1.0, 0),
846        None,
847        None,
848        None,
849        None,
850        Some(Decimal::ZERO),
851        Some(Decimal::ZERO),
852        Some(Decimal::ZERO),
853        Some(Decimal::ZERO),
854        None,
855        bag_contract.map(ib_contract_info_for_contract),
856        timestamp,
857        timestamp,
858    )?)
859}
860
861pub fn parse_spread_instrument_any(
862    instrument_id: InstrumentId,
863    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
864    bag_contract: Option<&ibapi::contracts::Contract>,
865    timestamp_ns: Option<UnixNanos>,
866) -> anyhow::Result<InstrumentAny> {
867    let has_future = leg_contract_details.iter().any(|(details, _)| {
868        matches!(
869            details.contract.security_type,
870            SecurityType::Future | SecurityType::ContinuousFuture
871        )
872    });
873
874    if has_future {
875        Ok(InstrumentAny::from(parse_futures_spread_instrument_id(
876            instrument_id,
877            leg_contract_details,
878            bag_contract,
879            timestamp_ns,
880        )?))
881    } else {
882        Ok(InstrumentAny::from(parse_option_spread_instrument_id(
883            instrument_id,
884            leg_contract_details,
885            bag_contract,
886            timestamp_ns,
887        )?))
888    }
889}
890
891/// Parse CFD contract (CFD).
892fn parse_cfd_contract(
893    details: &ibapi::contracts::ContractDetails,
894    instrument_id: InstrumentId,
895) -> InstrumentAny {
896    let price_precision = tick_size_to_precision(details.min_tick);
897    let size_precision = tick_size_to_precision(details.min_size);
898    let timestamp = get_atomic_clock_realtime().get_time_ns();
899
900    let base_currency = details
901        .contract
902        .local_symbol
903        .contains('.')
904        .then(|| Currency::from(details.contract.symbol.to_string()));
905
906    let instrument = Cfd::new(
907        instrument_id,
908        Symbol::from(details.contract.local_symbol.as_str()),
909        sec_type_to_asset_class(details.under_security_type.as_str()),
910        base_currency,
911        Currency::from(details.contract.currency.to_string()),
912        price_precision,
913        size_precision,
914        Price::new(details.min_tick, price_precision),
915        Quantity::new(details.size_increment, size_precision),
916        None,
917        None,
918        None,
919        None,
920        None,
921        None,
922        None,
923        None,
924        None,
925        None,
926        None,
927        None,
928        Some(ib_contract_info(details)),
929        timestamp,
930        timestamp,
931    );
932
933    InstrumentAny::from(instrument)
934}
935
936/// Parse commodity contract (CMDTY).
937fn parse_commodity_contract(
938    details: &ibapi::contracts::ContractDetails,
939    instrument_id: InstrumentId,
940) -> InstrumentAny {
941    let price_precision = tick_size_to_precision(details.min_tick);
942    let size_precision = tick_size_to_precision(details.min_size);
943    let timestamp = get_atomic_clock_realtime().get_time_ns();
944
945    let instrument = Commodity::new(
946        instrument_id,
947        Symbol::from(details.contract.local_symbol.as_str()),
948        AssetClass::Commodity,
949        Currency::from(details.contract.currency.to_string()),
950        price_precision,
951        size_precision,
952        Price::new(details.min_tick, price_precision),
953        Quantity::new(details.size_increment, size_precision),
954        None,
955        None,
956        None,
957        None,
958        None,
959        None,
960        None,
961        None,
962        None,
963        None,
964        None,
965        None,
966        Some(ib_contract_info(details)),
967        timestamp,
968        timestamp,
969    );
970
971    InstrumentAny::from(instrument)
972}
973
974/// Parse bond contract (BOND).
975fn parse_bond_contract(
976    details: &ibapi::contracts::ContractDetails,
977    instrument_id: InstrumentId,
978) -> InstrumentAny {
979    // Use Equity as a placeholder until Bond type is available in Rust model
980    // Note: This is a limitation of the current Nautilus Rust model, not the IB adapter
981    let price_precision = tick_size_to_precision(details.min_tick);
982    let timestamp = get_atomic_clock_realtime().get_time_ns();
983
984    let instrument = Equity::new(
985        instrument_id,
986        Symbol::from(details.contract.local_symbol.as_str()),
987        None, // isin - could extract from security_id if available
988        Currency::from(details.contract.currency.to_string()),
989        price_precision,
990        Price::new(details.min_tick, price_precision),
991        Some(Quantity::new(1.0, 0)),     // Standard lot size for bonds
992        None,                            // max_quantity
993        None,                            // min_quantity
994        None,                            // max_price
995        None,                            // min_price
996        None,                            // margin_init
997        None,                            // margin_maint
998        None,                            // maker_fee
999        None,                            // taker_fee
1000        None,                            // tick_scheme
1001        Some(ib_contract_info(details)), // info
1002        timestamp,
1003        timestamp,
1004    );
1005
1006    InstrumentAny::from(instrument)
1007}