1use 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#[must_use]
51pub fn tick_size_to_precision(tick_size: f64) -> u8 {
52 if tick_size <= 0.0 {
53 return 8; }
55
56 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
67pub 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 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
136fn 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
164pub 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), 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
228fn 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 .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
253fn 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
281fn 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
323fn 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 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
389fn 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 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
754fn 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
783pub 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 let (first_details, _) = leg_contract_details[0];
802 let first_contract = &first_details.contract;
803
804 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 let multiplier_str = first_contract.multiplier.clone();
814 let multiplier =
815 Quantity::from_str(&multiplier_str).unwrap_or_else(|_| Quantity::new(100.0, 0)); let asset_class = match first_contract.security_type {
819 ibapi::contracts::SecurityType::FuturesOption => AssetClass::Index, _ => AssetClass::Equity, };
822
823 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 let timestamp = timestamp_ns.unwrap_or_else(|| get_atomic_clock_realtime().get_time_ns());
833
834 let lot_size = multiplier;
836
837 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(UnixNanos::new(0))
846 .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
953fn 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
990fn 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
1018fn parse_bond_contract(
1020 details: &ibapi::contracts::ContractDetails,
1021 instrument_id: InstrumentId,
1022) -> InstrumentAny {
1023 let price_precision = tick_size_to_precision(details.min_tick);
1026 let timestamp = get_atomic_clock_realtime().get_time_ns();
1027
1028 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 .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}