1use 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#[must_use]
52pub fn tick_size_to_precision(tick_size: f64) -> u8 {
53 if tick_size <= 0.0 {
54 return 8; }
56
57 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
68pub(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
82pub(crate) fn trim_last_trade_date(contract: &mut ibapi::contracts::Contract) -> Option<String> {
85 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
104pub 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 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
180fn 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
196fn 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
224pub 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), 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
278fn 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 .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
303fn 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
331fn 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
373fn 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 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
439fn 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 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 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
1031fn 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
1060pub 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 let (first_details, _) = leg_contract_details[0];
1079 let first_contract = &first_details.contract;
1080
1081 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 let multiplier_str = first_contract.multiplier.clone();
1091 let multiplier =
1092 Quantity::from_str(&multiplier_str).unwrap_or_else(|_| Quantity::new(100.0, 0)); let asset_class = match first_contract.security_type {
1096 ibapi::contracts::SecurityType::FuturesOption => AssetClass::Index, _ => AssetClass::Equity, };
1099
1100 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 let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
1110
1111 let lot_size = multiplier;
1113
1114 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(UnixNanos::new(0))
1123 .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
1236fn 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
1273fn 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
1301fn parse_bond_contract(
1303 details: &ibapi::contracts::ContractDetails,
1304 instrument_id: InstrumentId,
1305) -> InstrumentAny {
1306 let price_precision = tick_size_to_precision(details.min_tick);
1309 let timestamp = get_atomic_clock_realtime().get_time_ns();
1310
1311 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 .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}