1use std::{collections::HashMap, sync::LazyLock};
19
20use ibapi::contracts::{Contract, Currency, Exchange, SecurityType, Symbol};
21use nautilus_core::UnixNanos;
22use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, TradeId, Venue};
23
24pub fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
34 let ts_secs = ts_event.as_i64() / 1_000_000_000;
35 TradeId::new(format!("{ts_secs}-{price}-{size}"))
36}
37
38pub fn ib_contract_to_instrument_id_simplified(
57 contract: &Contract,
58 venue: Option<Venue>,
59) -> anyhow::Result<InstrumentId> {
60 let venue = venue.unwrap_or_else(|| {
61 match contract.security_type {
63 SecurityType::Index => {
64 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
65 Venue::from(contract.exchange.as_str())
66 } else {
67 Venue::from("SMART")
68 }
69 }
70 SecurityType::Future => {
71 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
72 Venue::from(contract.exchange.as_str())
73 } else {
74 Venue::from("GLOBEX")
75 }
76 }
77 SecurityType::ForexPair => Venue::from("IDEALPRO"),
78 SecurityType::Crypto => Venue::from("PAXOS"),
79 SecurityType::Stock => Venue::from("SMART"),
80 SecurityType::Option | SecurityType::FuturesOption => {
81 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
82 Venue::from(contract.exchange.as_str())
83 } else {
84 Venue::from("SMART")
85 }
86 }
87 SecurityType::CFD => Venue::from("SMART"),
88 SecurityType::Commodity => Venue::from("SMART"),
89 SecurityType::Bond => Venue::from("SMART"),
90 _ => Venue::from("SMART"),
91 }
92 });
93
94 let symbol = match contract.security_type {
95 SecurityType::Stock => {
96 let symbol_str = if contract.local_symbol.is_empty() {
98 contract.symbol.as_str().to_string()
99 } else {
100 contract.local_symbol.as_str().replace(' ', "-")
101 };
102 NautilusSymbol::from(symbol_str.as_str())
103 }
104 SecurityType::Index => {
105 let base = if contract.local_symbol.is_empty() {
107 contract.symbol.as_str()
108 } else {
109 contract.local_symbol.as_str()
110 };
111 NautilusSymbol::from(format!("^{base}").as_str())
112 }
113 SecurityType::Option => {
114 let symbol_str = if contract.local_symbol.is_empty() {
116 format!(
117 "{} {} {} {}",
118 contract.right.as_str(),
119 contract.trading_class.as_str(),
120 contract.last_trade_date_or_contract_month.as_str(),
121 format_option_strike(contract.strike),
122 )
123 } else {
124 normalize_option_symbol(contract.local_symbol.as_str())
125 };
126 NautilusSymbol::from(symbol_str.as_str())
127 }
128 SecurityType::ForexPair | SecurityType::Crypto => {
129 let symbol_str = if contract.local_symbol.is_empty() {
131 format!(
132 "{}/{}",
133 contract.symbol.as_str(),
134 contract.currency.as_str()
135 )
136 } else {
137 contract.local_symbol.as_str().replace('.', "/")
138 };
139 NautilusSymbol::from(symbol_str.as_str())
140 }
141 SecurityType::Future => {
142 if contract.local_symbol.is_empty() {
144 if !contract.trading_class.is_empty()
145 && !contract.last_trade_date_or_contract_month.is_empty()
146 {
147 let symbol_str = format!(
148 "{} {} {}",
149 contract.symbol.as_str(),
150 contract.trading_class.as_str(),
151 contract.last_trade_date_or_contract_month.as_str()
152 );
153 NautilusSymbol::from(symbol_str.as_str())
154 } else if !contract.last_trade_date_or_contract_month.is_empty() {
155 let expiry = contract.last_trade_date_or_contract_month.as_str();
156 let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
157 NautilusSymbol::from(symbol_str.as_str())
158 } else {
159 NautilusSymbol::from(contract.symbol.as_str())
160 }
161 } else {
162 NautilusSymbol::from(contract.local_symbol.as_str())
163 }
164 }
165 SecurityType::FuturesOption => {
166 if contract.local_symbol.is_empty() {
168 let expiry = contract.last_trade_date_or_contract_month.as_str();
170 let right = if contract.right == "C" { "C" } else { "P" };
171 let strike_str = format!("{}", contract.strike as i64);
172 let symbol_str = format!(
173 "{}{} {}{}",
174 contract.symbol.as_str(),
175 expiry,
176 right,
177 strike_str
178 );
179 NautilusSymbol::from(symbol_str.as_str())
180 } else {
181 let cleaned = contract.local_symbol.as_str().replace(' ', "");
182 NautilusSymbol::from(cleaned.as_str())
183 }
184 }
185 SecurityType::CFD => {
186 if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
188 let cash_like = contract.local_symbol.as_str().replace('.', "/");
189 NautilusSymbol::from(cash_like.as_str())
190 } else {
191 let symbol_str = contract.symbol.as_str().replace(' ', "-");
192 NautilusSymbol::from(symbol_str.as_str())
193 }
194 }
195 SecurityType::Commodity => {
196 let symbol_str = contract.symbol.as_str().replace(' ', "-");
198 NautilusSymbol::from(symbol_str.as_str())
199 }
200 SecurityType::Bond => {
201 let symbol_str = if contract.local_symbol.is_empty() {
203 contract.symbol.as_str()
204 } else {
205 contract.local_symbol.as_str()
206 };
207 NautilusSymbol::from(symbol_str)
208 }
209 _ => {
210 let symbol_str = if contract.local_symbol.is_empty() {
212 contract.symbol.as_str()
213 } else {
214 contract.local_symbol.as_str()
215 };
216 NautilusSymbol::from(symbol_str)
217 }
218 };
219
220 Ok(InstrumentId::new(symbol, venue))
221}
222
223pub fn ib_contract_to_instrument_id_raw(
240 contract: &Contract,
241 venue: Option<Venue>,
242) -> anyhow::Result<InstrumentId> {
243 let venue = venue.unwrap_or_else(|| match contract.security_type {
244 SecurityType::ForexPair => Venue::from("IDEALPRO"),
245 SecurityType::Crypto => Venue::from("PAXOS"),
246 SecurityType::Stock => Venue::from("SMART"),
247 SecurityType::Option => Venue::from("SMART"),
248 SecurityType::FuturesOption => Venue::from("SMART"),
249 SecurityType::Future => Venue::from("GLOBEX"),
250 SecurityType::Index => Venue::from("SMART"),
251 SecurityType::CFD => Venue::from("SMART"),
252 SecurityType::Commodity => Venue::from("SMART"),
253 SecurityType::Bond => Venue::from("SMART"),
254 _ => Venue::from("SMART"),
255 });
256
257 let local_symbol = if contract.local_symbol.is_empty() {
258 contract.symbol.as_str()
259 } else {
260 contract.local_symbol.as_str()
261 };
262
263 let sec_type_str = match contract.security_type {
264 SecurityType::Stock => "STK",
265 SecurityType::Option => "OPT",
266 SecurityType::Future => "FUT",
267 SecurityType::FuturesOption => "FOP",
268 SecurityType::ForexPair => "CASH",
269 SecurityType::Crypto => "CRYPTO",
270 SecurityType::Index => "IND",
271 SecurityType::CFD => "CFD",
272 SecurityType::Commodity => "CMDTY",
273 SecurityType::Bond => "BOND",
274 _ => "OTHER",
275 };
276
277 let symbol_str = format!("{local_symbol}={sec_type_str}");
278 let symbol = NautilusSymbol::from(symbol_str.as_str());
279 Ok(InstrumentId::new(symbol, venue))
280}
281
282pub fn ib_contract_to_instrument_id_simple(contract: &Contract) -> anyhow::Result<InstrumentId> {
291 ib_contract_to_instrument_id_simplified(contract, None)
292}
293
294pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
297 LazyLock::new(|| {
298 let mut map = HashMap::new();
299 map.insert("NDEX", vec!["ENDEX"]);
301 map.insert("XCME", vec!["CME"]);
303 map.insert("XCEC", vec!["CME"]);
304 map.insert("XFXS", vec!["CME"]);
305 map.insert("XCBT", vec!["CBOT"]);
307 map.insert("CBCM", vec!["CBOT"]);
308 map.insert("XNYM", vec!["NYMEX"]);
310 map.insert("NYUM", vec!["NYMEX"]);
311 map.insert("IFUS", vec!["NYBOT"]);
313 map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
315 map.insert("XNAS", vec!["NASDAQ"]);
317 map.insert("XNYS", vec!["NYSE"]);
318 map.insert("ARCX", vec!["ARCA"]);
319 map.insert("BATS", vec!["BATS"]);
320 map.insert("IEXG", vec!["IEX"]);
321 map.insert("XCBO", vec!["CBOE"]);
322 map.insert("XCBF", vec!["CFE"]);
323 map.insert("XTSE", vec!["TSX"]);
325 map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
327 map.insert("XLON", vec!["LSE"]);
329 map.insert("XPAR", vec!["SBF"]);
330 map.insert("XETR", vec!["IBIS"]);
331 map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
332 map.insert("XAMS", vec!["AEB"]);
333 map.insert("XBRU", vec!["EBS"]);
334 map.insert("XBRD", vec!["BELFOX"]);
335 map.insert("XLIS", vec!["BVLP"]);
336 map.insert("XDUB", vec!["IRE"]);
337 map.insert("XOSL", vec!["OSL"]);
338 map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
339 map.insert("XSVX", vec!["VRTX"]);
340 map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
341 map.insert("XMAD", vec!["MDRD", "BME"]);
342 map.insert("DXEX", vec!["BATEEN"]);
343 map.insert("XWBO", vec!["WBAG"]);
344 map.insert("XBUD", vec!["BUX"]);
345 map.insert("XPRA", vec!["PRA"]);
346 map.insert("XWAR", vec!["WSE"]);
347 map.insert("XIST", vec!["ISE"]);
348 map.insert("XSTO", vec!["SFB"]);
350 map.insert("XCSE", vec!["KFB"]);
351 map.insert("XHEL", vec!["HMB"]);
352 map.insert("XICE", vec!["ISB"]);
353 map.insert("XASX", vec!["ASX"]);
355 map.insert("XHKG", vec!["SEHK"]);
356 map.insert("XHKF", vec!["HKFE"]);
357 map.insert("XSES", vec!["SGX"]);
358 map.insert("XOSE", vec!["OSE.JPN"]);
359 map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
360 map.insert("XKRX", vec!["KSE", "KRX"]);
361 map.insert("XTAI", vec!["TASE", "TWSE"]);
362 map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
363 map.insert("XSHE", vec!["SEHKSZSE"]);
364 map.insert("XNSE", vec!["NSE"]);
365 map.insert("XBOM", vec!["BSE"]);
366 map.insert("XSFE", vec!["SNFE"]);
368 map.insert("XMEX", vec!["MEXDER"]);
369 map.insert("XJSE", vec!["JSE"]);
371 map.insert("XBOG", vec!["BVC"]);
372 map.insert("XTAE", vec!["TASE"]);
373 map.insert("BVMF", vec!["BVMF"]);
374 map
375 });
376
377#[must_use]
378pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
379 if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
380 return exchanges
381 .iter()
382 .map(|exchange| (*exchange).to_string())
383 .collect();
384 }
385
386 vec![venue.to_string()]
387}
388
389const VENUES_CASH: &[&str] = &["IDEALPRO"];
391const VENUES_CRYPTO: &[&str] = &["PAXOS"];
392const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
393const VENUES_FUT: &[&str] = &[
394 "GLOBEX",
395 "NYMEX",
396 "NYBOT",
397 "CBOT",
398 "CME",
399 "CFE",
400 "ICE",
401 "ECBOT",
402 "CBOE",
403 "CMECRYPTO",
404 "NYMEXMETALS",
405 "NYMEXNG",
406 "NYMEXENERGY",
407 "CMEPRECIOUS",
408 "CMECURRENCY",
409 "CMEINDEX",
410 "CMEWEATHER",
411 "CMEINTEREST",
412 "CMEFLOOR",
413 "CBOTFLOOR",
414 "NYMEXFLOOR",
415 "NYBOTFLOOR",
416 "CFEFLOOR",
417 "CMEOPTIONS",
418 "CBOTOPTIONS",
419 "NYMEXOPTIONS",
420 "NYBOTOPTIONS",
421];
422const VENUES_CFD: &[&str] = &["SMART"];
423const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
424
425fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
426 venues.contains(&venue_str)
427 || VENUE_MEMBERS
428 .get(venue_str)
429 .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
430}
431
432#[allow(dead_code)]
435const FUTURES_MONTH_CODES: &[(char, &str)] = &[
436 ('F', "01"),
437 ('G', "02"),
438 ('H', "03"),
439 ('J', "04"),
440 ('K', "05"),
441 ('M', "06"),
442 ('N', "07"),
443 ('Q', "08"),
444 ('U', "09"),
445 ('V', "10"),
446 ('X', "11"),
447 ('Z', "12"),
448];
449
450pub fn determine_venue_from_contract(
467 contract: &Contract,
468 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
469 convert_exchange_to_mic_venue: bool,
470 valid_exchanges: Option<&str>,
471) -> String {
472 if matches!(contract.security_type, SecurityType::CFD) {
473 return "IBCFD".to_string();
474 }
475
476 if matches!(contract.security_type, SecurityType::Commodity) {
477 return "IBCMDTY".to_string();
478 }
479
480 if !symbol_to_mic_venue.is_empty() {
481 let symbol = contract.symbol.as_str();
482 for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
483 if symbol.starts_with(symbol_prefix) {
484 return symbol_venue.clone();
485 }
486 }
487 }
488
489 let mut exchange = if contract.exchange.as_str() == "SMART"
491 && !contract.primary_exchange.as_str().is_empty()
492 && contract.primary_exchange.as_str() != "SMART"
493 {
494 contract.primary_exchange.as_str().to_string()
495 } else {
496 contract.exchange.as_str().to_string()
497 };
498
499 if exchange == "SMART"
500 && let Some(valid_exchanges) = valid_exchanges
501 {
502 let parts: Vec<&str> = valid_exchanges
503 .split(',')
504 .map(str::trim)
505 .filter(|part| !part.is_empty())
506 .collect();
507
508 if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
509 exchange = (*chosen).to_string();
510 } else if let Some(first) = parts.first() {
511 exchange = (*first).to_string();
512 }
513 }
514
515 if convert_exchange_to_mic_venue {
516 for (venue_member, exchanges) in VENUE_MEMBERS.iter() {
517 if exchanges.iter().any(|candidate| *candidate == exchange) {
518 return (*venue_member).to_string();
519 }
520 }
521 }
522
523 exchange
524}
525
526pub fn instrument_id_to_ib_contract(
549 instrument_id: InstrumentId,
550 exchange: Option<&str>,
551) -> anyhow::Result<Contract> {
552 let venue_str = instrument_id.venue.to_string();
553 let derived_exchange = VENUE_MEMBERS
554 .get(venue_str.as_str())
555 .and_then(|exchanges| exchanges.first().copied())
556 .or_else(|| {
557 if venue_matches(venue_str.as_str(), VENUES_CASH)
558 || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
559 || venue_matches(venue_str.as_str(), VENUES_OPT)
560 || venue_matches(venue_str.as_str(), VENUES_FUT)
561 {
562 Some(venue_str.as_str())
563 } else {
564 None
565 }
566 })
567 .unwrap_or("SMART");
568 let exchange_str = exchange.unwrap_or(derived_exchange);
569 let symbol_str = instrument_id.symbol.as_str();
570
571 if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
572 return Ok(contract);
573 }
574
575 if venue_matches(venue_str.as_str(), VENUES_CASH)
583 && let Some(captures) = parse_cash_symbol(symbol_str)
584 {
585 return Ok(Contract {
586 contract_id: 0,
587 symbol: Symbol::from(&captures.base),
588 security_type: SecurityType::ForexPair,
589 exchange: Exchange::from(exchange_str),
590 currency: Currency::from(&captures.quote),
591 local_symbol: format!("{}.{}", captures.base, captures.quote),
592 ..Default::default()
593 });
594 }
595
596 if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
598 && let Some(captures) = parse_cash_symbol(symbol_str)
599 {
600 return Ok(Contract {
601 contract_id: 0,
602 symbol: Symbol::from(&captures.base),
603 security_type: SecurityType::Crypto,
604 exchange: Exchange::from(exchange_str),
605 currency: Currency::from(&captures.quote),
606 local_symbol: format!("{}.{}", captures.base, captures.quote),
607 ..Default::default()
608 });
609 }
610
611 if venue_matches(venue_str.as_str(), VENUES_OPT) {
613 if let Some(opt) = parse_option_symbol(symbol_str) {
614 let local_symbol = format!(
615 "{:6}{}{}{}{:08}",
616 opt.symbol, opt.expiry, opt.right, opt.strike_integer, opt.strike_decimal
617 );
618 return Ok(Contract {
619 contract_id: 0,
620 symbol: Symbol::from(&opt.symbol),
621 security_type: SecurityType::Option,
622 exchange: Exchange::from(exchange_str),
623 currency: Currency::from("USD"), local_symbol,
625 last_trade_date_or_contract_month: opt.expiry,
626 strike: opt.strike_value,
627 right: opt.right,
628 ..Default::default()
629 });
630 }
631
632 if let Some(opt) = parse_named_option_symbol(symbol_str) {
633 return Ok(Contract {
634 contract_id: 0,
635 symbol: Symbol::from(&opt.trading_class),
636 security_type: SecurityType::Option,
637 exchange: Exchange::from(exchange_str),
638 currency: Currency::from("USD"),
639 trading_class: opt.trading_class,
640 last_trade_date_or_contract_month: opt.expiry,
641 strike: opt.strike_value,
642 right: opt.right,
643 ..Default::default()
644 });
645 }
646 }
647
648 if venue_matches(venue_str.as_str(), VENUES_FUT) {
650 if let Some(underlying) = parse_futures_underlying(symbol_str) {
653 return Ok(Contract {
654 contract_id: 0,
655 symbol: Symbol::from(&underlying),
656 security_type: SecurityType::ContinuousFuture,
657 exchange: Exchange::from(exchange_str),
658 currency: Currency::from("USD"), ..Default::default()
660 });
661 }
662
663 if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
665 return Ok(Contract {
666 contract_id: 0,
667 security_type: SecurityType::FuturesOption,
668 exchange: Exchange::from(exchange_str),
669 currency: Currency::from("USD"),
670 local_symbol,
671 ..Default::default()
672 });
673 }
674
675 if let Some(fut) = parse_futures_symbol(symbol_str) {
677 return Ok(Contract {
678 contract_id: 0,
679 security_type: SecurityType::Future,
680 exchange: Exchange::from(exchange_str),
681 currency: Currency::from("USD"),
682 local_symbol: fut.local_symbol,
683 ..Default::default()
684 });
685 }
686 }
687
688 if VENUES_CFD.contains(&venue_str.as_str()) {
690 if let Some(captures) = parse_cfd_cash_symbol(symbol_str) {
691 return Ok(Contract {
692 contract_id: 0,
693 symbol: Symbol::from(&captures.base),
694 security_type: SecurityType::CFD,
695 exchange: Exchange::from("SMART"),
696 currency: Currency::from(&captures.quote),
697 local_symbol: format!("{}.{}", captures.base, captures.quote),
698 ..Default::default()
699 });
700 } else {
701 let symbol_clean = symbol_str.replace('-', " ");
703 return Ok(Contract {
704 contract_id: 0,
705 symbol: Symbol::from(&symbol_clean),
706 security_type: SecurityType::CFD,
707 exchange: Exchange::from("SMART"),
708 currency: Currency::from("USD"),
709 ..Default::default()
710 });
711 }
712 }
713
714 if VENUES_CMDTY.contains(&venue_str.as_str()) {
716 let symbol_clean = symbol_str.replace('-', " ");
717 return Ok(Contract {
718 contract_id: 0,
719 symbol: Symbol::from(&symbol_clean),
720 security_type: SecurityType::Commodity,
721 exchange: Exchange::from("SMART"),
722 currency: Currency::from("USD"),
723 ..Default::default()
724 });
725 }
726
727 if let Some(local_symbol) = symbol_str.strip_prefix('^') {
729 return Ok(Contract {
730 contract_id: 0,
731 symbol: Symbol::from(local_symbol),
732 security_type: SecurityType::Index,
733 exchange: Exchange::from(exchange_str),
734 currency: Currency::from("USD"),
735 local_symbol: local_symbol.into(),
736 ..Default::default()
737 });
738 }
739
740 let symbol_clean = symbol_str.replace('-', " ");
742 Ok(Contract {
743 contract_id: 0,
744 symbol: Symbol::from(&symbol_clean),
745 security_type: SecurityType::Stock,
746 exchange: Exchange::from("SMART"),
747 currency: Currency::from("USD"), primary_exchange: Exchange::from(exchange_str),
749 local_symbol: symbol_clean,
750 ..Default::default()
751 })
752}
753
754fn instrument_id_to_ib_contract_raw(
755 instrument_id: &InstrumentId,
756 exchange: Option<&str>,
757) -> Option<Contract> {
758 let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
759
760 let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
761 let exchange_str = exchange.unwrap_or(venue_exchange.as_str());
762 let security_type = match sec_type_code {
763 "STK" => SecurityType::Stock,
764 "OPT" => SecurityType::Option,
765 "FUT" => SecurityType::Future,
766 "FOP" => SecurityType::FuturesOption,
767 "CASH" => SecurityType::ForexPair,
768 "CRYPTO" => SecurityType::Crypto,
769 "CONTFUT" => SecurityType::ContinuousFuture,
770 "IND" => SecurityType::Index,
771 "CFD" => SecurityType::CFD,
772 "CMDTY" => SecurityType::Commodity,
773 "BOND" => SecurityType::Bond,
774 _ => return None,
775 };
776
777 let contract = match security_type {
778 SecurityType::Stock => Contract {
779 contract_id: 0,
780 security_type,
781 exchange: Exchange::from("SMART"),
782 primary_exchange: Exchange::from(exchange_str),
783 local_symbol: local_symbol.to_string(),
784 ..Default::default()
785 },
786 SecurityType::CFD | SecurityType::Commodity => Contract {
787 contract_id: 0,
788 security_type,
789 exchange: Exchange::from("SMART"),
790 local_symbol: local_symbol.to_string(),
791 ..Default::default()
792 },
793 SecurityType::Index => Contract {
794 contract_id: 0,
795 security_type,
796 exchange: Exchange::from(exchange_str),
797 local_symbol: local_symbol.to_string(),
798 ..Default::default()
799 },
800 _ => Contract {
801 contract_id: 0,
802 security_type,
803 exchange: Exchange::from(exchange_str),
804 local_symbol: local_symbol.to_string(),
805 ..Default::default()
806 },
807 };
808
809 Some(contract)
810}
811
812struct CurrencyPair {
814 base: String,
815 quote: String,
816}
817
818fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
820 if let Some((base, quote)) = symbol.split_once('/')
821 && base.len() == 3
822 && quote.len() == 3
823 {
824 return Some(CurrencyPair {
825 base: base.to_string(),
826 quote: quote.to_string(),
827 });
828 }
829 None
830}
831
832fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
834 if let Some((base, quote)) = symbol.split_once('.')
835 && base.len() == 3
836 && quote.len() == 3
837 {
838 return Some(CurrencyPair {
839 base: base.to_string(),
840 quote: quote.to_string(),
841 });
842 }
843 None
844}
845
846struct OptionSymbol {
848 symbol: String,
849 expiry: String,
850 right: String,
851 strike_integer: String,
852 strike_decimal: String,
853 strike_value: f64,
854}
855
856fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
858 if symbol.len() < 21 {
861 return None;
862 }
863
864 let symbol_part = &symbol[..6.min(symbol.len())].trim();
866 let remaining = &symbol[6.min(symbol.len())..];
867
868 if remaining.len() < 15 {
869 return None;
870 }
871
872 let expiry = &remaining[..6];
873 let right_char = remaining.chars().nth(6)?;
874 let right = if right_char == 'C' || right_char == 'c' {
875 "C"
876 } else if right_char == 'P' || right_char == 'p' {
877 "P"
878 } else {
879 return None;
880 };
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 let strike_integer = if strike_str.len() >= 8 {
897 &strike_str[..strike_str.len().min(8)]
898 } else {
899 strike_str
900 };
901 let strike_decimal = if strike_str.len() > 8 {
902 &strike_str[8..]
903 } else {
904 ""
905 };
906
907 Some(OptionSymbol {
908 symbol: (*symbol_part).to_string(),
909 expiry: expiry.to_string(),
910 right: right.to_string(),
911 strike_integer: strike_integer.to_string(),
912 strike_decimal: strike_decimal.to_string(),
913 strike_value,
914 })
915}
916
917struct NamedOptionSymbol {
919 trading_class: String,
920 expiry: String,
921 right: String,
922 strike_value: f64,
923}
924
925fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
926 let parts: Vec<&str> = symbol.split_whitespace().collect();
927 if !(parts.len() == 4 || parts.len() == 5) {
928 return None;
929 }
930
931 let right = match parts[0] {
932 "C" | "c" => "C",
933 "P" | "p" => "P",
934 _ => return None,
935 };
936
937 let expiry = parts[2];
938 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
939 return None;
940 }
941
942 Some(NamedOptionSymbol {
943 trading_class: parts[1].to_string(),
944 expiry: expiry.to_string(),
945 right: right.to_string(),
946 strike_value: parts[3].parse::<f64>().ok()?,
947 })
948}
949
950fn normalize_option_symbol(local_symbol: &str) -> String {
951 if local_symbol.len() >= 15 {
952 let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
953 let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
954 && matches!(suffix.chars().nth(6), Some('C' | 'P'))
955 && suffix[7..].chars().all(|c| c.is_ascii_digit());
956
957 if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
958 return format!("{:<6}{}", root.trim_end(), suffix);
959 }
960 }
961
962 local_symbol.to_string()
963}
964
965fn format_option_strike(strike: f64) -> String {
966 if strike.fract() == 0.0 {
967 format!("{strike:.0}")
968 } else {
969 format!("{strike}")
970 }
971}
972
973struct FuturesSymbol {
975 local_symbol: String,
976}
977
978fn parse_futures_underlying(symbol: &str) -> Option<String> {
980 if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
982 Some(symbol.to_string())
983 } else {
984 None
985 }
986}
987
988fn is_futures_month_code(ch: char) -> bool {
989 matches!(
990 ch,
991 'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
992 )
993}
994
995fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
996 for (month_pos, month_char) in symbol.char_indices().rev() {
997 if !is_futures_month_code(month_char) {
998 continue;
999 }
1000
1001 let remaining = &symbol[month_pos + month_char.len_utf8()..];
1002 if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
1003 continue;
1004 }
1005
1006 let year = match remaining.len() {
1007 1 | 2 => remaining.to_string(),
1008 4 => remaining[remaining.len() - 2..].to_string(),
1009 _ => continue,
1010 };
1011
1012 if month_pos == 0 {
1013 continue;
1014 }
1015
1016 return Some((month_pos, month_char, year));
1017 }
1018
1019 None
1020}
1021
1022fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
1024 parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
1025 local_symbol: symbol.to_string(),
1026 })
1027}
1028
1029fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
1031 let (futures_symbol, rest) = symbol.split_once(' ')?;
1032 let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
1033 let _fut_symbol = &futures_symbol[..month_pos];
1034
1035 let right_char = rest.chars().next()?;
1037 if right_char != 'C' && right_char != 'c' && right_char != 'P' && right_char != 'p' {
1038 return None;
1039 }
1040
1041 let strike_str = &rest[1..];
1042 strike_str.parse::<f64>().ok()?;
1043
1044 Some(symbol.to_string())
1045}
1046
1047#[must_use]
1059pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
1060 let symbol_str = instrument_id.symbol.as_str();
1061 symbol_str.contains('(') && symbol_str.contains('_')
1063}
1064
1065pub fn parse_spread_instrument_id_to_legs(
1085 instrument_id: &InstrumentId,
1086) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
1087 let symbol_str = instrument_id.symbol.as_str();
1088 let venue = instrument_id.venue;
1089
1090 let components: Vec<&str> = symbol_str.split('_').collect();
1092 let mut result = Vec::new();
1093
1094 for component in components {
1098 if component.is_empty() {
1099 continue;
1100 }
1101
1102 if let Some(rest) = component.strip_prefix("((")
1104 && let Some(pos) = rest.find("))")
1105 {
1106 let ratio_str = &rest[..pos];
1107 let symbol_value = &rest[pos + 2..];
1108
1109 if let Ok(ratio) = ratio_str.parse::<i32>() {
1110 let leg_instrument_id =
1111 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1112 result.push((leg_instrument_id, -ratio));
1113 continue;
1114 }
1115 }
1116
1117 if let Some(rest) = component.strip_prefix('(')
1119 && let Some(pos) = rest.find(')')
1120 {
1121 let ratio_str = &rest[..pos];
1122 let symbol_value = &rest[pos + 1..];
1123
1124 if let Ok(ratio) = ratio_str.parse::<i32>() {
1125 let leg_instrument_id =
1126 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1127 result.push((leg_instrument_id, ratio));
1128 continue;
1129 }
1130 }
1131
1132 anyhow::bail!("Invalid spread symbol format for component: {component}");
1133 }
1134
1135 result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1137
1138 Ok(result)
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use ibapi::contracts::{Contract, Currency, Exchange, SecurityType, Symbol};
1144 use nautilus_model::identifiers::InstrumentId;
1145 use rstest::rstest;
1146
1147 use super::{ib_contract_to_instrument_id_simplified, instrument_id_to_ib_contract};
1148
1149 #[rstest]
1150 fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1151 let contract = Contract {
1152 symbol: Symbol::from("SPXW"),
1153 security_type: SecurityType::Option,
1154 exchange: Exchange::from("SMART"),
1155 currency: Currency::from("USD"),
1156 local_symbol: "SPXW260313P06630000".to_string(),
1157 last_trade_date_or_contract_month: "260313".to_string(),
1158 right: "P".to_string(),
1159 strike: 6630.0,
1160 ..Default::default()
1161 };
1162
1163 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1164
1165 assert_eq!(
1166 instrument_id,
1167 InstrumentId::from("SPXW 260313P06630000.SMART")
1168 );
1169 }
1170
1171 #[rstest]
1172 fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1173 let contract = Contract {
1174 symbol: Symbol::from("OESX"),
1175 security_type: SecurityType::Option,
1176 exchange: Exchange::from("EUREX"),
1177 currency: Currency::from("EUR"),
1178 trading_class: "OESX".to_string(),
1179 local_symbol: String::new(),
1180 last_trade_date_or_contract_month: "20260213".to_string(),
1181 right: "C".to_string(),
1182 strike: 4775.0,
1183 ..Default::default()
1184 };
1185
1186 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1187
1188 assert_eq!(
1189 instrument_id,
1190 InstrumentId::from("C OESX 20260213 4775.EUREX")
1191 );
1192 }
1193
1194 #[rstest]
1195 fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1196 let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1197
1198 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1199
1200 assert_eq!(contract.security_type, SecurityType::Option);
1201 assert_eq!(contract.exchange.as_str(), "EUREX");
1202 assert_eq!(contract.symbol.as_str(), "OESX");
1203 assert_eq!(contract.trading_class.as_str(), "OESX");
1204 assert_eq!(
1205 contract.last_trade_date_or_contract_month.as_str(),
1206 "20260213"
1207 );
1208 assert_eq!(contract.right.as_str(), "C");
1209 assert_eq!(contract.strike, 4775.0);
1210 }
1211
1212 #[rstest]
1213 fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1214 let instrument_id = InstrumentId::from("YMM6.XCBT");
1215
1216 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1217
1218 assert_eq!(contract.security_type, SecurityType::Future);
1219 assert_eq!(contract.exchange.as_str(), "CBOT");
1220 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1221 assert!(contract.symbol.as_str().is_empty());
1222 assert!(contract.last_trade_date_or_contract_month.is_empty());
1223 }
1224
1225 #[rstest]
1226 fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1227 let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1228
1229 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1230
1231 assert_eq!(contract.security_type, SecurityType::FuturesOption);
1232 assert_eq!(contract.exchange.as_str(), "CBOT");
1233 assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1234 }
1235
1236 #[rstest]
1237 fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1238 let instrument_id = InstrumentId::from("ES.XCME");
1239
1240 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1241
1242 assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1243 assert_eq!(contract.exchange.as_str(), "CME");
1244 assert_eq!(contract.symbol.as_str(), "ES");
1245 }
1246
1247 #[rstest]
1248 fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1249 let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1250
1251 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1252
1253 assert_eq!(contract.security_type, SecurityType::Stock);
1254 assert_eq!(contract.exchange.as_str(), "SMART");
1255 assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1256 assert_eq!(contract.local_symbol.as_str(), "AAPL");
1257 assert!(contract.symbol.as_str().is_empty());
1258 }
1259
1260 #[rstest]
1261 fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1262 let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1263
1264 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1265
1266 assert_eq!(contract.security_type, SecurityType::ForexPair);
1267 assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1268 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1269 assert!(contract.symbol.as_str().is_empty());
1270 }
1271
1272 #[rstest]
1273 fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1274 let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1275
1276 let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
1277
1278 assert_eq!(contract.security_type, SecurityType::Future);
1279 assert_eq!(contract.exchange.as_str(), "CBOT");
1280 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1281 }
1282}