1use std::{collections::HashMap, str::FromStr, sync::LazyLock};
19
20use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
21use nautilus_core::UnixNanos;
22use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, TradeId, Venue};
23
24use crate::common::enums::{IbOptionRight, IbSecurityType};
25
26fn ib_option_right_to_option_right(right: IbOptionRight) -> OptionRight {
27 match right {
28 IbOptionRight::Call => OptionRight::Call,
29 IbOptionRight::Put => OptionRight::Put,
30 }
31}
32
33pub fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
37 let ts_secs = ts_event.as_i64() / 1_000_000_000;
38 TradeId::new(format!("{ts_secs}-{price}-{size}"))
39}
40
41pub fn ib_contract_to_instrument_id_simplified(
55 contract: &Contract,
56 venue: Option<Venue>,
57) -> anyhow::Result<InstrumentId> {
58 let venue = venue.unwrap_or_else(|| {
59 match contract.security_type {
61 SecurityType::Index => {
62 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
63 Venue::from(contract.exchange.as_str())
64 } else {
65 Venue::from("SMART")
66 }
67 }
68 SecurityType::Future => {
69 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
70 Venue::from(contract.exchange.as_str())
71 } else {
72 Venue::from("GLOBEX")
73 }
74 }
75 SecurityType::ForexPair => Venue::from("IDEALPRO"),
76 SecurityType::Crypto => Venue::from("PAXOS"),
77 SecurityType::Stock => Venue::from("SMART"),
78 SecurityType::Option | SecurityType::FuturesOption => {
79 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
80 Venue::from(contract.exchange.as_str())
81 } else {
82 Venue::from("SMART")
83 }
84 }
85 SecurityType::CFD => Venue::from("SMART"),
86 SecurityType::Commodity => Venue::from("SMART"),
87 SecurityType::Bond => Venue::from("SMART"),
88 _ => Venue::from("SMART"),
89 }
90 });
91
92 let symbol = match contract.security_type {
93 SecurityType::Stock => {
94 let symbol_str = if contract.local_symbol.is_empty() {
96 contract.symbol.as_str().to_string()
97 } else {
98 contract.local_symbol.as_str().replace(' ', "-")
99 };
100 NautilusSymbol::from(symbol_str.as_str())
101 }
102 SecurityType::Index => {
103 let base = if contract.local_symbol.is_empty() {
105 contract.symbol.as_str()
106 } else {
107 contract.local_symbol.as_str()
108 };
109 NautilusSymbol::from(format!("^{base}").as_str())
110 }
111 SecurityType::Option => {
112 let symbol_str = if contract.local_symbol.is_empty() {
114 format!(
115 "{} {} {} {}",
116 contract.right.map_or("", |right| right.as_str()),
117 contract.trading_class.as_str(),
118 contract.last_trade_date_or_contract_month.as_str(),
119 format_option_strike(contract.strike),
120 )
121 } else {
122 normalize_option_symbol(contract.local_symbol.as_str())
123 };
124 NautilusSymbol::from(symbol_str.as_str())
125 }
126 SecurityType::ForexPair | SecurityType::Crypto => {
127 let symbol_str = if contract.local_symbol.is_empty() {
129 format!(
130 "{}/{}",
131 contract.symbol.as_str(),
132 contract.currency.as_str()
133 )
134 } else {
135 contract.local_symbol.as_str().replace('.', "/")
136 };
137 NautilusSymbol::from(symbol_str.as_str())
138 }
139 SecurityType::Future => {
140 if contract.local_symbol.is_empty() {
142 if !contract.trading_class.is_empty()
143 && !contract.last_trade_date_or_contract_month.is_empty()
144 {
145 let symbol_str = format!(
146 "{} {} {}",
147 contract.symbol.as_str(),
148 contract.trading_class.as_str(),
149 contract.last_trade_date_or_contract_month.as_str()
150 );
151 NautilusSymbol::from(symbol_str.as_str())
152 } else if !contract.last_trade_date_or_contract_month.is_empty() {
153 let expiry = contract.last_trade_date_or_contract_month.as_str();
154 let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
155 NautilusSymbol::from(symbol_str.as_str())
156 } else {
157 NautilusSymbol::from(contract.symbol.as_str())
158 }
159 } else {
160 NautilusSymbol::from(contract.local_symbol.as_str())
161 }
162 }
163 SecurityType::FuturesOption => {
164 if contract.local_symbol.is_empty() {
166 let expiry = contract.last_trade_date_or_contract_month.as_str();
168 let right = contract.right.map_or("P", |right| right.as_str());
169 let strike_str = format!("{}", contract.strike as i64);
170 let symbol_str = format!(
171 "{}{} {}{}",
172 contract.symbol.as_str(),
173 expiry,
174 right,
175 strike_str
176 );
177 NautilusSymbol::from(symbol_str.as_str())
178 } else {
179 NautilusSymbol::from(contract.local_symbol.as_str())
180 }
181 }
182 SecurityType::CFD => {
183 if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
185 let cash_like = contract.local_symbol.as_str().replace('.', "/");
186 NautilusSymbol::from(cash_like.as_str())
187 } else {
188 let symbol_str = contract.symbol.as_str().replace(' ', "-");
189 NautilusSymbol::from(symbol_str.as_str())
190 }
191 }
192 SecurityType::Commodity => {
193 let symbol_str = contract.symbol.as_str().replace(' ', "-");
195 NautilusSymbol::from(symbol_str.as_str())
196 }
197 SecurityType::Bond => {
198 let symbol_str = if contract.local_symbol.is_empty() {
200 contract.symbol.as_str()
201 } else {
202 contract.local_symbol.as_str()
203 };
204 NautilusSymbol::from(symbol_str)
205 }
206 _ => {
207 let symbol_str = if contract.local_symbol.is_empty() {
209 contract.symbol.as_str()
210 } else {
211 contract.local_symbol.as_str()
212 };
213 NautilusSymbol::from(symbol_str)
214 }
215 };
216
217 Ok(InstrumentId::new(symbol, venue))
218}
219
220pub fn ib_contract_to_instrument_id_raw(
232 contract: &Contract,
233 venue: Option<Venue>,
234) -> anyhow::Result<InstrumentId> {
235 let venue = venue.unwrap_or_else(|| match contract.security_type {
236 SecurityType::ForexPair => Venue::from("IDEALPRO"),
237 SecurityType::Crypto => Venue::from("PAXOS"),
238 SecurityType::Stock => Venue::from("SMART"),
239 SecurityType::Option => Venue::from("SMART"),
240 SecurityType::FuturesOption => Venue::from("SMART"),
241 SecurityType::Future => Venue::from("GLOBEX"),
242 SecurityType::Index => Venue::from("SMART"),
243 SecurityType::CFD => Venue::from("SMART"),
244 SecurityType::Commodity => Venue::from("SMART"),
245 SecurityType::Bond => Venue::from("SMART"),
246 _ => Venue::from("SMART"),
247 });
248
249 let local_symbol = if contract.local_symbol.is_empty() {
250 contract.symbol.as_str()
251 } else {
252 contract.local_symbol.as_str()
253 };
254
255 let sec_type_str = IbSecurityType::try_from(&contract.security_type).map_or_else(
256 |_| "OTHER".to_string(),
257 |security_type| security_type.to_string(),
258 );
259
260 let symbol_str = format!("{local_symbol}={sec_type_str}");
261 let symbol = NautilusSymbol::from(symbol_str.as_str());
262 Ok(InstrumentId::new(symbol, venue))
263}
264
265pub fn ib_contract_to_instrument_id_simple(contract: &Contract) -> anyhow::Result<InstrumentId> {
274 ib_contract_to_instrument_id_simplified(contract, None)
275}
276
277pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
280 LazyLock::new(|| {
281 let mut map = HashMap::new();
282 map.insert("NDEX", vec!["ENDEX"]);
284 map.insert("XCME", vec!["CME"]);
286 map.insert("XCEC", vec!["CME"]);
287 map.insert("XFXS", vec!["CME"]);
288 map.insert("XCBT", vec!["CBOT"]);
290 map.insert("CBCM", vec!["CBOT"]);
291 map.insert("XNYM", vec!["NYMEX"]);
293 map.insert("NYUM", vec!["NYMEX"]);
294 map.insert("IFUS", vec!["NYBOT"]);
296 map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
298 map.insert("XNAS", vec!["NASDAQ"]);
300 map.insert("XNYS", vec!["NYSE"]);
301 map.insert("ARCX", vec!["ARCA"]);
302 map.insert("BATS", vec!["BATS"]);
303 map.insert("IEXG", vec!["IEX"]);
304 map.insert("XCBO", vec!["CBOE"]);
305 map.insert("XCBF", vec!["CFE"]);
306 map.insert("XTSE", vec!["TSX"]);
308 map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
310 map.insert("XLON", vec!["LSE"]);
312 map.insert("XPAR", vec!["SBF"]);
313 map.insert("XETR", vec!["IBIS"]);
314 map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
315 map.insert("XAMS", vec!["AEB"]);
316 map.insert("XBRU", vec!["EBS"]);
317 map.insert("XBRD", vec!["BELFOX"]);
318 map.insert("XLIS", vec!["BVLP"]);
319 map.insert("XDUB", vec!["IRE"]);
320 map.insert("XOSL", vec!["OSL"]);
321 map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
322 map.insert("XSVX", vec!["VRTX"]);
323 map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
324 map.insert("XMAD", vec!["MDRD", "BME"]);
325 map.insert("DXEX", vec!["BATEEN"]);
326 map.insert("XWBO", vec!["WBAG"]);
327 map.insert("XBUD", vec!["BUX"]);
328 map.insert("XPRA", vec!["PRA"]);
329 map.insert("XWAR", vec!["WSE"]);
330 map.insert("XIST", vec!["ISE"]);
331 map.insert("XSTO", vec!["SFB"]);
333 map.insert("XCSE", vec!["KFB"]);
334 map.insert("XHEL", vec!["HMB"]);
335 map.insert("XICE", vec!["ISB"]);
336 map.insert("XASX", vec!["ASX"]);
338 map.insert("XHKG", vec!["SEHK"]);
339 map.insert("XHKF", vec!["HKFE"]);
340 map.insert("XSES", vec!["SGX"]);
341 map.insert("XOSE", vec!["OSE.JPN"]);
342 map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
343 map.insert("XKRX", vec!["KSE", "KRX"]);
344 map.insert("XTAI", vec!["TASE", "TWSE"]);
345 map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
346 map.insert("XSHE", vec!["SEHKSZSE"]);
347 map.insert("XNSE", vec!["NSE"]);
348 map.insert("XBOM", vec!["BSE"]);
349 map.insert("XSFE", vec!["SNFE"]);
351 map.insert("XMEX", vec!["MEXDER"]);
352 map.insert("XJSE", vec!["JSE"]);
354 map.insert("XBOG", vec!["BVC"]);
355 map.insert("XTAE", vec!["TASE"]);
356 map.insert("BVMF", vec!["BVMF"]);
357 map
358 });
359
360#[must_use]
361pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
362 if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
363 return exchanges
364 .iter()
365 .map(|exchange| (*exchange).to_string())
366 .collect();
367 }
368
369 vec![venue.to_string()]
370}
371
372const VENUES_CASH: &[&str] = &["IDEALPRO"];
374const VENUES_CRYPTO: &[&str] = &["PAXOS"];
375const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
376const VENUES_FUT: &[&str] = &[
377 "BELFOX",
378 "GLOBEX",
379 "CBOT",
380 "CFE",
381 "CME",
382 "COMEX",
383 "CBOE",
384 "DTB",
385 "EUREX",
386 "HKFE",
387 "ICE",
388 "ICEEU",
389 "ICEEUSOFT",
390 "IDEM",
391 "IPE",
392 "KCBT",
393 "MEXDER",
394 "MGE",
395 "NYBOT",
396 "NYMEX",
397 "OSE.JPN",
398 "SNFE",
399 "SOFFEX",
400 "VRTX",
401 "CMECRYPTO",
402 "NYMEXMETALS",
403 "NYMEXNG",
404 "NYMEXENERGY",
405 "CMEPRECIOUS",
406 "CMECURRENCY",
407 "CMEINDEX",
408 "CMEWEATHER",
409 "CMEINTEREST",
410 "CMEFLOOR",
411 "CBOTFLOOR",
412 "NYMEXFLOOR",
413 "NYBOTFLOOR",
414 "CFEFLOOR",
415 "CMEOPTIONS",
416 "CBOTOPTIONS",
417 "NYMEXOPTIONS",
418 "NYBOTOPTIONS",
419 "ECBOT",
420];
421const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
422const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
423
424fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
425 venues.contains(&venue_str)
426 || VENUE_MEMBERS
427 .get(venue_str)
428 .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
429}
430
431#[allow(dead_code)]
434const FUTURES_MONTH_CODES: &[(char, &str)] = &[
435 ('F', "01"),
436 ('G', "02"),
437 ('H', "03"),
438 ('J', "04"),
439 ('K', "05"),
440 ('M', "06"),
441 ('N', "07"),
442 ('Q', "08"),
443 ('U', "09"),
444 ('V', "10"),
445 ('X', "11"),
446 ('Z', "12"),
447];
448
449pub fn determine_venue_from_contract(
456 contract: &Contract,
457 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
458 convert_exchange_to_mic_venue: bool,
459 valid_exchanges: Option<&str>,
460) -> String {
461 if matches!(contract.security_type, SecurityType::CFD) {
462 return "IBCFD".to_string();
463 }
464
465 if matches!(contract.security_type, SecurityType::Commodity) {
466 return "IBCMDTY".to_string();
467 }
468
469 if !symbol_to_mic_venue.is_empty() {
470 let symbol = contract.symbol.as_str();
471 for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
472 if symbol.starts_with(symbol_prefix) {
473 return symbol_venue.clone();
474 }
475 }
476 }
477
478 let mut exchange = if contract.exchange.as_str() == "SMART"
480 && !contract.primary_exchange.as_str().is_empty()
481 && contract.primary_exchange.as_str() != "SMART"
482 {
483 contract.primary_exchange.as_str().to_string()
484 } else {
485 contract.exchange.as_str().to_string()
486 };
487
488 if exchange == "SMART"
489 && let Some(valid_exchanges) = valid_exchanges
490 {
491 let parts: Vec<&str> = valid_exchanges
492 .split(',')
493 .map(str::trim)
494 .filter(|part| !part.is_empty())
495 .collect();
496
497 if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
498 exchange = (*chosen).to_string();
499 } else if let Some(first) = parts.first() {
500 exchange = (*first).to_string();
501 }
502 }
503
504 if convert_exchange_to_mic_venue {
505 if let Some(venue) = exchange_to_mic_venue(&exchange) {
506 return venue;
507 }
508 }
509
510 exchange
511}
512
513#[must_use]
515pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
516 VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
517 exchanges
518 .contains(&exchange)
519 .then(|| (*venue_member).to_string())
520 })
521}
522
523pub fn instrument_id_to_ib_contract(
541 instrument_id: InstrumentId,
542 exchange: Option<&str>,
543) -> anyhow::Result<Contract> {
544 let venue_str = instrument_id.venue.to_string();
545 let derived_exchange = VENUE_MEMBERS
546 .get(venue_str.as_str())
547 .and_then(|exchanges| exchanges.first().copied())
548 .or_else(|| {
549 if venue_matches(venue_str.as_str(), VENUES_CASH)
550 || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
551 || venue_matches(venue_str.as_str(), VENUES_OPT)
552 || venue_matches(venue_str.as_str(), VENUES_FUT)
553 {
554 Some(venue_str.as_str())
555 } else {
556 None
557 }
558 })
559 .unwrap_or("SMART");
560 let exchange_str = exchange.unwrap_or(derived_exchange);
561 let symbol_str = instrument_id.symbol.as_str();
562
563 if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
564 return Ok(contract);
565 }
566
567 if venue_matches(venue_str.as_str(), VENUES_CASH)
575 && let Some(captures) = parse_cash_symbol(symbol_str)
576 {
577 return Ok(Contract {
578 contract_id: 0,
579 symbol: Symbol::from(&captures.base),
580 security_type: SecurityType::ForexPair,
581 exchange: Exchange::from(exchange_str),
582 currency: Currency::from(&captures.quote),
583 local_symbol: format!("{}.{}", captures.base, captures.quote),
584 ..Default::default()
585 });
586 }
587
588 if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
590 && let Some(captures) = parse_crypto_symbol(symbol_str)
591 {
592 return Ok(Contract {
593 contract_id: 0,
594 symbol: Symbol::from(&captures.base),
595 security_type: SecurityType::Crypto,
596 exchange: Exchange::from(exchange_str),
597 currency: Currency::from(&captures.quote),
598 local_symbol: format!("{}.{}", captures.base, captures.quote),
599 ..Default::default()
600 });
601 }
602
603 if venue_matches(venue_str.as_str(), VENUES_OPT) {
605 if let Some(opt) = parse_option_symbol(symbol_str) {
606 return Ok(Contract {
607 contract_id: 0,
608 symbol: Symbol::from(&opt.symbol),
609 security_type: SecurityType::Option,
610 exchange: Exchange::from(exchange_str),
611 currency: Currency::from("USD"), local_symbol: opt.local_symbol,
613 last_trade_date_or_contract_month: opt.expiry,
614 strike: opt.strike_value,
615 right: Some(opt.right),
616 ..Default::default()
617 });
618 }
619
620 if let Some(opt) = parse_named_option_symbol(symbol_str) {
621 return Ok(Contract {
622 contract_id: 0,
623 symbol: Symbol::from(&opt.trading_class),
624 security_type: SecurityType::Option,
625 exchange: Exchange::from(exchange_str),
626 currency: Currency::from("USD"),
627 trading_class: opt.trading_class,
628 last_trade_date_or_contract_month: opt.expiry,
629 strike: opt.strike_value,
630 right: Some(opt.right),
631 ..Default::default()
632 });
633 }
634 }
635
636 if venue_matches(venue_str.as_str(), VENUES_FUT) {
638 if let Some(fut) = parse_named_futures_symbol(symbol_str) {
639 return Ok(Contract {
640 contract_id: 0,
641 symbol: Symbol::from(&fut.underlying),
642 security_type: SecurityType::Future,
643 exchange: Exchange::from(exchange_str),
644 currency: Currency::from("USD"),
645 trading_class: fut.trading_class,
646 last_trade_date_or_contract_month: fut.expiry,
647 ..Default::default()
648 });
649 }
650
651 if let Some(underlying) = parse_futures_underlying(symbol_str) {
654 return Ok(Contract {
655 contract_id: 0,
656 symbol: Symbol::from(&underlying),
657 security_type: SecurityType::ContinuousFuture,
658 exchange: Exchange::from(exchange_str),
659 currency: Currency::from("USD"), ..Default::default()
661 });
662 }
663
664 if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
666 return Ok(Contract {
667 contract_id: 0,
668 security_type: SecurityType::FuturesOption,
669 exchange: Exchange::from(exchange_str),
670 currency: Currency::from("USD"),
671 local_symbol,
672 ..Default::default()
673 });
674 }
675
676 if let Some(fut) = parse_futures_symbol(symbol_str) {
678 return Ok(Contract {
679 contract_id: 0,
680 security_type: SecurityType::Future,
681 exchange: Exchange::from(exchange_str),
682 currency: Currency::from("USD"),
683 local_symbol: fut.local_symbol,
684 ..Default::default()
685 });
686 }
687 }
688
689 if venue_matches(venue_str.as_str(), VENUES_CFD) {
691 if let Some(captures) =
692 parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
693 {
694 return Ok(Contract {
695 contract_id: 0,
696 symbol: Symbol::from(&captures.base),
697 security_type: SecurityType::CFD,
698 exchange: Exchange::from("SMART"),
699 currency: Currency::from(&captures.quote),
700 local_symbol: format!("{}.{}", captures.base, captures.quote),
701 ..Default::default()
702 });
703 } else {
704 let symbol_clean = symbol_str.replace('-', " ");
706 return Ok(Contract {
707 contract_id: 0,
708 symbol: Symbol::from(&symbol_clean),
709 security_type: SecurityType::CFD,
710 exchange: Exchange::from("SMART"),
711 currency: Currency::from("USD"),
712 ..Default::default()
713 });
714 }
715 }
716
717 if VENUES_CMDTY.contains(&venue_str.as_str()) {
719 let symbol_clean = symbol_str.replace('-', " ");
720 return Ok(Contract {
721 contract_id: 0,
722 symbol: Symbol::from(&symbol_clean),
723 security_type: SecurityType::Commodity,
724 exchange: Exchange::from("SMART"),
725 currency: Currency::from("USD"),
726 ..Default::default()
727 });
728 }
729
730 if let Some(local_symbol) = symbol_str.strip_prefix('^') {
732 return Ok(Contract {
733 contract_id: 0,
734 symbol: Symbol::from(local_symbol),
735 security_type: SecurityType::Index,
736 exchange: Exchange::from(exchange_str),
737 currency: Currency::from("USD"),
738 local_symbol: local_symbol.into(),
739 ..Default::default()
740 });
741 }
742
743 let symbol_clean = symbol_str.replace('-', " ");
745 Ok(Contract {
746 contract_id: 0,
747 symbol: Symbol::from(&symbol_clean),
748 security_type: SecurityType::Stock,
749 exchange: Exchange::from("SMART"),
750 currency: Currency::from(""), primary_exchange: Exchange::from(exchange_str),
752 ..Default::default()
753 })
754}
755
756fn instrument_id_to_ib_contract_raw(
757 instrument_id: &InstrumentId,
758 exchange: Option<&str>,
759) -> Option<Contract> {
760 let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
761
762 let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
763 let exchange_str = exchange.unwrap_or(venue_exchange.as_str());
764 let security_type = IbSecurityType::from_str(sec_type_code)
765 .ok()
766 .map(IbSecurityType::ibapi_security_type)?;
767
768 let contract = match security_type {
769 SecurityType::Stock => Contract {
770 contract_id: 0,
771 security_type,
772 exchange: Exchange::from("SMART"),
773 primary_exchange: Exchange::from(exchange_str),
774 local_symbol: local_symbol.to_string(),
775 ..Default::default()
776 },
777 SecurityType::CFD | SecurityType::Commodity => Contract {
778 contract_id: 0,
779 security_type,
780 exchange: Exchange::from("SMART"),
781 local_symbol: local_symbol.to_string(),
782 ..Default::default()
783 },
784 SecurityType::Index => Contract {
785 contract_id: 0,
786 security_type,
787 exchange: Exchange::from(exchange_str),
788 local_symbol: local_symbol.to_string(),
789 ..Default::default()
790 },
791 _ => Contract {
792 contract_id: 0,
793 security_type,
794 exchange: Exchange::from(exchange_str),
795 local_symbol: local_symbol.to_string(),
796 ..Default::default()
797 },
798 };
799
800 Some(contract)
801}
802
803struct CurrencyPair {
805 base: String,
806 quote: String,
807}
808
809fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
811 if let Some((base, quote)) = symbol.split_once('/')
812 && base.len() == 3
813 && quote.len() == 3
814 {
815 return Some(CurrencyPair {
816 base: base.to_string(),
817 quote: quote.to_string(),
818 });
819 }
820 None
821}
822
823fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
825 if let Some((base, quote)) = symbol.split_once('/')
826 && !base.is_empty()
827 && base.chars().all(|ch| ch.is_ascii_uppercase())
828 && quote.len() == 3
829 && quote.chars().all(|ch| ch.is_ascii_uppercase())
830 {
831 return Some(CurrencyPair {
832 base: base.to_string(),
833 quote: quote.to_string(),
834 });
835 }
836 None
837}
838
839fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
841 if let Some((base, quote)) = symbol.split_once('.')
842 && base.len() == 3
843 && quote.len() == 3
844 {
845 return Some(CurrencyPair {
846 base: base.to_string(),
847 quote: quote.to_string(),
848 });
849 }
850 None
851}
852
853struct OptionSymbol {
855 symbol: String,
856 expiry: String,
857 right: OptionRight,
858 local_symbol: String,
859 strike_value: f64,
860}
861
862fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
864 if symbol.len() < 21 {
867 return None;
868 }
869
870 let symbol_part = symbol[..6.min(symbol.len())].trim();
872 let remaining = &symbol[6.min(symbol.len())..];
873
874 if remaining.len() < 15 {
875 return None;
876 }
877
878 let expiry = &remaining[..6];
879 let right_char = remaining.chars().nth(6)?;
880 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
881
882 let strike_str = &remaining[7..];
883 if strike_str.len() < 8 {
884 return None;
885 }
886
887 let strike_value = if strike_str.contains('.') {
889 strike_str.parse().ok()?
890 } else {
891 let strike_int: i32 = strike_str.parse().ok()?;
893 strike_int as f64 / 1000.0
894 };
895
896 Some(OptionSymbol {
897 symbol: symbol_part.to_string(),
898 expiry: expiry.to_string(),
899 right: ib_option_right_to_option_right(right),
900 local_symbol: symbol.to_string(),
901 strike_value,
902 })
903}
904
905struct NamedOptionSymbol {
907 trading_class: String,
908 expiry: String,
909 right: OptionRight,
910 strike_value: f64,
911}
912
913struct NamedFuturesSymbol {
915 underlying: String,
916 trading_class: String,
917 expiry: String,
918}
919
920fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
921 let parts: Vec<&str> = symbol.split_whitespace().collect();
922 if parts.len() != 3 {
923 return None;
924 }
925
926 let expiry = parts[2];
927 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
928 return None;
929 }
930
931 Some(NamedFuturesSymbol {
932 underlying: parts[0].to_string(),
933 trading_class: parts[1].to_string(),
934 expiry: expiry.to_string(),
935 })
936}
937
938fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
939 let parts: Vec<&str> = symbol.split_whitespace().collect();
940 if !(parts.len() == 4 || parts.len() == 5) {
941 return None;
942 }
943
944 let right = IbOptionRight::from_str(parts[0]).ok()?;
945
946 let expiry = parts[2];
947 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
948 return None;
949 }
950
951 Some(NamedOptionSymbol {
952 trading_class: parts[1].to_string(),
953 expiry: expiry.to_string(),
954 right: ib_option_right_to_option_right(right),
955 strike_value: parts[3].parse::<f64>().ok()?,
956 })
957}
958
959fn normalize_option_symbol(local_symbol: &str) -> String {
960 if local_symbol.len() >= 15 {
961 let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
962 let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
963 && matches!(suffix.chars().nth(6), Some('C' | 'P'))
964 && suffix[7..].chars().all(|c| c.is_ascii_digit());
965
966 if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
967 return format!("{:<6}{}", root.trim_end(), suffix);
968 }
969 }
970
971 local_symbol.to_string()
972}
973
974fn format_option_strike(strike: f64) -> String {
975 if strike.fract() == 0.0 {
976 format!("{strike:.0}")
977 } else {
978 format!("{strike}")
979 }
980}
981
982struct FuturesSymbol {
984 local_symbol: String,
985}
986
987fn parse_futures_underlying(symbol: &str) -> Option<String> {
989 if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
991 Some(symbol.to_string())
992 } else {
993 None
994 }
995}
996
997fn is_futures_month_code(ch: char) -> bool {
998 matches!(
999 ch,
1000 'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
1001 )
1002}
1003
1004fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1005 for (month_pos, month_char) in symbol.char_indices().rev() {
1006 if !is_futures_month_code(month_char) {
1007 continue;
1008 }
1009
1010 let remaining = &symbol[month_pos + month_char.len_utf8()..];
1011 if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
1012 continue;
1013 }
1014
1015 let year = match remaining.len() {
1016 1 | 2 => remaining.to_string(),
1017 4 => remaining[remaining.len() - 2..].to_string(),
1018 _ => continue,
1019 };
1020
1021 if month_pos == 0 {
1022 continue;
1023 }
1024
1025 return Some((month_pos, month_char, year));
1026 }
1027
1028 None
1029}
1030
1031fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
1033 parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
1034 local_symbol: symbol.to_string(),
1035 })
1036}
1037
1038fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
1040 let (futures_symbol, rest) = symbol.split_once(' ')?;
1041 let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
1042 let _fut_symbol = &futures_symbol[..month_pos];
1043
1044 let right_char = rest.chars().next()?;
1046 IbOptionRight::from_str(&right_char.to_string()).ok()?;
1047
1048 let strike_str = &rest[1..];
1049 strike_str.parse::<f64>().ok()?;
1050
1051 Some(symbol.to_string())
1052}
1053
1054#[must_use]
1058pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
1059 let symbol_str = instrument_id.symbol.as_str();
1060 symbol_str.contains('(') && symbol_str.contains('_')
1062}
1063
1064pub fn create_spread_instrument_id(
1080 leg_tuples: &[(InstrumentId, i32)],
1081) -> anyhow::Result<InstrumentId> {
1082 if leg_tuples.len() < 2 {
1083 anyhow::bail!("instrument_ratios list needs to have at least 2 legs");
1084 }
1085
1086 let first_venue = leg_tuples[0].0.venue;
1087
1088 for (instrument_id, ratio) in leg_tuples {
1089 if *ratio == 0 {
1090 anyhow::bail!("ratio cannot be zero");
1091 }
1092
1093 if instrument_id.venue != first_venue {
1094 anyhow::bail!(
1095 "All venues must match. Expected {}, was {}",
1096 first_venue,
1097 instrument_id.venue
1098 );
1099 }
1100 }
1101
1102 let mut sorted_ratios = leg_tuples.to_vec();
1103 sorted_ratios.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1104
1105 let symbol_parts = sorted_ratios
1106 .iter()
1107 .map(|(instrument_id, ratio)| {
1108 if *ratio > 0 {
1109 format!("({}){}", ratio, instrument_id.symbol.as_str())
1110 } else {
1111 format!("(({})){}", ratio.abs(), instrument_id.symbol.as_str())
1112 }
1113 })
1114 .collect::<Vec<_>>();
1115
1116 let composite_symbol = symbol_parts.join("_");
1117 let symbol = NautilusSymbol::from(composite_symbol.as_str());
1118
1119 Ok(InstrumentId::new(symbol, first_venue))
1120}
1121
1122pub fn parse_spread_instrument_id_to_legs(
1134 instrument_id: &InstrumentId,
1135) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
1136 let symbol_str = instrument_id.symbol.as_str();
1137 let venue = instrument_id.venue;
1138
1139 let components: Vec<&str> = symbol_str.split('_').collect();
1140 let mut result = Vec::new();
1141
1142 for component in components {
1143 if component.is_empty() {
1144 continue;
1145 }
1146
1147 if let Some(rest) = component.strip_prefix("((")
1149 && let Some(pos) = rest.find("))")
1150 {
1151 let ratio_str = &rest[..pos];
1152 let symbol_value = &rest[pos + 2..];
1153
1154 if let Ok(ratio) = ratio_str.parse::<i32>() {
1155 let leg_instrument_id =
1156 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1157 result.push((leg_instrument_id, -ratio));
1158 continue;
1159 }
1160 }
1161
1162 if let Some(rest) = component.strip_prefix('(')
1164 && let Some(pos) = rest.find(')')
1165 {
1166 let ratio_str = &rest[..pos];
1167 let symbol_value = &rest[pos + 1..];
1168
1169 if let Ok(ratio) = ratio_str.parse::<i32>() {
1170 let leg_instrument_id =
1171 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1172 result.push((leg_instrument_id, ratio));
1173 continue;
1174 }
1175 }
1176
1177 anyhow::bail!("Invalid spread symbol format for component: {component}");
1178 }
1179
1180 result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1182
1183 Ok(result)
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1189 use nautilus_model::identifiers::InstrumentId;
1190 use rstest::rstest;
1191
1192 use super::{ib_contract_to_instrument_id_simplified, instrument_id_to_ib_contract};
1193
1194 #[rstest]
1195 fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1196 let contract = Contract {
1197 symbol: Symbol::from("SPXW"),
1198 security_type: SecurityType::Option,
1199 exchange: Exchange::from("SMART"),
1200 currency: Currency::from("USD"),
1201 local_symbol: "SPXW260313P06630000".to_string(),
1202 last_trade_date_or_contract_month: "260313".to_string(),
1203 right: Some(OptionRight::Put),
1204 strike: 6630.0,
1205 ..Default::default()
1206 };
1207
1208 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1209
1210 assert_eq!(
1211 instrument_id,
1212 InstrumentId::from("SPXW 260313P06630000.SMART")
1213 );
1214 }
1215
1216 #[rstest]
1217 fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1218 let contract = Contract {
1219 symbol: Symbol::from("OESX"),
1220 security_type: SecurityType::Option,
1221 exchange: Exchange::from("EUREX"),
1222 currency: Currency::from("EUR"),
1223 trading_class: "OESX".to_string(),
1224 local_symbol: String::new(),
1225 last_trade_date_or_contract_month: "20260213".to_string(),
1226 right: Some(OptionRight::Call),
1227 strike: 4775.0,
1228 ..Default::default()
1229 };
1230
1231 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1232
1233 assert_eq!(
1234 instrument_id,
1235 InstrumentId::from("C OESX 20260213 4775.EUREX")
1236 );
1237 }
1238
1239 #[rstest]
1240 fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1241 let contract = Contract {
1242 symbol: Symbol::from("EX2"),
1243 security_type: SecurityType::FuturesOption,
1244 exchange: Exchange::from("NYBOT"),
1245 currency: Currency::from("USD"),
1246 local_symbol: "EX2G3 P4080".to_string(),
1247 ..Default::default()
1248 };
1249
1250 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1251
1252 assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1253 }
1254
1255 #[rstest]
1256 fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1257 let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1258
1259 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1260
1261 assert_eq!(contract.security_type, SecurityType::Option);
1262 assert_eq!(contract.exchange.as_str(), "EUREX");
1263 assert_eq!(contract.symbol.as_str(), "OESX");
1264 assert_eq!(contract.trading_class.as_str(), "OESX");
1265 assert_eq!(
1266 contract.last_trade_date_or_contract_month.as_str(),
1267 "20260213"
1268 );
1269 assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1270 assert_eq!(contract.strike, 4775.0);
1271 }
1272
1273 #[rstest]
1274 fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1275 let instrument_id = InstrumentId::from("AAPL 230217P00155000.SMART");
1276
1277 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1278
1279 assert_eq!(contract.security_type, SecurityType::Option);
1280 assert_eq!(contract.exchange.as_str(), "SMART");
1281 assert_eq!(contract.symbol.as_str(), "AAPL");
1282 assert_eq!(contract.local_symbol.as_str(), "AAPL 230217P00155000");
1283 assert_eq!(
1284 contract.last_trade_date_or_contract_month.as_str(),
1285 "230217"
1286 );
1287 assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1288 assert_eq!(contract.strike, 155.0);
1289 }
1290
1291 #[rstest]
1292 fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1293 let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1294
1295 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1296
1297 assert_eq!(contract.security_type, SecurityType::Future);
1298 assert_eq!(contract.exchange.as_str(), "EUREX");
1299 assert_eq!(contract.symbol.as_str(), "ESTX50");
1300 assert_eq!(contract.trading_class.as_str(), "FESX");
1301 assert_eq!(
1302 contract.last_trade_date_or_contract_month.as_str(),
1303 "20240315"
1304 );
1305 }
1306
1307 #[rstest]
1308 fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1309 let instrument_id = InstrumentId::from("YMM6.XCBT");
1310
1311 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1312
1313 assert_eq!(contract.security_type, SecurityType::Future);
1314 assert_eq!(contract.exchange.as_str(), "CBOT");
1315 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1316 assert!(contract.symbol.as_str().is_empty());
1317 assert!(contract.last_trade_date_or_contract_month.is_empty());
1318 }
1319
1320 #[rstest]
1321 fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1322 let instrument_id = InstrumentId::from("OESXH6.XEUR");
1323
1324 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1325
1326 assert_eq!(contract.security_type, SecurityType::Future);
1327 assert_eq!(contract.exchange.as_str(), "DTB");
1328 assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1329 }
1330
1331 #[rstest]
1332 fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1333 let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1334
1335 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1336
1337 assert_eq!(contract.security_type, SecurityType::FuturesOption);
1338 assert_eq!(contract.exchange.as_str(), "CBOT");
1339 assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1340 }
1341
1342 #[rstest]
1343 fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1344 let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1345
1346 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1347
1348 assert_eq!(contract.security_type, SecurityType::CFD);
1349 assert_eq!(contract.exchange.as_str(), "SMART");
1350 assert_eq!(contract.symbol.as_str(), "EUR");
1351 assert_eq!(contract.currency.as_str(), "USD");
1352 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1353 }
1354
1355 #[rstest]
1356 fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1357 let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1358
1359 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1360
1361 assert_eq!(contract.security_type, SecurityType::Crypto);
1362 assert_eq!(contract.exchange.as_str(), "PAXOS");
1363 assert_eq!(contract.symbol.as_str(), "DOGE");
1364 assert_eq!(contract.currency.as_str(), "USD");
1365 assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1366 }
1367
1368 #[rstest]
1369 fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1370 let instrument_id = InstrumentId::from("ES.XCME");
1371
1372 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1373
1374 assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1375 assert_eq!(contract.exchange.as_str(), "CME");
1376 assert_eq!(contract.symbol.as_str(), "ES");
1377 }
1378
1379 #[rstest]
1380 fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1381 let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1382
1383 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1384
1385 assert_eq!(contract.security_type, SecurityType::Stock);
1386 assert_eq!(contract.exchange.as_str(), "SMART");
1387 assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1388 assert_eq!(contract.local_symbol.as_str(), "AAPL");
1389 assert!(contract.symbol.as_str().is_empty());
1390 }
1391
1392 #[rstest]
1393 fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1394 let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1395
1396 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1397
1398 assert_eq!(contract.security_type, SecurityType::ForexPair);
1399 assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1400 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1401 assert!(contract.symbol.as_str().is_empty());
1402 }
1403
1404 #[rstest]
1405 fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1406 let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1407
1408 let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
1409
1410 assert_eq!(contract.security_type, SecurityType::Future);
1411 assert_eq!(contract.exchange.as_str(), "CBOT");
1412 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1413 }
1414
1415 #[rstest]
1416 fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1417 let instrument_id = InstrumentId::from("IUSA.IBIS");
1418
1419 let contract = instrument_id_to_ib_contract(instrument_id, Some("IBIS")).unwrap();
1420
1421 assert_eq!(contract.security_type, SecurityType::Stock);
1422 assert_eq!(contract.symbol.as_str(), "IUSA");
1423 assert_eq!(contract.exchange.as_str(), "SMART");
1424 assert_eq!(contract.primary_exchange.as_str(), "IBIS");
1425 assert!(contract.currency.as_str().is_empty());
1426 assert!(contract.local_symbol.is_empty());
1427 }
1428}