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 => derive_crypto_venue(contract),
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 => derive_crypto_venue(contract),
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]
366pub fn is_crypto_contract(contract: &Contract) -> bool {
367 matches!(contract.security_type, SecurityType::Crypto)
368}
369
370#[must_use]
376pub fn derive_crypto_venue(contract: &Contract) -> Venue {
377 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
378 Venue::from(contract.exchange.as_str())
379 } else {
380 Venue::from("PAXOS")
381 }
382}
383
384#[must_use]
385pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
386 if venue == "OPRA" {
387 return vec!["SMART".to_string()];
388 }
389
390 if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
391 return exchanges
392 .iter()
393 .map(|exchange| (*exchange).to_string())
394 .collect();
395 }
396
397 vec![venue.to_string()]
398}
399
400const VENUES_CASH: &[&str] = &["IDEALPRO"];
402const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
405const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
406const VENUES_FUT: &[&str] = &[
407 "BELFOX",
408 "GLOBEX",
409 "CBOT",
410 "CFE",
411 "CME",
412 "COMEX",
413 "CBOE",
414 "DTB",
415 "EUREX",
416 "HKFE",
417 "ICE",
418 "ICEEU",
419 "ICEEUSOFT",
420 "IDEM",
421 "IPE",
422 "KCBT",
423 "MEXDER",
424 "MGE",
425 "NYBOT",
426 "NYMEX",
427 "OSE.JPN",
428 "SNFE",
429 "SOFFEX",
430 "VRTX",
431 "CMECRYPTO",
432 "NYMEXMETALS",
433 "NYMEXNG",
434 "NYMEXENERGY",
435 "CMEPRECIOUS",
436 "CMECURRENCY",
437 "CMEINDEX",
438 "CMEWEATHER",
439 "CMEINTEREST",
440 "CMEFLOOR",
441 "CBOTFLOOR",
442 "NYMEXFLOOR",
443 "NYBOTFLOOR",
444 "CFEFLOOR",
445 "CMEOPTIONS",
446 "CBOTOPTIONS",
447 "NYMEXOPTIONS",
448 "NYBOTOPTIONS",
449 "ECBOT",
450];
451const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
452const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
453
454fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
455 venues.contains(&venue_str)
456 || VENUE_MEMBERS
457 .get(venue_str)
458 .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
459}
460
461fn is_option_venue(venue: &str) -> bool {
462 venue == "OPRA" || venue_matches(venue, VENUES_OPT)
463}
464
465fn is_canonical_occ_option_symbol(symbol: &str) -> bool {
466 let bytes = symbol.as_bytes();
467 if bytes.len() != 21 || !symbol.is_ascii() {
468 return false;
469 }
470
471 let root = &bytes[..6];
472 let root_len = root.iter().position(|byte| *byte == b' ').unwrap_or(6);
473
474 root_len > 0
475 && root[..root_len].iter().all(u8::is_ascii_graphic)
476 && root[root_len..].iter().all(|byte| *byte == b' ')
477 && bytes[6..12].iter().all(u8::is_ascii_digit)
478 && matches!(bytes[12], b'C' | b'P')
479 && bytes[13..].iter().all(u8::is_ascii_digit)
480}
481
482#[allow(dead_code)]
485const FUTURES_MONTH_CODES: &[(char, &str)] = &[
486 ('F', "01"),
487 ('G', "02"),
488 ('H', "03"),
489 ('J', "04"),
490 ('K', "05"),
491 ('M', "06"),
492 ('N', "07"),
493 ('Q', "08"),
494 ('U', "09"),
495 ('V', "10"),
496 ('X', "11"),
497 ('Z', "12"),
498];
499
500pub fn determine_venue_from_contract(
507 contract: &Contract,
508 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
509 convert_exchange_to_mic_venue: bool,
510 valid_exchanges: Option<&str>,
511) -> String {
512 if matches!(contract.security_type, SecurityType::CFD) {
513 return "IBCFD".to_string();
514 }
515
516 if matches!(contract.security_type, SecurityType::Commodity) {
517 return "IBCMDTY".to_string();
518 }
519
520 if !symbol_to_mic_venue.is_empty() {
521 let symbol = contract.symbol.as_str();
522 for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
523 if symbol.starts_with(symbol_prefix) {
524 return symbol_venue.clone();
525 }
526 }
527 }
528
529 let mut exchange = if contract.exchange.as_str() == "SMART"
531 && !contract.primary_exchange.as_str().is_empty()
532 && contract.primary_exchange.as_str() != "SMART"
533 {
534 contract.primary_exchange.as_str().to_string()
535 } else {
536 contract.exchange.as_str().to_string()
537 };
538
539 if exchange == "SMART"
540 && let Some(valid_exchanges) = valid_exchanges
541 {
542 let parts: Vec<&str> = valid_exchanges
543 .split(',')
544 .map(str::trim)
545 .filter(|part| !part.is_empty())
546 .collect();
547
548 if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
549 exchange = (*chosen).to_string();
550 } else if let Some(first) = parts.first() {
551 exchange = (*first).to_string();
552 }
553 }
554
555 if convert_exchange_to_mic_venue {
556 if let Some(venue) = exchange_to_mic_venue(&exchange) {
557 return venue;
558 }
559 }
560
561 exchange
562}
563
564#[must_use]
566pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
567 VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
568 exchanges
569 .contains(&exchange)
570 .then(|| (*venue_member).to_string())
571 })
572}
573
574pub fn instrument_id_to_ib_contract(
592 instrument_id: InstrumentId,
593 exchange: Option<&str>,
594) -> anyhow::Result<Contract> {
595 let venue_str = instrument_id.venue.to_string();
596 let derived_exchange = if venue_str == "OPRA" {
597 "SMART"
598 } else {
599 VENUE_MEMBERS
600 .get(venue_str.as_str())
601 .and_then(|exchanges| exchanges.first().copied())
602 .or_else(|| {
603 if venue_matches(venue_str.as_str(), VENUES_CASH)
604 || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
605 || venue_matches(venue_str.as_str(), VENUES_OPT)
606 || venue_matches(venue_str.as_str(), VENUES_FUT)
607 {
608 Some(venue_str.as_str())
609 } else {
610 None
611 }
612 })
613 .unwrap_or("SMART")
614 };
615 let exchange_str = exchange.unwrap_or(derived_exchange);
616 let symbol_str = instrument_id.symbol.as_str();
617
618 if let Some(contract) = instrument_id_to_ib_contract_raw(&instrument_id, exchange) {
619 return Ok(contract);
620 }
621
622 if venue_matches(venue_str.as_str(), VENUES_CASH)
630 && let Some(captures) = parse_cash_symbol(symbol_str)
631 {
632 return Ok(Contract {
633 contract_id: 0,
634 symbol: Symbol::from(&captures.base),
635 security_type: SecurityType::ForexPair,
636 exchange: Exchange::from(exchange_str),
637 currency: Currency::from(&captures.quote),
638 local_symbol: format!("{}.{}", captures.base, captures.quote),
639 ..Default::default()
640 });
641 }
642
643 if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
645 && let Some(captures) = parse_crypto_symbol(symbol_str)
646 {
647 return Ok(Contract {
648 contract_id: 0,
649 symbol: Symbol::from(&captures.base),
650 security_type: SecurityType::Crypto,
651 exchange: Exchange::from(exchange_str),
652 currency: Currency::from(&captures.quote),
653 local_symbol: format!("{}.{}", captures.base, captures.quote),
654 ..Default::default()
655 });
656 }
657
658 if is_option_venue(venue_str.as_str()) {
660 if venue_str == "OPRA" {
661 if !is_canonical_occ_option_symbol(symbol_str) {
662 anyhow::bail!("Invalid OPRA option symbol: {symbol_str}");
663 }
664
665 return Ok(Contract {
666 contract_id: 0,
667 security_type: SecurityType::Option,
668 exchange: Exchange::from(exchange_str),
669 currency: Currency::from("USD"),
670 local_symbol: symbol_str.to_string(),
671 ..Default::default()
672 });
673 }
674
675 if let Some(opt) = parse_option_symbol(symbol_str) {
676 return Ok(Contract {
677 contract_id: 0,
678 symbol: Symbol::from(&opt.symbol),
679 security_type: SecurityType::Option,
680 exchange: Exchange::from(exchange_str),
681 currency: Currency::from("USD"), local_symbol: opt.local_symbol,
683 last_trade_date_or_contract_month: opt.expiry,
684 strike: opt.strike_value,
685 right: Some(opt.right),
686 ..Default::default()
687 });
688 }
689
690 if let Some(opt) = parse_named_option_symbol(symbol_str) {
691 return Ok(Contract {
692 contract_id: 0,
693 symbol: Symbol::from(&opt.trading_class),
694 security_type: SecurityType::Option,
695 exchange: Exchange::from(exchange_str),
696 currency: Currency::from("USD"),
697 trading_class: opt.trading_class,
698 last_trade_date_or_contract_month: opt.expiry,
699 strike: opt.strike_value,
700 right: Some(opt.right),
701 ..Default::default()
702 });
703 }
704 }
705
706 if venue_matches(venue_str.as_str(), VENUES_FUT) {
708 if let Some(fut) = parse_named_futures_symbol(symbol_str) {
709 return Ok(Contract {
710 contract_id: 0,
711 symbol: Symbol::from(&fut.underlying),
712 security_type: SecurityType::Future,
713 exchange: Exchange::from(exchange_str),
714 currency: Currency::from("USD"),
715 trading_class: fut.trading_class,
716 last_trade_date_or_contract_month: fut.expiry,
717 ..Default::default()
718 });
719 }
720
721 if let Some(underlying) = parse_futures_underlying(symbol_str) {
724 return Ok(Contract {
725 contract_id: 0,
726 symbol: Symbol::from(&underlying),
727 security_type: SecurityType::ContinuousFuture,
728 exchange: Exchange::from(exchange_str),
729 currency: Currency::from("USD"), ..Default::default()
731 });
732 }
733
734 if let Some(local_symbol) = parse_futures_option_symbol(symbol_str) {
736 return Ok(Contract {
737 contract_id: 0,
738 security_type: SecurityType::FuturesOption,
739 exchange: Exchange::from(exchange_str),
740 currency: Currency::from("USD"),
741 local_symbol,
742 ..Default::default()
743 });
744 }
745
746 if let Some(fut) = parse_futures_symbol(symbol_str) {
748 return Ok(Contract {
749 contract_id: 0,
750 security_type: SecurityType::Future,
751 exchange: Exchange::from(exchange_str),
752 currency: Currency::from("USD"),
753 local_symbol: fut.local_symbol,
754 ..Default::default()
755 });
756 }
757 }
758
759 if venue_matches(venue_str.as_str(), VENUES_CFD) {
761 if let Some(captures) =
762 parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
763 {
764 return Ok(Contract {
765 contract_id: 0,
766 symbol: Symbol::from(&captures.base),
767 security_type: SecurityType::CFD,
768 exchange: Exchange::from("SMART"),
769 currency: Currency::from(&captures.quote),
770 local_symbol: format!("{}.{}", captures.base, captures.quote),
771 ..Default::default()
772 });
773 } else {
774 let symbol_clean = symbol_str.replace('-', " ");
776 return Ok(Contract {
777 contract_id: 0,
778 symbol: Symbol::from(&symbol_clean),
779 security_type: SecurityType::CFD,
780 exchange: Exchange::from("SMART"),
781 currency: Currency::from("USD"),
782 ..Default::default()
783 });
784 }
785 }
786
787 if VENUES_CMDTY.contains(&venue_str.as_str()) {
789 let symbol_clean = symbol_str.replace('-', " ");
790 return Ok(Contract {
791 contract_id: 0,
792 symbol: Symbol::from(&symbol_clean),
793 security_type: SecurityType::Commodity,
794 exchange: Exchange::from("SMART"),
795 currency: Currency::from("USD"),
796 ..Default::default()
797 });
798 }
799
800 if let Some(local_symbol) = symbol_str.strip_prefix('^') {
802 return Ok(Contract {
803 contract_id: 0,
804 symbol: Symbol::from(local_symbol),
805 security_type: SecurityType::Index,
806 exchange: Exchange::from(exchange_str),
807 currency: Currency::from("USD"),
808 local_symbol: local_symbol.into(),
809 ..Default::default()
810 });
811 }
812
813 let symbol_clean = symbol_str.replace('-', " ");
815 Ok(Contract {
816 contract_id: 0,
817 symbol: Symbol::from(&symbol_clean),
818 security_type: SecurityType::Stock,
819 exchange: Exchange::from("SMART"),
820 currency: Currency::from(""), primary_exchange: Exchange::from(exchange_str),
822 ..Default::default()
823 })
824}
825
826fn instrument_id_to_ib_contract_raw(
827 instrument_id: &InstrumentId,
828 exchange: Option<&str>,
829) -> Option<Contract> {
830 let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
831
832 let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
833 let security_type = IbSecurityType::from_str(sec_type_code)
834 .ok()
835 .map(IbSecurityType::ibapi_security_type)?;
836 let default_exchange =
837 if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
838 "SMART"
839 } else {
840 venue_exchange.as_str()
841 };
842 let exchange_str = exchange.unwrap_or(default_exchange);
843
844 let contract = match security_type {
845 SecurityType::Stock => Contract {
846 contract_id: 0,
847 security_type,
848 exchange: Exchange::from("SMART"),
849 primary_exchange: Exchange::from(exchange_str),
850 local_symbol: local_symbol.to_string(),
851 ..Default::default()
852 },
853 SecurityType::CFD | SecurityType::Commodity => Contract {
854 contract_id: 0,
855 security_type,
856 exchange: Exchange::from("SMART"),
857 local_symbol: local_symbol.to_string(),
858 ..Default::default()
859 },
860 SecurityType::Index => Contract {
861 contract_id: 0,
862 security_type,
863 exchange: Exchange::from(exchange_str),
864 local_symbol: local_symbol.to_string(),
865 ..Default::default()
866 },
867 _ => Contract {
868 contract_id: 0,
869 security_type,
870 exchange: Exchange::from(exchange_str),
871 local_symbol: local_symbol.to_string(),
872 ..Default::default()
873 },
874 };
875
876 Some(contract)
877}
878
879struct CurrencyPair {
881 base: String,
882 quote: String,
883}
884
885fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
887 if let Some((base, quote)) = symbol.split_once('/')
888 && base.len() == 3
889 && quote.len() == 3
890 {
891 return Some(CurrencyPair {
892 base: base.to_string(),
893 quote: quote.to_string(),
894 });
895 }
896 None
897}
898
899fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
901 if let Some((base, quote)) = symbol.split_once('/')
902 && !base.is_empty()
903 && base.chars().all(|ch| ch.is_ascii_uppercase())
904 && quote.len() == 3
905 && quote.chars().all(|ch| ch.is_ascii_uppercase())
906 {
907 return Some(CurrencyPair {
908 base: base.to_string(),
909 quote: quote.to_string(),
910 });
911 }
912 None
913}
914
915fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
917 if let Some((base, quote)) = symbol.split_once('.')
918 && base.len() == 3
919 && quote.len() == 3
920 {
921 return Some(CurrencyPair {
922 base: base.to_string(),
923 quote: quote.to_string(),
924 });
925 }
926 None
927}
928
929struct OptionSymbol {
931 symbol: String,
932 expiry: String,
933 right: OptionRight,
934 local_symbol: String,
935 strike_value: f64,
936}
937
938fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
940 if symbol.len() < 21 {
943 return None;
944 }
945
946 let symbol_part = symbol[..6.min(symbol.len())].trim();
948 let remaining = &symbol[6.min(symbol.len())..];
949
950 if remaining.len() < 15 {
951 return None;
952 }
953
954 let expiry = &remaining[..6];
955 let right_char = remaining.chars().nth(6)?;
956 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
957
958 let strike_str = &remaining[7..];
959 if strike_str.len() < 8 {
960 return None;
961 }
962
963 let strike_value = if strike_str.contains('.') {
965 strike_str.parse().ok()?
966 } else {
967 let strike_int: i32 = strike_str.parse().ok()?;
969 strike_int as f64 / 1000.0
970 };
971
972 Some(OptionSymbol {
973 symbol: symbol_part.to_string(),
974 expiry: expiry.to_string(),
975 right: ib_option_right_to_option_right(right),
976 local_symbol: symbol.to_string(),
977 strike_value,
978 })
979}
980
981struct NamedOptionSymbol {
983 trading_class: String,
984 expiry: String,
985 right: OptionRight,
986 strike_value: f64,
987}
988
989struct NamedFuturesSymbol {
991 underlying: String,
992 trading_class: String,
993 expiry: String,
994}
995
996fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
997 let parts: Vec<&str> = symbol.split_whitespace().collect();
998 if parts.len() != 3 {
999 return None;
1000 }
1001
1002 let expiry = parts[2];
1003 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1004 return None;
1005 }
1006
1007 Some(NamedFuturesSymbol {
1008 underlying: parts[0].to_string(),
1009 trading_class: parts[1].to_string(),
1010 expiry: expiry.to_string(),
1011 })
1012}
1013
1014fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1015 let parts: Vec<&str> = symbol.split_whitespace().collect();
1016 if !(parts.len() == 4 || parts.len() == 5) {
1017 return None;
1018 }
1019
1020 let right = IbOptionRight::from_str(parts[0]).ok()?;
1021
1022 let expiry = parts[2];
1023 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1024 return None;
1025 }
1026
1027 Some(NamedOptionSymbol {
1028 trading_class: parts[1].to_string(),
1029 expiry: expiry.to_string(),
1030 right: ib_option_right_to_option_right(right),
1031 strike_value: parts[3].parse::<f64>().ok()?,
1032 })
1033}
1034
1035fn normalize_option_symbol(local_symbol: &str) -> String {
1036 if local_symbol.len() >= 15 {
1037 let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1038 let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1039 && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1040 && suffix[7..].chars().all(|c| c.is_ascii_digit());
1041
1042 if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1043 return format!("{:<6}{}", root.trim_end(), suffix);
1044 }
1045 }
1046
1047 local_symbol.to_string()
1048}
1049
1050fn format_option_strike(strike: f64) -> String {
1051 if strike.fract() == 0.0 {
1052 format!("{strike:.0}")
1053 } else {
1054 format!("{strike}")
1055 }
1056}
1057
1058struct FuturesSymbol {
1060 local_symbol: String,
1061}
1062
1063fn parse_futures_underlying(symbol: &str) -> Option<String> {
1065 if symbol.len() <= 3 && symbol.chars().all(|c| c.is_alphabetic()) {
1067 Some(symbol.to_string())
1068 } else {
1069 None
1070 }
1071}
1072
1073fn is_futures_month_code(ch: char) -> bool {
1074 matches!(
1075 ch,
1076 'F' | 'G' | 'H' | 'J' | 'K' | 'M' | 'N' | 'Q' | 'U' | 'V' | 'X' | 'Z'
1077 )
1078}
1079
1080fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1081 for (month_pos, month_char) in symbol.char_indices().rev() {
1082 if !is_futures_month_code(month_char) {
1083 continue;
1084 }
1085
1086 let remaining = &symbol[month_pos + month_char.len_utf8()..];
1087 if remaining.is_empty() || !remaining.chars().all(|ch| ch.is_ascii_digit()) {
1088 continue;
1089 }
1090
1091 let year = match remaining.len() {
1092 1 | 2 => remaining.to_string(),
1093 4 => remaining[remaining.len() - 2..].to_string(),
1094 _ => continue,
1095 };
1096
1097 if month_pos == 0 {
1098 continue;
1099 }
1100
1101 return Some((month_pos, month_char, year));
1102 }
1103
1104 None
1105}
1106
1107fn parse_futures_symbol(symbol: &str) -> Option<FuturesSymbol> {
1109 parse_futures_month_and_year(symbol).map(|_| FuturesSymbol {
1110 local_symbol: symbol.to_string(),
1111 })
1112}
1113
1114fn parse_futures_option_symbol(symbol: &str) -> Option<String> {
1116 let (futures_symbol, rest) = symbol.split_once(' ')?;
1117 let (month_pos, _, _) = parse_futures_month_and_year(futures_symbol)?;
1118 let _fut_symbol = &futures_symbol[..month_pos];
1119
1120 let right_char = rest.chars().next()?;
1122 IbOptionRight::from_str(&right_char.to_string()).ok()?;
1123
1124 let strike_str = &rest[1..];
1125 strike_str.parse::<f64>().ok()?;
1126
1127 Some(symbol.to_string())
1128}
1129
1130#[must_use]
1134pub fn is_spread_instrument_id(instrument_id: &InstrumentId) -> bool {
1135 let symbol_str = instrument_id.symbol.as_str();
1136 symbol_str.contains('(') && symbol_str.contains('_')
1138}
1139
1140pub fn create_spread_instrument_id(
1156 leg_tuples: &[(InstrumentId, i32)],
1157) -> anyhow::Result<InstrumentId> {
1158 if leg_tuples.len() < 2 {
1159 anyhow::bail!("instrument_ratios list needs to have at least 2 legs");
1160 }
1161
1162 let first_venue = leg_tuples[0].0.venue;
1163
1164 for (instrument_id, ratio) in leg_tuples {
1165 if *ratio == 0 {
1166 anyhow::bail!("ratio cannot be zero");
1167 }
1168
1169 if instrument_id.venue != first_venue {
1170 anyhow::bail!(
1171 "All venues must match. Expected {}, was {}",
1172 first_venue,
1173 instrument_id.venue
1174 );
1175 }
1176 }
1177
1178 let mut sorted_ratios = leg_tuples.to_vec();
1179 sorted_ratios.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1180
1181 let symbol_parts = sorted_ratios
1182 .iter()
1183 .map(|(instrument_id, ratio)| {
1184 if *ratio > 0 {
1185 format!("({}){}", ratio, instrument_id.symbol.as_str())
1186 } else {
1187 format!("(({})){}", ratio.abs(), instrument_id.symbol.as_str())
1188 }
1189 })
1190 .collect::<Vec<_>>();
1191
1192 let composite_symbol = symbol_parts.join("_");
1193 let symbol = NautilusSymbol::from(composite_symbol.as_str());
1194
1195 Ok(InstrumentId::new(symbol, first_venue))
1196}
1197
1198pub fn parse_spread_instrument_id_to_legs(
1210 instrument_id: &InstrumentId,
1211) -> anyhow::Result<Vec<(InstrumentId, i32)>> {
1212 let symbol_str = instrument_id.symbol.as_str();
1213 let venue = instrument_id.venue;
1214
1215 let components: Vec<&str> = symbol_str.split('_').collect();
1216 let mut result = Vec::new();
1217
1218 for component in components {
1219 if component.is_empty() {
1220 continue;
1221 }
1222
1223 if let Some(rest) = component.strip_prefix("((")
1225 && let Some(pos) = rest.find("))")
1226 {
1227 let ratio_str = &rest[..pos];
1228 let symbol_value = &rest[pos + 2..];
1229
1230 if let Ok(ratio) = ratio_str.parse::<i32>() {
1231 let leg_instrument_id =
1232 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1233 result.push((leg_instrument_id, -ratio));
1234 continue;
1235 }
1236 }
1237
1238 if let Some(rest) = component.strip_prefix('(')
1240 && let Some(pos) = rest.find(')')
1241 {
1242 let ratio_str = &rest[..pos];
1243 let symbol_value = &rest[pos + 1..];
1244
1245 if let Ok(ratio) = ratio_str.parse::<i32>() {
1246 let leg_instrument_id =
1247 InstrumentId::new(NautilusSymbol::from(symbol_value), venue);
1248 result.push((leg_instrument_id, ratio));
1249 continue;
1250 }
1251 }
1252
1253 anyhow::bail!("Invalid spread symbol format for component: {component}");
1254 }
1255
1256 result.sort_by(|a, b| a.0.symbol.as_str().cmp(b.0.symbol.as_str()));
1258
1259 Ok(result)
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1265 use nautilus_model::identifiers::InstrumentId;
1266 use rstest::rstest;
1267
1268 use super::{
1269 exchange_to_mic_venue, ib_contract_to_instrument_id_simplified,
1270 instrument_id_to_ib_contract, possible_exchanges_for_venue,
1271 };
1272
1273 #[rstest]
1274 fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1275 let contract = Contract {
1276 symbol: Symbol::from("SPXW"),
1277 security_type: SecurityType::Option,
1278 exchange: Exchange::from("SMART"),
1279 currency: Currency::from("USD"),
1280 local_symbol: "SPXW260313P06630000".to_string(),
1281 last_trade_date_or_contract_month: "260313".to_string(),
1282 right: Some(OptionRight::Put),
1283 strike: 6630.0,
1284 ..Default::default()
1285 };
1286
1287 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1288
1289 assert_eq!(
1290 instrument_id,
1291 InstrumentId::from("SPXW 260313P06630000.SMART")
1292 );
1293 }
1294
1295 #[rstest]
1296 fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1297 let contract = Contract {
1298 symbol: Symbol::from("OESX"),
1299 security_type: SecurityType::Option,
1300 exchange: Exchange::from("EUREX"),
1301 currency: Currency::from("EUR"),
1302 trading_class: "OESX".to_string(),
1303 local_symbol: String::new(),
1304 last_trade_date_or_contract_month: "20260213".to_string(),
1305 right: Some(OptionRight::Call),
1306 strike: 4775.0,
1307 ..Default::default()
1308 };
1309
1310 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1311
1312 assert_eq!(
1313 instrument_id,
1314 InstrumentId::from("C OESX 20260213 4775.EUREX")
1315 );
1316 }
1317
1318 #[rstest]
1319 fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1320 let contract = Contract {
1321 symbol: Symbol::from("EX2"),
1322 security_type: SecurityType::FuturesOption,
1323 exchange: Exchange::from("NYBOT"),
1324 currency: Currency::from("USD"),
1325 local_symbol: "EX2G3 P4080".to_string(),
1326 ..Default::default()
1327 };
1328
1329 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1330
1331 assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1332 }
1333
1334 #[rstest]
1335 fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1336 let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1337
1338 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1339
1340 assert_eq!(contract.security_type, SecurityType::Option);
1341 assert_eq!(contract.exchange.as_str(), "EUREX");
1342 assert_eq!(contract.symbol.as_str(), "OESX");
1343 assert_eq!(contract.trading_class.as_str(), "OESX");
1344 assert_eq!(
1345 contract.last_trade_date_or_contract_month.as_str(),
1346 "20260213"
1347 );
1348 assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1349 assert_eq!(contract.strike, 4775.0);
1350 }
1351
1352 #[rstest]
1353 fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1354 let instrument_id = InstrumentId::from("AAPL 230217P00155000.SMART");
1355
1356 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1357
1358 assert_eq!(contract.security_type, SecurityType::Option);
1359 assert_eq!(contract.exchange.as_str(), "SMART");
1360 assert_eq!(contract.symbol.as_str(), "AAPL");
1361 assert_eq!(contract.local_symbol.as_str(), "AAPL 230217P00155000");
1362 assert_eq!(
1363 contract.last_trade_date_or_contract_month.as_str(),
1364 "230217"
1365 );
1366 assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1367 assert_eq!(contract.strike, 155.0);
1368 }
1369
1370 #[rstest]
1371 fn test_possible_exchanges_for_opra_routes_to_smart() {
1372 assert_eq!(
1373 possible_exchanges_for_venue("OPRA"),
1374 vec!["SMART".to_string()]
1375 );
1376 }
1377
1378 #[rstest]
1379 fn test_opra_occ_option_default_contract_routes_to_smart() {
1380 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1381
1382 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1383
1384 assert_eq!(contract.security_type, SecurityType::Option);
1385 assert_eq!(contract.exchange.as_str(), "SMART");
1386 assert!(contract.symbol.as_str().is_empty());
1387 assert_eq!(contract.currency.as_str(), "USD");
1388 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1389 assert!(contract.last_trade_date_or_contract_month.is_empty());
1390 assert!(contract.right.is_none());
1391 assert_eq!(contract.strike, 0.0);
1392 }
1393
1394 #[rstest]
1395 fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1396 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1397 let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1398
1399 assert_eq!(exchanges, vec!["SMART".to_string()]);
1400
1401 let contract =
1402 instrument_id_to_ib_contract(instrument_id, exchanges.first().map(String::as_str))
1403 .unwrap();
1404
1405 assert_eq!(contract.security_type, SecurityType::Option);
1406 assert_eq!(contract.exchange.as_str(), "SMART");
1407 assert!(contract.symbol.as_str().is_empty());
1408 assert_eq!(contract.currency.as_str(), "USD");
1409 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1410 assert!(contract.last_trade_date_or_contract_month.is_empty());
1411 assert!(contract.right.is_none());
1412 assert_eq!(contract.strike, 0.0);
1413 }
1414
1415 #[rstest]
1416 fn test_opra_raw_option_default_contract_routes_to_smart() {
1417 let instrument_id = InstrumentId::from("SPY 240319P00511000=OPT.OPRA");
1418
1419 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1420
1421 assert_eq!(contract.security_type, SecurityType::Option);
1422 assert_eq!(contract.exchange.as_str(), "SMART");
1423 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1424 }
1425
1426 #[rstest]
1427 #[case("SPY 240319P00511000.OPRA")]
1428 #[case("SPY 240319P00511000=OPT.OPRA")]
1429 fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1430 let instrument_id = InstrumentId::from(value);
1431
1432 let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOE")).unwrap();
1433
1434 assert_eq!(contract.security_type, SecurityType::Option);
1435 assert_eq!(contract.exchange.as_str(), "CBOE");
1436 }
1437
1438 #[rstest]
1439 #[case("AAPL.OPRA")]
1440 #[case("SPY abcdefP00511000.OPRA")]
1441 #[case("P SPY 20240319 511.OPRA")]
1442 fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1443 let instrument_id = InstrumentId::from(value);
1444
1445 let result = instrument_id_to_ib_contract(instrument_id, None);
1446
1447 assert!(result.is_err());
1448 }
1449
1450 #[rstest]
1451 fn test_opra_route_does_not_create_reverse_smart_mapping() {
1452 assert_eq!(exchange_to_mic_venue("SMART"), None);
1453 }
1454
1455 #[rstest]
1456 fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1457 let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1458
1459 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1460
1461 assert_eq!(contract.security_type, SecurityType::Future);
1462 assert_eq!(contract.exchange.as_str(), "EUREX");
1463 assert_eq!(contract.symbol.as_str(), "ESTX50");
1464 assert_eq!(contract.trading_class.as_str(), "FESX");
1465 assert_eq!(
1466 contract.last_trade_date_or_contract_month.as_str(),
1467 "20240315"
1468 );
1469 }
1470
1471 #[rstest]
1472 fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1473 let instrument_id = InstrumentId::from("YMM6.XCBT");
1474
1475 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1476
1477 assert_eq!(contract.security_type, SecurityType::Future);
1478 assert_eq!(contract.exchange.as_str(), "CBOT");
1479 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1480 assert!(contract.symbol.as_str().is_empty());
1481 assert!(contract.last_trade_date_or_contract_month.is_empty());
1482 }
1483
1484 #[rstest]
1485 fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1486 let instrument_id = InstrumentId::from("OESXH6.XEUR");
1487
1488 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1489
1490 assert_eq!(contract.security_type, SecurityType::Future);
1491 assert_eq!(contract.exchange.as_str(), "DTB");
1492 assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1493 }
1494
1495 #[rstest]
1496 fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1497 let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1498
1499 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1500
1501 assert_eq!(contract.security_type, SecurityType::FuturesOption);
1502 assert_eq!(contract.exchange.as_str(), "CBOT");
1503 assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1504 }
1505
1506 #[rstest]
1507 fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1508 let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1509
1510 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1511
1512 assert_eq!(contract.security_type, SecurityType::CFD);
1513 assert_eq!(contract.exchange.as_str(), "SMART");
1514 assert_eq!(contract.symbol.as_str(), "EUR");
1515 assert_eq!(contract.currency.as_str(), "USD");
1516 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1517 }
1518
1519 #[rstest]
1520 fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1521 let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1522
1523 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1524
1525 assert_eq!(contract.security_type, SecurityType::Crypto);
1526 assert_eq!(contract.exchange.as_str(), "PAXOS");
1527 assert_eq!(contract.symbol.as_str(), "DOGE");
1528 assert_eq!(contract.currency.as_str(), "USD");
1529 assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1530 }
1531
1532 #[rstest]
1533 fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1534 let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1536
1537 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1538
1539 assert_eq!(contract.security_type, SecurityType::Crypto);
1540 assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1541 assert_eq!(contract.symbol.as_str(), "BTC");
1542 assert_eq!(contract.currency.as_str(), "USD");
1543 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1544 }
1545
1546 #[rstest]
1547 fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1548 let contract = Contract {
1550 symbol: Symbol::from("BTC"),
1551 security_type: SecurityType::Crypto,
1552 exchange: Exchange::from("ZEROHASH"),
1553 currency: Currency::from("USD"),
1554 local_symbol: "BTC.USD".to_string(),
1555 ..Default::default()
1556 };
1557
1558 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1559
1560 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1561 }
1562
1563 #[rstest]
1564 fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1565 let contract = Contract {
1568 symbol: Symbol::from("DOGE"),
1569 security_type: SecurityType::Crypto,
1570 currency: Currency::from("USD"),
1571 local_symbol: "DOGE.USD".to_string(),
1572 ..Default::default()
1573 };
1574
1575 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1576
1577 assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1578 }
1579
1580 #[rstest]
1581 fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1582 let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1584
1585 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1586
1587 assert_eq!(contract.security_type, SecurityType::Crypto);
1588 assert_eq!(contract.exchange.as_str(), "PAXOS");
1589 assert_eq!(contract.symbol.as_str(), "BTC");
1590 assert_eq!(contract.currency.as_str(), "USD");
1591 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1592 }
1593
1594 #[rstest]
1595 fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1596 let contract = Contract {
1599 symbol: Symbol::from("BTC"),
1600 security_type: SecurityType::Crypto,
1601 exchange: Exchange::from("PAXOS"),
1602 currency: Currency::from("USD"),
1603 local_symbol: "BTC.USD".to_string(),
1604 ..Default::default()
1605 };
1606
1607 let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1608
1609 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1610 }
1611
1612 #[rstest]
1613 fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1614 let instrument_id = InstrumentId::from("ES.XCME");
1615
1616 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1617
1618 assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1619 assert_eq!(contract.exchange.as_str(), "CME");
1620 assert_eq!(contract.symbol.as_str(), "ES");
1621 }
1622
1623 #[rstest]
1624 fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1625 let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1626
1627 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1628
1629 assert_eq!(contract.security_type, SecurityType::Stock);
1630 assert_eq!(contract.exchange.as_str(), "SMART");
1631 assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1632 assert_eq!(contract.local_symbol.as_str(), "AAPL");
1633 assert!(contract.symbol.as_str().is_empty());
1634 }
1635
1636 #[rstest]
1637 fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1638 let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1639
1640 let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1641
1642 assert_eq!(contract.security_type, SecurityType::ForexPair);
1643 assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1644 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1645 assert!(contract.symbol.as_str().is_empty());
1646 }
1647
1648 #[rstest]
1649 fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1650 let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1651
1652 let contract = instrument_id_to_ib_contract(instrument_id, Some("CBOT")).unwrap();
1653
1654 assert_eq!(contract.security_type, SecurityType::Future);
1655 assert_eq!(contract.exchange.as_str(), "CBOT");
1656 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1657 }
1658
1659 #[rstest]
1660 fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1661 let instrument_id = InstrumentId::from("IUSA.IBIS");
1662
1663 let contract = instrument_id_to_ib_contract(instrument_id, Some("IBIS")).unwrap();
1664
1665 assert_eq!(contract.security_type, SecurityType::Stock);
1666 assert_eq!(contract.symbol.as_str(), "IUSA");
1667 assert_eq!(contract.exchange.as_str(), "SMART");
1668 assert_eq!(contract.primary_exchange.as_str(), "IBIS");
1669 assert!(contract.currency.as_str().is_empty());
1670 assert!(contract.local_symbol.is_empty());
1671 }
1672}