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 indexmap::IndexMap;
23use jiff::{
24    Timestamp,
25    civil::DateTime,
26    tz::{AmbiguousOffset, Offset},
27};
28use nautilus_core::{
29    DurationNanos, UnixNanos, datetime::get_timezone, time::get_atomic_clock_realtime,
30};
31use nautilus_model::{
32    enums::AssetClass,
33    identifiers::{InstrumentId, Symbol},
34    instruments::{
35        Cfd, Commodity, CurrencyPair, Equity, FuturesContract, FuturesSpread, IndexInstrument,
36        InstrumentAny, OptionContract, OptionSpread,
37    },
38    types::{Currency, Price, Quantity},
39};
40use rust_decimal::Decimal;
41use ustr::Ustr;
42
43use crate::common::{
44    contract_to_json_value,
45    enums::{IbOptionRight, IbSecurityType},
46};
47
48const NINETY_DAYS: DurationNanos = DurationNanos::from_days(90);
49
50/// Convert tick size to precision value.
51#[must_use]
52pub fn tick_size_to_precision(tick_size: f64) -> u8 {
53    if tick_size <= 0.0 {
54        return 8; // Default precision for zero or negative tick sizes
55    }
56
57    // Count decimal places
58    let s = format!("{tick_size:.10}");
59    let s = s.trim_end_matches('0');
60    let parts: Vec<&str> = s.split('.').collect();
61    if parts.len() == 2 {
62        parts[1].len().min(8) as u8
63    } else {
64        0
65    }
66}
67
68/// Moves the last trade time and zone that follow the date in `lastTradeDateOrContractMonth`
69/// (`20261218 08:30:00 US/Central`) to `last_trade_time` (`08:30:00 US/Central`), leaving the
70/// date alone in the contract.
71///
72/// IB Gateway 10.51 sends that form over protobuf, ibapi keeps it whole for every contract but a
73/// bond, and IB refuses it when the contract is sent back in a request (error 10372). IB's own
74/// API client moves only the time; the zone moves with it because the contract's `time_zone_id`
75/// may be empty or differ from it, and the expiration must stay the instant IB stated.
76pub(crate) fn split_last_trade_date(details: &mut ibapi::contracts::ContractDetails) {
77    if let Some(time_and_zone) = trim_last_trade_date(&mut details.contract) {
78        details.last_trade_time = time_and_zone;
79    }
80}
81
82/// Cuts `lastTradeDateOrContractMonth` to its date, returning the time and zone that followed
83/// it (see [`split_last_trade_date`]).
84pub(crate) fn trim_last_trade_date(contract: &mut ibapi::contracts::Contract) -> Option<String> {
85    // ibapi reads a bond's maturity, time and zone from the value and keeps it whole, as IB's
86    // own client does
87    if contract.security_type == SecurityType::Bond {
88        return None;
89    }
90
91    let mut parts = contract
92        .last_trade_date_or_contract_month
93        .split_whitespace();
94    let date = parts.next()?.to_string();
95    let time_and_zone = parts.collect::<Vec<_>>().join(" ");
96    if time_and_zone.is_empty() {
97        return None;
98    }
99    contract.last_trade_date_or_contract_month = date;
100
101    Some(time_and_zone)
102}
103
104/// Convert timestamp string to UnixNanos.
105///
106/// Handles formats like "20230101" or "20230101 00:00:00 UTC". A date alone takes its time
107/// from the `last_trade_time` of `details` if it holds one, otherwise from its trading session.
108/// A time without a zone is in the contract's `time_zone_id`, or in UTC if that is empty.
109///
110/// # Errors
111///
112/// Returns an error if the timestamp cannot be parsed.
113pub fn expiry_timestring_to_unix_nanos(
114    expiry: &str,
115    details: Option<&ibapi::contracts::ContractDetails>,
116) -> anyhow::Result<UnixNanos> {
117    if expiry.is_empty() {
118        anyhow::bail!("Empty expiry string");
119    }
120
121    let contract_timezone = details
122        .map(|details| details.time_zone_id.as_str())
123        .filter(|timezone| !timezone.is_empty())
124        .unwrap_or("UTC");
125    let last_trade_time = details
126        .map(|details| details.last_trade_time.as_str())
127        .filter(|time| !time.is_empty());
128
129    let (date, time, timezone) = if expiry.len() == 8
130        && let Some(last_trade_time) = last_trade_time
131    {
132        let (time, timezone) = read_time_and_zone(
133            last_trade_time.split_whitespace(),
134            contract_timezone,
135            last_trade_time,
136        )?;
137        (expiry, time.to_string(), timezone)
138    } else if expiry.len() == 8 {
139        let session_end = details.and_then(|details| {
140            details
141                .trading_hours
142                .iter()
143                .find(|session| session.starts_with(expiry))
144                .and_then(|session| session.split_once('-'))
145                .map(|(_, end)| end)
146        });
147
148        let (date, time) = match session_end {
149            Some(end) => end.split_once(':').unwrap_or((expiry, end)),
150            // IB lists only the coming week of sessions, so a later expiry takes the
151            // closing time of the last listed session
152            None => (
153                expiry,
154                details.and_then(last_session_close).unwrap_or("0000"),
155            ),
156        };
157
158        let time = match time.len() {
159            4 => format!("{}:{}:00", &time[0..2], &time[2..4]),
160            _ => anyhow::bail!("Invalid expiry session end '{time}' for {expiry}"),
161        };
162
163        (date, time, contract_timezone)
164    } else {
165        let mut parts = expiry.split_whitespace();
166        let date = parts.next().context("Expiry timestamp is missing a date")?;
167        let (time, timezone) = read_time_and_zone(parts, contract_timezone, expiry)?;
168
169        (date, time.to_string(), timezone)
170    };
171
172    let datetime = DateTime::strptime("%Y%m%d %H:%M:%S", format!("{date} {time}"))
173        .with_context(|| format!("Invalid expiry timestamp: {expiry}"))?;
174    let timestamp = localize_expiry(datetime, timezone, expiry)?;
175    let nanos = u64::try_from(timestamp.as_nanosecond())
176        .with_context(|| format!("Expiry timestamp precedes the Unix epoch: {expiry}"))?;
177    Ok(UnixNanos::new(nanos))
178}
179
180// Reads `HH:MM:SS [zone]` from the `parts` of `value`, the zone defaulting to
181// `default_timezone`
182fn read_time_and_zone<'a>(
183    mut parts: std::str::SplitWhitespace<'a>,
184    default_timezone: &'a str,
185    value: &str,
186) -> anyhow::Result<(&'a str, &'a str)> {
187    let time = parts.next().context("Expiry timestamp is missing a time")?;
188    let timezone = parts.next().unwrap_or(default_timezone);
189    if parts.next().is_some() {
190        anyhow::bail!("Invalid expiry format: {value}");
191    }
192
193    Ok((time, timezone))
194}
195
196// Returns the `HHMM` closing time of the last listed trading session that is not closed
197fn last_session_close(details: &ibapi::contracts::ContractDetails) -> Option<&str> {
198    details.trading_hours.iter().rev().find_map(|session| {
199        let (_, end) = session.rsplit_once('-')?;
200        let close = end.rsplit_once(':').map_or(end, |(_, time)| time);
201        (close.len() == 4 && close.bytes().all(|byte| byte.is_ascii_digit())).then_some(close)
202    })
203}
204
205fn localize_expiry(datetime: DateTime, timezone: &str, expiry: &str) -> anyhow::Result<Timestamp> {
206    if timezone.eq_ignore_ascii_case("UTC") || timezone.eq_ignore_ascii_case("Z") {
207        return Ok(Offset::UTC.to_timestamp(datetime)?);
208    }
209
210    let zone = get_timezone(timezone).with_context(|| {
211        format!("Unknown IB contract timezone '{timezone}' for expiry {expiry}")
212    })?;
213
214    let ambiguous = zone.to_ambiguous_timestamp(datetime);
215    match ambiguous.offset() {
216        AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
217        AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
218        AmbiguousOffset::Gap { .. } => {
219            anyhow::bail!("Expiry {expiry} does not exist in timezone '{timezone}'")
220        }
221    }
222}
223
224/// Parse an IB ContractDetails to a Nautilus instrument.
225///
226/// # Errors
227///
228/// Returns an error if parsing fails.
229pub fn parse_ib_contract_to_instrument(
230    details: &ibapi::contracts::ContractDetails,
231    instrument_id: InstrumentId,
232) -> anyhow::Result<InstrumentAny> {
233    let sec_type = &details.contract.security_type;
234    match sec_type {
235        SecurityType::Stock => Ok(parse_equity_contract(details, instrument_id)),
236        SecurityType::ForexPair => Ok(parse_forex_contract(details, instrument_id)),
237        SecurityType::Crypto => Ok(parse_crypto_contract(details, instrument_id)),
238        SecurityType::Future | SecurityType::ContinuousFuture => {
239            parse_futures_contract(details, instrument_id)
240        }
241        SecurityType::Option => parse_option_contract(details, instrument_id),
242        SecurityType::FuturesOption => parse_option_contract(details, instrument_id), // FOP uses same parsing as OPT
243        SecurityType::Index => Ok(parse_index_contract(details, instrument_id)),
244        SecurityType::CFD => Ok(parse_cfd_contract(details, instrument_id)),
245        SecurityType::Commodity => Ok(parse_commodity_contract(details, instrument_id)),
246        SecurityType::Bond => Ok(parse_bond_contract(details, instrument_id)),
247        _ => anyhow::bail!("Unsupported security type: {sec_type:?}"),
248    }
249}
250
251fn ib_contract_info(details: &ibapi::contracts::ContractDetails) -> nautilus_core::Params {
252    let mut info = ib_contract_info_for_contract(&details.contract);
253    info.insert(
254        "priceMagnifier".to_string(),
255        serde_json::Value::from(details.price_magnifier),
256    );
257    nautilus_core::Params::new(info)
258}
259
260fn ib_contract_info_for_contract(
261    contract: &ibapi::contracts::Contract,
262) -> IndexMap<String, serde_json::Value> {
263    IndexMap::from([("contract".to_string(), contract_to_json_value(contract))])
264}
265
266fn sec_type_to_asset_class(sec_type: &str) -> AssetClass {
267    match IbSecurityType::from_str(sec_type).ok() {
268        Some(IbSecurityType::Stock) => AssetClass::Equity,
269        Some(IbSecurityType::Index) => AssetClass::Index,
270        Some(IbSecurityType::ForexPair) => AssetClass::FX,
271        Some(IbSecurityType::Bond) => AssetClass::Debt,
272        Some(IbSecurityType::Commodity) => AssetClass::Commodity,
273        Some(IbSecurityType::Future) => AssetClass::Index,
274        _ => AssetClass::Equity,
275    }
276}
277
278/// Parse equity contract (STK).
279fn parse_equity_contract(
280    details: &ibapi::contracts::ContractDetails,
281    instrument_id: InstrumentId,
282) -> InstrumentAny {
283    let price_precision = tick_size_to_precision(details.min_tick);
284    let timestamp = get_atomic_clock_realtime().get_time_ns();
285
286    let instrument = Equity::builder()
287        .instrument_id(instrument_id)
288        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
289        .currency(Currency::from(details.contract.currency.to_string()))
290        .price_precision(price_precision)
291        .price_increment(Price::new(details.min_tick, price_precision))
292        // Standard lot size for stocks
293        .lot_size(Quantity::new(100.0, 0))
294        .info(ib_contract_info(details))
295        .ts_event(timestamp)
296        .ts_init(timestamp)
297        .build()
298        .unwrap();
299
300    InstrumentAny::from(instrument)
301}
302
303/// Parse forex contract (CASH).
304fn parse_forex_contract(
305    details: &ibapi::contracts::ContractDetails,
306    instrument_id: InstrumentId,
307) -> InstrumentAny {
308    let price_precision = tick_size_to_precision(details.min_tick);
309    let (size_precision, size_increment, min_quantity) = parse_contract_size_rules(details, 1.0);
310    let timestamp = get_atomic_clock_realtime().get_time_ns();
311
312    let instrument = CurrencyPair::builder()
313        .instrument_id(instrument_id)
314        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
315        .base_currency(Currency::from(details.contract.symbol.to_string()))
316        .quote_currency(Currency::from(details.contract.currency.to_string()))
317        .price_precision(price_precision)
318        .size_precision(size_precision)
319        .price_increment(Price::new(details.min_tick, price_precision))
320        .size_increment(size_increment)
321        .maybe_min_quantity(min_quantity)
322        .info(ib_contract_info(details))
323        .ts_event(timestamp)
324        .ts_init(timestamp)
325        .build()
326        .unwrap();
327
328    InstrumentAny::from(instrument)
329}
330
331/// Parse crypto contract (CRYPTO).
332fn parse_crypto_contract(
333    details: &ibapi::contracts::ContractDetails,
334    instrument_id: InstrumentId,
335) -> InstrumentAny {
336    let price_precision = tick_size_to_precision(details.min_tick);
337    let (size_precision, size_increment, min_quantity) =
338        parse_contract_size_rules(details, 0.00000001);
339    let timestamp = get_atomic_clock_realtime().get_time_ns();
340
341    let instrument = CurrencyPair::builder()
342        .instrument_id(instrument_id)
343        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
344        .base_currency(Currency::from(details.contract.symbol.to_string()))
345        .quote_currency(Currency::from(details.contract.currency.to_string()))
346        .price_precision(price_precision)
347        .size_precision(size_precision)
348        .price_increment(Price::new(details.min_tick, price_precision))
349        .size_increment(size_increment)
350        .maybe_min_quantity(min_quantity)
351        .info(ib_contract_info(details))
352        .ts_event(timestamp)
353        .ts_init(timestamp)
354        .build()
355        .unwrap();
356
357    InstrumentAny::from(instrument)
358}
359
360fn parse_contract_multiplier(multiplier: &str, default: f64) -> Quantity {
361    if multiplier.is_empty() {
362        return Quantity::new(default, 0);
363    }
364
365    Quantity::from_str(multiplier).unwrap_or_else(|e| {
366        tracing::warn!(
367            "Failed to parse IB contract multiplier '{multiplier}', using default {default}: {e}"
368        );
369        Quantity::new(default, 0)
370    })
371}
372
373/// Parse futures contract (FUT).
374fn parse_futures_contract(
375    details: &ibapi::contracts::ContractDetails,
376    instrument_id: InstrumentId,
377) -> anyhow::Result<InstrumentAny> {
378    let price_precision = tick_size_to_precision(details.min_tick);
379    let timestamp = get_atomic_clock_realtime().get_time_ns();
380
381    let expiry = &details.contract.last_trade_date_or_contract_month;
382
383    let expiration_ns = match expiry_timestring_to_unix_nanos(expiry, Some(details)) {
384        Ok(expiration_ns) => expiration_ns,
385        // Continuous futures can report contract details without a last-trade date.
386        Err(e)
387            if matches!(
388                details.contract.security_type,
389                SecurityType::ContinuousFuture
390            ) =>
391        {
392            tracing::warn!(
393                "Continuous future {} reported expiry '{expiry}', defaulting to 90 days out: {e}",
394                details.contract.symbol.as_str(),
395            );
396            timestamp + NINETY_DAYS
397        }
398        Err(e) => {
399            return Err(e).with_context(|| format!("Failed to parse futures expiry '{expiry}'"));
400        }
401    };
402
403    let activation_ns = activation_from_expiration(expiration_ns);
404
405    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 1.0);
406    let raw_symbol = if matches!(
407        details.contract.security_type,
408        SecurityType::ContinuousFuture
409    ) && !details.contract.symbol.as_str().is_empty()
410    {
411        details.contract.symbol.as_str()
412    } else {
413        details.contract.local_symbol.as_str()
414    };
415
416    let instrument = FuturesContract::builder()
417        .instrument_id(instrument_id)
418        .raw_symbol(Symbol::from(raw_symbol))
419        .asset_class(sec_type_to_asset_class(
420            details.under_security_type.as_str(),
421        ))
422        .underlying(Ustr::from(details.under_symbol.as_str()))
423        .activation_ns(activation_ns)
424        .expiration_ns(expiration_ns)
425        .currency(Currency::from(details.contract.currency.to_string()))
426        .price_precision(price_precision)
427        .price_increment(Price::new(details.min_tick, price_precision))
428        .multiplier(multiplier)
429        .lot_size(Quantity::new(1.0, 0))
430        .info(ib_contract_info(details))
431        .ts_event(timestamp)
432        .ts_init(timestamp)
433        .build()
434        .unwrap();
435
436    Ok(InstrumentAny::from(instrument))
437}
438
439/// Parse option contract (OPT).
440fn parse_option_contract(
441    details: &ibapi::contracts::ContractDetails,
442    instrument_id: InstrumentId,
443) -> anyhow::Result<InstrumentAny> {
444    let price_precision = tick_size_to_precision(details.min_tick);
445    let timestamp = get_atomic_clock_realtime().get_time_ns();
446
447    let expiry = &details.contract.last_trade_date_or_contract_month;
448    let expiration_ns = expiry_timestring_to_unix_nanos(expiry, Some(details))
449        .with_context(|| format!("Failed to parse option expiry '{expiry}'"))?;
450
451    let activation_ns = activation_from_expiration(expiration_ns);
452
453    // Parse option kind (CALL or PUT)
454    let option_kind = details
455        .contract
456        .right
457        .map(|right| IbOptionRight::from_str(right.as_str()))
458        .transpose()?
459        .context("Option contract missing right")?
460        .option_kind();
461
462    let multiplier = parse_contract_multiplier(&details.contract.multiplier, 100.0);
463    let asset_class = sec_type_to_asset_class(details.under_security_type.as_str());
464
465    // IB reports a magnified contract's strike in the same sub-unit as its market data
466    let strike = details.contract.strike / f64::from(details.price_magnifier.max(1));
467
468    let underlying =
469        if details.under_security_type == "IND" && !details.under_symbol.starts_with('^') {
470            format!("^{}", details.under_symbol)
471        } else {
472            details.under_symbol.clone()
473        };
474
475    let instrument = OptionContract::builder()
476        .instrument_id(instrument_id)
477        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
478        .asset_class(asset_class)
479        .underlying(Ustr::from(underlying.as_str()))
480        .option_kind(option_kind)
481        .strike_price(Price::new(strike, price_precision))
482        .currency(Currency::from(details.contract.currency.to_string()))
483        .activation_ns(activation_ns)
484        .expiration_ns(expiration_ns)
485        .price_precision(price_precision)
486        .price_increment(Price::new(details.min_tick, price_precision))
487        .multiplier(multiplier)
488        .lot_size(multiplier)
489        .info(ib_contract_info(details))
490        .ts_event(timestamp)
491        .ts_init(timestamp)
492        .build()
493        .unwrap();
494
495    Ok(InstrumentAny::from(instrument))
496}
497
498fn activation_from_expiration(expiration_ns: UnixNanos) -> UnixNanos {
499    expiration_ns.checked_sub(NINETY_DAYS).unwrap_or_default()
500}
501
502fn parse_contract_size_rules(
503    details: &ibapi::contracts::ContractDetails,
504    default_increment: f64,
505) -> (u8, Quantity, Option<Quantity>) {
506    let size_increment = details
507        .size_increment
508        .or(details.min_size)
509        .unwrap_or(default_increment);
510    let size_precision = details.min_size.map_or_else(
511        || tick_size_to_precision(size_increment),
512        |min_size| tick_size_to_precision(min_size).max(tick_size_to_precision(size_increment)),
513    );
514    let min_quantity = details
515        .min_size
516        .map(|min_size| Quantity::new(min_size, size_precision));
517
518    (
519        size_precision,
520        Quantity::new(size_increment, size_precision),
521        min_quantity,
522    )
523}
524
525#[cfg(test)]
526#[allow(
527    clippy::items_after_test_module,
528    reason = "private parser tests remain beside the contract helpers they exercise"
529)]
530mod tests {
531    use ibapi::contracts::{
532        Contract, ContractDetails, Currency, Exchange, OptionRight, SecurityType, Symbol,
533    };
534    use nautilus_model::{
535        enums::AssetClass,
536        identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue},
537        instruments::{Instrument, InstrumentAny},
538        types::{Price, Quantity},
539    };
540    use rstest::rstest;
541    use ustr::Ustr;
542
543    use super::{
544        expiry_timestring_to_unix_nanos, parse_contract_multiplier,
545        parse_ib_contract_to_instrument, parse_option_spread_instrument_id, split_last_trade_date,
546    };
547
548    fn es_future_details(expiry: &str) -> ContractDetails {
549        ContractDetails {
550            contract: Contract {
551                symbol: Symbol::from("ES"),
552                security_type: SecurityType::Future,
553                exchange: Exchange::from("CME"),
554                currency: Currency::from("USD"),
555                local_symbol: String::from("ESZ6"),
556                last_trade_date_or_contract_month: expiry.to_string(),
557                multiplier: "50".to_string(),
558                ..Default::default()
559            },
560            min_tick: 0.25,
561            under_symbol: String::from("ES"),
562            under_security_type: String::from("IND"),
563            time_zone_id: String::from("US/Central"),
564            ..Default::default()
565        }
566    }
567
568    #[rstest]
569    fn test_parse_crypto_contract_creates_spot_currency_pair() {
570        let details = ContractDetails {
571            contract: Contract {
572                symbol: Symbol::from("BTC"),
573                security_type: SecurityType::Crypto,
574                exchange: Exchange::from("PAXOS"),
575                currency: Currency::from("USD"),
576                local_symbol: String::from("BTC.USD"),
577                ..Default::default()
578            },
579            min_tick: 0.01,
580            min_size: Some(0.0001),
581            size_increment: Some(0.0001),
582            ..Default::default()
583        };
584
585        let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
586
587        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
588
589        let InstrumentAny::CurrencyPair(pair) = instrument else {
590            panic!("expected spot currency pair");
591        };
592
593        assert_eq!(pair.base_currency.code.as_str(), "BTC");
594        assert_eq!(pair.quote_currency.code.as_str(), "USD");
595        assert_eq!(pair.size_precision, 4);
596        assert_eq!(pair.size_increment, Quantity::from("0.0001"));
597    }
598
599    #[rstest]
600    fn test_expiry_session_end_uses_contract_timezone() {
601        let details = ContractDetails {
602            time_zone_id: String::from("America/New_York"),
603            trading_hours: vec![String::from("20260313:0930-20260313:1600")],
604            ..Default::default()
605        };
606
607        let expiry = expiry_timestring_to_unix_nanos("20260313", Some(&details)).unwrap();
608        let expected = "2026-03-13T20:00:00Z".parse::<jiff::Timestamp>().unwrap();
609
610        assert_eq!(
611            expiry.as_u64(),
612            u64::try_from(expected.as_nanosecond()).unwrap()
613        );
614    }
615
616    #[rstest]
617    #[case::listed_session_closes_later_than_expiry(
618        vec!["20260929:0930-20260929:1600", "20260930:0930-20260930:1600", "20261001:CLOSED"],
619        "2026-10-30T20:00:00Z"
620    )]
621    #[case::overnight_sessions(
622        vec!["20260928:1700-20260929:1600", "20260929:1700-20260930:1600"],
623        "2026-10-30T20:00:00Z"
624    )]
625    #[case::only_closed_sessions(vec!["20261001:CLOSED"], "2026-10-30T04:00:00Z")]
626    #[case::no_sessions(vec![], "2026-10-30T04:00:00Z")]
627    fn test_expiry_beyond_listed_sessions_uses_last_session_close(
628        #[case] trading_hours: Vec<&str>,
629        #[case] expected: &str,
630    ) {
631        let details = ContractDetails {
632            time_zone_id: String::from("America/New_York"),
633            trading_hours: trading_hours.into_iter().map(String::from).collect(),
634            ..Default::default()
635        };
636
637        let expiry = expiry_timestring_to_unix_nanos("20261030", Some(&details)).unwrap();
638        let expected = expected.parse::<jiff::Timestamp>().unwrap();
639
640        assert_eq!(
641            expiry.as_u64(),
642            u64::try_from(expected.as_nanosecond()).unwrap()
643        );
644    }
645
646    #[rstest]
647    fn test_expiry_date_takes_last_trade_time_over_session() {
648        let details = ContractDetails {
649            time_zone_id: String::from("US/Central"),
650            last_trade_time: String::from("08:30:00"),
651            trading_hours: vec![String::from("20261218:1700-20261219:1600")],
652            ..Default::default()
653        };
654
655        let expiry = expiry_timestring_to_unix_nanos("20261218", Some(&details)).unwrap();
656        let expected = "2026-12-18T14:30:00Z".parse::<jiff::Timestamp>().unwrap();
657
658        assert_eq!(
659            expiry.as_u64(),
660            u64::try_from(expected.as_nanosecond()).unwrap()
661        );
662    }
663
664    #[rstest]
665    #[case::no_contract_timezone("")]
666    #[case::same_contract_timezone("US/Central")]
667    #[case::other_contract_timezone("Asia/Tokyo")]
668    fn test_expiry_date_takes_last_trade_time_zone_over_contract_timezone(
669        #[case] time_zone_id: &str,
670    ) {
671        let details = ContractDetails {
672            time_zone_id: time_zone_id.to_string(),
673            last_trade_time: String::from("08:30:00 US/Central"),
674            ..Default::default()
675        };
676
677        let expiry = expiry_timestring_to_unix_nanos("20261218", Some(&details)).unwrap();
678        let expected = "2026-12-18T14:30:00Z".parse::<jiff::Timestamp>().unwrap();
679
680        assert_eq!(
681            expiry.as_u64(),
682            u64::try_from(expected.as_nanosecond()).unwrap()
683        );
684    }
685
686    #[rstest]
687    fn test_expiry_rejects_last_trade_time_with_extra_token() {
688        let details = ContractDetails {
689            last_trade_time: String::from("08:30:00 US/Central extra"),
690            ..Default::default()
691        };
692
693        let result = expiry_timestring_to_unix_nanos("20261218", Some(&details));
694
695        assert_eq!(
696            result.unwrap_err().to_string(),
697            "Invalid expiry format: 08:30:00 US/Central extra"
698        );
699    }
700
701    #[rstest]
702    fn test_split_last_trade_date_moves_time_and_zone_out_of_expiry() {
703        let mut details = es_future_details("20261218 08:30:00 US/Central");
704
705        split_last_trade_date(&mut details);
706
707        assert_eq!(
708            details.contract.last_trade_date_or_contract_month,
709            "20261218"
710        );
711        assert_eq!(details.last_trade_time, "08:30:00 US/Central");
712    }
713
714    #[rstest]
715    #[case::date("20261218")]
716    #[case::contract_month("202612")]
717    #[case::empty("")]
718    fn test_split_last_trade_date_keeps_expiry_without_time(#[case] expiry: &str) {
719        let mut details = es_future_details(expiry);
720
721        split_last_trade_date(&mut details);
722
723        assert_eq!(details.contract.last_trade_date_or_contract_month, expiry);
724        assert!(details.last_trade_time.is_empty());
725    }
726
727    #[rstest]
728    fn test_split_last_trade_date_keeps_bond_expiry() {
729        let mut details = ContractDetails {
730            contract: Contract {
731                security_type: SecurityType::Bond,
732                last_trade_date_or_contract_month: String::from("20301115 16:00:00 US/Eastern"),
733                ..Default::default()
734            },
735            ..Default::default()
736        };
737
738        split_last_trade_date(&mut details);
739
740        assert_eq!(
741            details.contract.last_trade_date_or_contract_month,
742            "20301115 16:00:00 US/Eastern"
743        );
744        assert!(details.last_trade_time.is_empty());
745    }
746
747    #[rstest]
748    #[case::same_contract_timezone("US/Central", "")]
749    #[case::no_contract_timezone("", "")]
750    #[case::other_contract_timezone("America/New_York", "")]
751    #[case::other_last_trade_time("America/New_York", "10:00:00")]
752    fn test_parse_future_after_split_keeps_expiry_and_stores_date(
753        #[case] time_zone_id: &str,
754        #[case] last_trade_time: &str,
755    ) {
756        let instrument_id = InstrumentId::from("ESZ6.CME");
757        let mut details = es_future_details("20261218 08:30:00 US/Central");
758        details.time_zone_id = time_zone_id.to_string();
759        details.last_trade_time = last_trade_time.to_string();
760        let unsplit = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
761
762        split_last_trade_date(&mut details);
763        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
764
765        let expected = "2026-12-18T14:30:00Z".parse::<jiff::Timestamp>().unwrap();
766        assert_eq!(
767            instrument.expiration_ns().unwrap().as_u64(),
768            u64::try_from(expected.as_nanosecond()).unwrap()
769        );
770        assert_eq!(instrument.expiration_ns(), unsplit.expiration_ns());
771        let InstrumentAny::FuturesContract(future) = instrument else {
772            panic!("expected futures contract");
773        };
774        let info = future.info.unwrap();
775        assert_eq!(
776            info.get("contract")
777                .and_then(|contract| contract.get("lastTradeDateOrContractMonth")),
778            Some(&serde_json::Value::from("20261218"))
779        );
780    }
781
782    #[rstest]
783    fn test_parse_future_rejects_missing_expiry() {
784        let details = ContractDetails {
785            contract: Contract {
786                symbol: Symbol::from("ES"),
787                security_type: SecurityType::Future,
788                exchange: Exchange::from("CME"),
789                currency: Currency::from("USD"),
790                local_symbol: String::from("ESZ6"),
791                ..Default::default()
792            },
793            min_tick: 0.25,
794            ..Default::default()
795        };
796
797        let result = parse_ib_contract_to_instrument(&details, InstrumentId::from("ESZ6.CME"));
798
799        assert_eq!(
800            result.unwrap_err().to_string(),
801            "Failed to parse futures expiry ''"
802        );
803    }
804
805    #[rstest]
806    fn test_parse_option_contract_prefixes_index_underlying() {
807        let details = ContractDetails {
808            contract: Contract {
809                symbol: Symbol::from("SPXW"),
810                security_type: SecurityType::Option,
811                exchange: Exchange::from("SMART"),
812                currency: Currency::from("USD"),
813                local_symbol: "SPXW  260313P06630000".to_string(),
814                last_trade_date_or_contract_month: "20260313".to_string(),
815                right: Some(OptionRight::Put),
816                strike: 6630.0,
817                multiplier: "100".to_string(),
818                ..Default::default()
819            },
820            min_tick: 0.05,
821            under_symbol: "SPX".to_string(),
822            under_security_type: "IND".to_string(),
823            ..Default::default()
824        };
825
826        let instrument_id = InstrumentId::new(
827            NautilusSymbol::from("SPXW  260313P06630000"),
828            Venue::from("SMART"),
829        );
830
831        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
832
833        let InstrumentAny::OptionContract(option) = instrument else {
834            panic!("expected option contract");
835        };
836
837        assert_eq!(option.asset_class(), AssetClass::Index);
838        assert_eq!(option.underlying(), Some(Ustr::from("^SPX")));
839    }
840
841    #[rstest]
842    fn test_parse_contract_preserves_price_magnifier_in_info() {
843        let details = ContractDetails {
844            contract: Contract {
845                symbol: Symbol::from("AAPL"),
846                security_type: SecurityType::Stock,
847                exchange: Exchange::from("SMART"),
848                primary_exchange: Exchange::from("NASDAQ"),
849                currency: Currency::from("USD"),
850                local_symbol: String::from("AAPL"),
851                ..Default::default()
852            },
853            min_tick: 0.01,
854            price_magnifier: 100,
855            ..Default::default()
856        };
857
858        let instrument_id = InstrumentId::new(NautilusSymbol::from("AAPL"), Venue::from("XNAS"));
859
860        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
861
862        let InstrumentAny::Equity(equity) = instrument else {
863            panic!("expected equity");
864        };
865
866        assert_eq!(
867            equity.info.unwrap().get("priceMagnifier"),
868            Some(&serde_json::Value::from(100))
869        );
870    }
871
872    #[rstest]
873    #[case("100", 100.0)]
874    #[case("", 1.0)]
875    #[case("not-a-number", 1.0)]
876    fn test_parse_contract_multiplier_uses_quantity_parser(
877        #[case] multiplier: &str,
878        #[case] expected: f64,
879    ) {
880        assert_eq!(
881            parse_contract_multiplier(multiplier, 1.0),
882            Quantity::new(expected, 0)
883        );
884    }
885
886    #[rstest]
887    fn test_parse_continuous_future_contract_uses_symbol_as_raw_symbol() {
888        let details = ContractDetails {
889            contract: Contract {
890                symbol: Symbol::from("ES"),
891                security_type: SecurityType::ContinuousFuture,
892                exchange: Exchange::from("CME"),
893                currency: Currency::from("USD"),
894                local_symbol: String::new(),
895                multiplier: "50".to_string(),
896                ..Default::default()
897            },
898            min_tick: 0.25,
899            under_symbol: "ES".to_string(),
900            under_security_type: "IND".to_string(),
901            ..Default::default()
902        };
903
904        let instrument_id = InstrumentId::new(NautilusSymbol::from("ES"), Venue::from("CME"));
905
906        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
907
908        let InstrumentAny::FuturesContract(future) = instrument else {
909            panic!("expected futures contract");
910        };
911
912        assert_eq!(future.raw_symbol().as_str(), "ES");
913    }
914
915    #[rstest]
916    fn test_parse_option_spread_uses_minimum_leg_tick() {
917        let leg1 = ContractDetails {
918            contract: Contract {
919                symbol: Symbol::from("SPY"),
920                security_type: SecurityType::Option,
921                exchange: Exchange::from("SMART"),
922                currency: Currency::from("USD"),
923                local_symbol: "SPY   260120C00400000".to_string(),
924                multiplier: "100".to_string(),
925                ..Default::default()
926            },
927            min_tick: 0.05,
928            under_symbol: "SPY".to_string(),
929            ..Default::default()
930        };
931
932        let leg2 = ContractDetails {
933            contract: Contract {
934                symbol: Symbol::from("SPY"),
935                security_type: SecurityType::Option,
936                exchange: Exchange::from("SMART"),
937                currency: Currency::from("USD"),
938                local_symbol: "SPY   260120C00410000".to_string(),
939                multiplier: "100".to_string(),
940                ..Default::default()
941            },
942            min_tick: 0.01,
943            under_symbol: "SPY".to_string(),
944            ..Default::default()
945        };
946
947        let instrument_id =
948            InstrumentId::from("(1)SPY   260120C00400000_((-1))SPY   260120C00410000.SMART");
949
950        let spread = parse_option_spread_instrument_id(
951            instrument_id,
952            &[(&leg1, 1), (&leg2, -1)],
953            None,
954            None,
955        )
956        .unwrap();
957
958        assert_eq!(spread.price_precision(), 2);
959        assert_eq!(spread.price_increment(), Price::from("0.01"));
960    }
961
962    #[rstest]
963    fn test_parse_futures_option_contract_divides_magnified_strike() {
964        let details = ContractDetails {
965            contract: Contract {
966                symbol: Symbol::from("ZC"),
967                security_type: SecurityType::FuturesOption,
968                exchange: Exchange::from("CBOT"),
969                currency: Currency::from("USD"),
970                local_symbol: "OZCX6 C4630".to_string(),
971                last_trade_date_or_contract_month: "20261023".to_string(),
972                right: Some(OptionRight::Call),
973                strike: 463.0,
974                multiplier: "5000".to_string(),
975                ..Default::default()
976            },
977            min_tick: 0.00125,
978            price_magnifier: 100,
979            under_symbol: "ZCZ6".to_string(),
980            under_security_type: "FUT".to_string(),
981            ..Default::default()
982        };
983
984        let instrument_id =
985            InstrumentId::new(NautilusSymbol::from("OZCX6 C4630"), Venue::from("CBOT"));
986
987        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
988
989        let InstrumentAny::OptionContract(option) = instrument else {
990            panic!("expected option contract");
991        };
992
993        assert_eq!(option.strike_price, Price::from("4.63000"));
994        assert_eq!(option.price_increment, Price::from("0.00125"));
995        assert_eq!(option.price_precision, 5);
996    }
997
998    #[rstest]
999    fn test_parse_future_contract_keeps_min_tick_with_price_magnifier() {
1000        let details = ContractDetails {
1001            contract: Contract {
1002                symbol: Symbol::from("ZC"),
1003                security_type: SecurityType::Future,
1004                exchange: Exchange::from("CBOT"),
1005                currency: Currency::from("USD"),
1006                local_symbol: "ZCZ6".to_string(),
1007                last_trade_date_or_contract_month: "20261214".to_string(),
1008                multiplier: "5000".to_string(),
1009                ..Default::default()
1010            },
1011            min_tick: 0.0025,
1012            price_magnifier: 100,
1013            under_symbol: "ZC".to_string(),
1014            ..Default::default()
1015        };
1016
1017        let instrument_id = InstrumentId::new(NautilusSymbol::from("ZCZ6"), Venue::from("CBOT"));
1018
1019        let instrument = parse_ib_contract_to_instrument(&details, instrument_id).unwrap();
1020
1021        let InstrumentAny::FuturesContract(future) = instrument else {
1022            panic!("expected futures contract");
1023        };
1024
1025        assert_eq!(future.price_increment, Price::from("0.0025"));
1026        assert_eq!(future.price_precision, 4);
1027        assert_eq!(future.multiplier, Quantity::from(5000));
1028    }
1029}
1030
1031/// Parse index contract (IND).
1032///
1033/// Note: Indices are typically not directly tradable. This creates a CurrencyPair
1034/// representation as a placeholder until IndexInstrument type is available.
1035fn parse_index_contract(
1036    details: &ibapi::contracts::ContractDetails,
1037    instrument_id: InstrumentId,
1038) -> InstrumentAny {
1039    let price_precision = tick_size_to_precision(details.min_tick);
1040    let (size_precision, size_increment, _) = parse_contract_size_rules(details, 1.0);
1041    let timestamp = get_atomic_clock_realtime().get_time_ns();
1042
1043    let instrument = IndexInstrument::builder()
1044        .instrument_id(instrument_id)
1045        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
1046        .currency(Currency::from(details.contract.currency.to_string()))
1047        .price_precision(price_precision)
1048        .size_precision(size_precision)
1049        .price_increment(Price::new(details.min_tick, price_precision))
1050        .size_increment(size_increment)
1051        .info(ib_contract_info(details))
1052        .ts_event(timestamp)
1053        .ts_init(timestamp)
1054        .build()
1055        .unwrap();
1056
1057    InstrumentAny::from(instrument)
1058}
1059
1060/// Parse a spread instrument ID into an OptionSpread instrument.
1061///
1062/// This implements the same logic as Python's `parse_spread_instrument_id`.
1063/// Uses contract details from the first leg to determine spread properties.
1064///
1065/// # Errors
1066///
1067/// Returns an error if parsing fails.
1068pub fn parse_spread_instrument_id(
1069    instrument_id: InstrumentId,
1070    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
1071    timestamp_ns: Option<UnixNanos>,
1072) -> anyhow::Result<OptionSpread> {
1073    if leg_contract_details.is_empty() {
1074        anyhow::bail!("leg_contract_details must be provided");
1075    }
1076
1077    // Use contract details from first leg
1078    let (first_details, _) = leg_contract_details[0];
1079    let first_contract = &first_details.contract;
1080
1081    // Extract properties from the first leg contract details
1082    let currency = Currency::from(first_contract.currency.to_string());
1083    let underlying = if first_details.under_symbol.is_empty() {
1084        Ustr::from(first_contract.symbol.as_str())
1085    } else {
1086        Ustr::from(first_details.under_symbol.as_str())
1087    };
1088
1089    // Parse multiplier
1090    let multiplier_str = first_contract.multiplier.clone();
1091    let multiplier =
1092        Quantity::from_str(&multiplier_str).unwrap_or_else(|_| Quantity::new(100.0, 0)); // Default to 100 for options
1093
1094    // Determine asset class based on security type
1095    let asset_class = match first_contract.security_type {
1096        ibapi::contracts::SecurityType::FuturesOption => AssetClass::Index, // Futures options
1097        _ => AssetClass::Equity,                                            // Equity options
1098    };
1099
1100    // Calculate price precision and increment from the finest leg tick.
1101    let min_tick = leg_contract_details
1102        .iter()
1103        .map(|(details, _)| details.min_tick)
1104        .fold(first_details.min_tick, f64::min);
1105    let price_precision = tick_size_to_precision(min_tick);
1106    let price_increment = Price::new(min_tick, price_precision);
1107
1108    // Use provided timestamp or current time
1109    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
1110
1111    // For options spreads, lot size equals multiplier (same as individual option contracts)
1112    let lot_size = multiplier;
1113
1114    // Create the spread instrument
1115    let spread = OptionSpread::builder()
1116        .instrument_id(instrument_id)
1117        .raw_symbol(Symbol::from(instrument_id.symbol.as_str()))
1118        .asset_class(asset_class)
1119        .underlying(underlying)
1120        .strategy_type(Ustr::from("SPREAD"))
1121        // activation_ns (spreads don't have single activation dates)
1122        .activation_ns(UnixNanos::new(0))
1123        // expiration_ns (spreads don't have single expiration dates)
1124        .expiration_ns(UnixNanos::new(0))
1125        .currency(currency)
1126        .price_precision(price_precision)
1127        .price_increment(price_increment)
1128        .multiplier(multiplier)
1129        .lot_size(lot_size)
1130        .margin_init(Decimal::ZERO)
1131        .margin_maint(Decimal::ZERO)
1132        .ts_event(timestamp)
1133        .ts_init(timestamp)
1134        .build()?;
1135
1136    Ok(spread)
1137}
1138
1139pub fn parse_option_spread_instrument_id(
1140    instrument_id: InstrumentId,
1141    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
1142    bag_contract: Option<&ibapi::contracts::Contract>,
1143    timestamp_ns: Option<UnixNanos>,
1144) -> anyhow::Result<OptionSpread> {
1145    let mut spread = parse_spread_instrument_id(instrument_id, leg_contract_details, timestamp_ns)?;
1146    spread.info = bag_contract
1147        .map(ib_contract_info_for_contract)
1148        .map(nautilus_core::Params::new);
1149    Ok(spread)
1150}
1151
1152pub fn parse_futures_spread_instrument_id(
1153    instrument_id: InstrumentId,
1154    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
1155    bag_contract: Option<&ibapi::contracts::Contract>,
1156    timestamp_ns: Option<UnixNanos>,
1157) -> anyhow::Result<FuturesSpread> {
1158    if leg_contract_details.is_empty() {
1159        anyhow::bail!("leg_contract_details must be provided");
1160    }
1161
1162    let (first_details, _) = leg_contract_details[0];
1163    let first_contract = &first_details.contract;
1164    let currency = Currency::from(first_contract.currency.to_string());
1165    let underlying = if first_details.under_symbol.is_empty() {
1166        Ustr::from(first_contract.symbol.as_str())
1167    } else {
1168        Ustr::from(first_details.under_symbol.as_str())
1169    };
1170
1171    let multiplier =
1172        Quantity::from_str(&first_contract.multiplier).unwrap_or_else(|_| Quantity::new(1.0, 0));
1173    let min_tick = leg_contract_details
1174        .iter()
1175        .map(|(details, _)| details.min_tick)
1176        .fold(first_details.min_tick, f64::min);
1177    let price_precision = tick_size_to_precision(min_tick);
1178    let price_increment = Price::new(min_tick, price_precision);
1179    let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
1180
1181    Ok(FuturesSpread::builder()
1182        .instrument_id(instrument_id)
1183        .raw_symbol(Symbol::from(instrument_id.symbol.as_str()))
1184        .asset_class(AssetClass::Index)
1185        .underlying(underlying)
1186        .strategy_type(Ustr::from("SPREAD"))
1187        .activation_ns(UnixNanos::new(0))
1188        .expiration_ns(UnixNanos::new(0))
1189        .currency(currency)
1190        .price_precision(price_precision)
1191        .price_increment(price_increment)
1192        .multiplier(multiplier)
1193        .lot_size(Quantity::new(1.0, 0))
1194        .margin_init(Decimal::ZERO)
1195        .margin_maint(Decimal::ZERO)
1196        .maybe_info(
1197            bag_contract
1198                .map(ib_contract_info_for_contract)
1199                .map(nautilus_core::Params::new),
1200        )
1201        .ts_event(timestamp)
1202        .ts_init(timestamp)
1203        .build()?)
1204}
1205
1206pub fn parse_spread_instrument_any(
1207    instrument_id: InstrumentId,
1208    leg_contract_details: &[(&ibapi::contracts::ContractDetails, i32)],
1209    bag_contract: Option<&ibapi::contracts::Contract>,
1210    timestamp_ns: Option<UnixNanos>,
1211) -> anyhow::Result<InstrumentAny> {
1212    let has_future = leg_contract_details.iter().any(|(details, _)| {
1213        matches!(
1214            details.contract.security_type,
1215            SecurityType::Future | SecurityType::ContinuousFuture
1216        )
1217    });
1218
1219    if has_future {
1220        Ok(InstrumentAny::from(parse_futures_spread_instrument_id(
1221            instrument_id,
1222            leg_contract_details,
1223            bag_contract,
1224            timestamp_ns,
1225        )?))
1226    } else {
1227        Ok(InstrumentAny::from(parse_option_spread_instrument_id(
1228            instrument_id,
1229            leg_contract_details,
1230            bag_contract,
1231            timestamp_ns,
1232        )?))
1233    }
1234}
1235
1236/// Parse CFD contract (CFD).
1237fn parse_cfd_contract(
1238    details: &ibapi::contracts::ContractDetails,
1239    instrument_id: InstrumentId,
1240) -> InstrumentAny {
1241    let price_precision = tick_size_to_precision(details.min_tick);
1242    let (size_precision, size_increment, min_quantity) = parse_contract_size_rules(details, 1.0);
1243    let timestamp = get_atomic_clock_realtime().get_time_ns();
1244
1245    let base_currency = details
1246        .contract
1247        .local_symbol
1248        .contains('.')
1249        .then(|| Currency::from(details.contract.symbol.to_string()));
1250
1251    let instrument = Cfd::builder()
1252        .instrument_id(instrument_id)
1253        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
1254        .asset_class(sec_type_to_asset_class(
1255            details.under_security_type.as_str(),
1256        ))
1257        .maybe_base_currency(base_currency)
1258        .quote_currency(Currency::from(details.contract.currency.to_string()))
1259        .price_precision(price_precision)
1260        .size_precision(size_precision)
1261        .price_increment(Price::new(details.min_tick, price_precision))
1262        .size_increment(size_increment)
1263        .maybe_min_quantity(min_quantity)
1264        .info(ib_contract_info(details))
1265        .ts_event(timestamp)
1266        .ts_init(timestamp)
1267        .build()
1268        .unwrap();
1269
1270    InstrumentAny::from(instrument)
1271}
1272
1273/// Parse commodity contract (CMDTY).
1274fn parse_commodity_contract(
1275    details: &ibapi::contracts::ContractDetails,
1276    instrument_id: InstrumentId,
1277) -> InstrumentAny {
1278    let price_precision = tick_size_to_precision(details.min_tick);
1279    let (size_precision, size_increment, min_quantity) = parse_contract_size_rules(details, 1.0);
1280    let timestamp = get_atomic_clock_realtime().get_time_ns();
1281
1282    let instrument = Commodity::builder()
1283        .instrument_id(instrument_id)
1284        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
1285        .asset_class(AssetClass::Commodity)
1286        .quote_currency(Currency::from(details.contract.currency.to_string()))
1287        .price_precision(price_precision)
1288        .size_precision(size_precision)
1289        .price_increment(Price::new(details.min_tick, price_precision))
1290        .size_increment(size_increment)
1291        .maybe_min_quantity(min_quantity)
1292        .info(ib_contract_info(details))
1293        .ts_event(timestamp)
1294        .ts_init(timestamp)
1295        .build()
1296        .unwrap();
1297
1298    InstrumentAny::from(instrument)
1299}
1300
1301/// Parse bond contract (BOND).
1302fn parse_bond_contract(
1303    details: &ibapi::contracts::ContractDetails,
1304    instrument_id: InstrumentId,
1305) -> InstrumentAny {
1306    // Use Equity as a placeholder until Bond type is available in Rust model
1307    // Note: This is a limitation of the current Nautilus Rust model, not the IB adapter
1308    let price_precision = tick_size_to_precision(details.min_tick);
1309    let timestamp = get_atomic_clock_realtime().get_time_ns();
1310
1311    // ISIN could be extracted from `security_id` if needed
1312    let instrument = Equity::builder()
1313        .instrument_id(instrument_id)
1314        .raw_symbol(Symbol::from(details.contract.local_symbol.as_str()))
1315        .currency(Currency::from(details.contract.currency.to_string()))
1316        .price_precision(price_precision)
1317        .price_increment(Price::new(details.min_tick, price_precision))
1318        // Standard lot size for bonds
1319        .lot_size(Quantity::new(1.0, 0))
1320        .info(ib_contract_info(details))
1321        .ts_event(timestamp)
1322        .ts_init(timestamp)
1323        .build()
1324        .unwrap();
1325
1326    InstrumentAny::from(instrument)
1327}