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