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