1use std::{collections::HashMap, str::FromStr, sync::LazyLock};
19
20use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
21use jiff::{Timestamp, tz::Offset};
22use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
23
24use crate::{
25 common::{
26 enums::{IbOptionRight, IbSecurityType},
27 futures::{
28 format_futures_symbol, futures_month, futures_month_code,
29 parse_futures_symbol as parse_futures_code, resolve_futures_year,
30 },
31 },
32 config::SymbologyMethod,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Symbology {
38 method: SymbologyMethod,
39 futures_year_digits: u8,
40}
41
42impl Symbology {
43 #[must_use]
44 pub const fn new(method: SymbologyMethod) -> Self {
45 Self {
46 method,
47 futures_year_digits: 1,
49 }
50 }
51
52 #[must_use]
53 pub const fn method(self) -> SymbologyMethod {
54 self.method
55 }
56
57 pub fn instrument_id(
63 self,
64 contract: &Contract,
65 venue: Option<Venue>,
66 ) -> anyhow::Result<InstrumentId> {
67 anyhow::ensure!(
68 matches!(self.futures_year_digits, 1 | 2),
69 "`futures_year_digits` must be either 1 or 2"
70 );
71
72 let instrument_id = match self.method {
73 SymbologyMethod::Simplified => {
74 contract_to_instrument_id_simplified(contract, venue, self.futures_year_digits)
75 }
76 SymbologyMethod::Raw => {
77 contract_to_instrument_id_raw(contract, venue, self.futures_year_digits)
78 }
79 };
80
81 Ok(instrument_id)
82 }
83
84 pub fn contract(
90 self,
91 instrument_id: InstrumentId,
92 exchange: Option<&str>,
93 ) -> anyhow::Result<Contract> {
94 self.contract_with_currency(instrument_id, exchange, None)
95 }
96
97 pub(crate) fn contract_with_currency(
98 self,
99 instrument_id: InstrumentId,
100 exchange: Option<&str>,
101 currency: Option<&str>,
102 ) -> anyhow::Result<Contract> {
103 anyhow::ensure!(
104 matches!(self.futures_year_digits, 1 | 2),
105 "`futures_year_digits` must be either 1 or 2"
106 );
107
108 if instrument_id.venue.as_str() == "OPRA" {
109 let symbol = instrument_id
110 .symbol
111 .as_str()
112 .strip_suffix("=OPT")
113 .unwrap_or(instrument_id.symbol.as_str());
114
115 if !is_canonical_occ_option_symbol(symbol) {
116 anyhow::bail!("Invalid OPRA option symbol: {symbol}");
117 }
118 }
119
120 let reference_year = i32::from(Offset::UTC.to_datetime(Timestamp::now()).year());
121 Ok(instrument_id_to_contract(
122 instrument_id,
123 exchange,
124 self.futures_year_digits,
125 reference_year,
126 currency,
127 ))
128 }
129}
130
131impl From<SymbologyMethod> for Symbology {
132 fn from(method: SymbologyMethod) -> Self {
133 Self::new(method)
134 }
135}
136
137fn ib_option_right_to_option_right(right: IbOptionRight) -> OptionRight {
138 match right {
139 IbOptionRight::Call => OptionRight::Call,
140 IbOptionRight::Put => OptionRight::Put,
141 }
142}
143
144fn contract_to_instrument_id_simplified(
154 contract: &Contract,
155 venue: Option<Venue>,
156 futures_year_digits: u8,
157) -> InstrumentId {
158 let venue = venue.unwrap_or_else(|| {
159 match contract.security_type {
161 SecurityType::Index => {
162 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
163 Venue::from(contract.exchange.as_str())
164 } else {
165 Venue::from("SMART")
166 }
167 }
168 SecurityType::Future => {
169 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
170 Venue::from(contract.exchange.as_str())
171 } else {
172 Venue::from("GLOBEX")
173 }
174 }
175 SecurityType::ForexPair => Venue::from("IDEALPRO"),
176 SecurityType::Crypto => derive_crypto_venue(contract),
177 SecurityType::Stock => Venue::from("SMART"),
178 SecurityType::Option | SecurityType::FuturesOption => {
179 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
180 Venue::from(contract.exchange.as_str())
181 } else {
182 Venue::from("SMART")
183 }
184 }
185 SecurityType::CFD => Venue::from("IBCFD"),
186 SecurityType::Commodity => Venue::from("IBCMDTY"),
187 SecurityType::Bond => Venue::from("SMART"),
188 _ => Venue::from("SMART"),
189 }
190 });
191
192 let symbol = match contract.security_type {
193 SecurityType::Stock => {
194 let symbol_str = if contract.local_symbol.is_empty() {
196 contract.symbol.as_str().to_string()
197 } else {
198 contract.local_symbol.as_str().replace(' ', "-")
199 };
200
201 NautilusSymbol::from(symbol_str.as_str())
202 }
203 SecurityType::Index => {
204 let base = if contract.local_symbol.is_empty() {
206 contract.symbol.as_str()
207 } else {
208 contract.local_symbol.as_str()
209 };
210
211 NautilusSymbol::from(format!("^{base}").as_str())
212 }
213 SecurityType::Option => {
214 let symbol_str = if contract.local_symbol.is_empty() {
216 format!(
217 "{} {} {} {}",
218 contract.right.map_or("", |right| right.as_str()),
219 contract.trading_class.as_str(),
220 contract.last_trade_date_or_contract_month.as_str(),
221 format_option_strike(contract.strike),
222 )
223 } else {
224 normalize_option_symbol(contract.local_symbol.as_str())
225 };
226
227 NautilusSymbol::from(symbol_str.as_str())
228 }
229 SecurityType::ForexPair | SecurityType::Crypto => {
230 let symbol_str = if contract.local_symbol.is_empty() {
232 format!(
233 "{}/{}",
234 contract.symbol.as_str(),
235 contract.currency.as_str()
236 )
237 } else {
238 contract.local_symbol.as_str().replace('.', "/")
239 };
240
241 NautilusSymbol::from(symbol_str.as_str())
242 }
243 SecurityType::Future => {
244 if futures_year_digits == 2 {
245 match futures_symbol_from_contract(contract, futures_year_digits) {
246 Ok(symbol) => {
247 return InstrumentId::new(NautilusSymbol::from(symbol), venue);
248 }
249 Err(e) => tracing::warn!(
250 "Falling back to legacy futures symbol for local symbol '{}': {e}",
251 contract.local_symbol
252 ),
253 }
254 }
255
256 if contract.local_symbol.is_empty() {
258 if !contract.trading_class.is_empty()
259 && !contract.last_trade_date_or_contract_month.is_empty()
260 {
261 let symbol_str = format!(
262 "{} {} {}",
263 contract.symbol.as_str(),
264 contract.trading_class.as_str(),
265 contract.last_trade_date_or_contract_month.as_str()
266 );
267 NautilusSymbol::from(symbol_str.as_str())
268 } else if !contract.last_trade_date_or_contract_month.is_empty() {
269 let expiry = contract.last_trade_date_or_contract_month.as_str();
270 let symbol_str = format!("{}{}", contract.symbol.as_str(), expiry);
271 NautilusSymbol::from(symbol_str.as_str())
272 } else {
273 NautilusSymbol::from(contract.symbol.as_str())
274 }
275 } else {
276 NautilusSymbol::from(contract.local_symbol.as_str())
277 }
278 }
279 SecurityType::FuturesOption => {
280 if futures_year_digits == 2 {
281 match futures_symbol_from_contract(contract, futures_year_digits) {
282 Ok(symbol) => {
283 return InstrumentId::new(NautilusSymbol::from(symbol), venue);
284 }
285 Err(e) => tracing::warn!(
286 "Falling back to legacy futures option symbol for local symbol '{}': {e}",
287 contract.local_symbol
288 ),
289 }
290 }
291
292 if contract.local_symbol.is_empty() {
294 let expiry = contract.last_trade_date_or_contract_month.as_str();
296 let right = contract.right.map_or("P", |right| right.as_str());
297 let strike_str = format!("{}", contract.strike as i64);
298 let symbol_str = format!(
299 "{}{} {}{}",
300 contract.symbol.as_str(),
301 expiry,
302 right,
303 strike_str
304 );
305 NautilusSymbol::from(symbol_str.as_str())
306 } else {
307 NautilusSymbol::from(contract.local_symbol.as_str())
308 }
309 }
310 SecurityType::CFD => {
311 if !contract.local_symbol.is_empty() && contract.local_symbol.contains('.') {
313 let cash_like = contract.local_symbol.as_str().replace('.', "/");
314 NautilusSymbol::from(cash_like.as_str())
315 } else {
316 let symbol_str = contract.symbol.as_str().replace(' ', "-");
317 NautilusSymbol::from(symbol_str.as_str())
318 }
319 }
320 SecurityType::Commodity => {
321 let symbol_str = contract.symbol.as_str().replace(' ', "-");
323 NautilusSymbol::from(symbol_str.as_str())
324 }
325 SecurityType::Bond => {
326 let symbol_str = if contract.local_symbol.is_empty() {
328 contract.symbol.as_str()
329 } else {
330 contract.local_symbol.as_str()
331 };
332
333 NautilusSymbol::from(symbol_str)
334 }
335 _ => {
336 let symbol_str = if contract.local_symbol.is_empty() {
338 contract.symbol.as_str()
339 } else {
340 contract.local_symbol.as_str()
341 };
342
343 NautilusSymbol::from(symbol_str)
344 }
345 };
346
347 InstrumentId::new(symbol, venue)
348}
349
350fn contract_to_instrument_id_raw(
358 contract: &Contract,
359 venue: Option<Venue>,
360 futures_year_digits: u8,
361) -> InstrumentId {
362 let venue = venue.unwrap_or_else(|| match contract.security_type {
363 SecurityType::ForexPair => Venue::from("IDEALPRO"),
364 SecurityType::Crypto => derive_crypto_venue(contract),
365 SecurityType::Stock => Venue::from("SMART"),
366 SecurityType::Option => Venue::from("SMART"),
367 SecurityType::FuturesOption => Venue::from("SMART"),
368 SecurityType::Future => Venue::from("GLOBEX"),
369 SecurityType::Index => Venue::from("SMART"),
370 SecurityType::CFD => Venue::from("IBCFD"),
371 SecurityType::Commodity => Venue::from("IBCMDTY"),
372 SecurityType::Bond => Venue::from("SMART"),
373 _ => Venue::from("SMART"),
374 });
375
376 let local_symbol = if futures_year_digits == 2
377 && matches!(
378 contract.security_type,
379 SecurityType::Future | SecurityType::FuturesOption
380 ) {
381 match futures_symbol_from_contract(contract, futures_year_digits) {
382 Ok(symbol) => symbol,
383 Err(e) => {
384 tracing::warn!(
385 "Falling back to legacy futures symbol for local symbol '{}': {e}",
386 contract.local_symbol
387 );
388 legacy_raw_local_symbol(contract)
389 }
390 }
391 } else if contract.local_symbol.is_empty() {
392 contract.symbol.as_str().to_string()
393 } else {
394 contract.local_symbol.as_str().to_string()
395 };
396
397 let sec_type_str = IbSecurityType::try_from(&contract.security_type).map_or_else(
398 |_| "OTHER".to_string(),
399 |security_type| security_type.to_string(),
400 );
401
402 let symbol_str = format!("{local_symbol}={sec_type_str}");
403 let symbol = NautilusSymbol::from(symbol_str.as_str());
404 InstrumentId::new(symbol, venue)
405}
406
407fn legacy_raw_local_symbol(contract: &Contract) -> String {
408 if contract.local_symbol.is_empty() {
409 contract.symbol.as_str().to_string()
410 } else {
411 contract.local_symbol.as_str().to_string()
412 }
413}
414
415pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
418 LazyLock::new(|| {
419 let mut map = HashMap::new();
420 map.insert("NDEX", vec!["ENDEX"]);
422 map.insert("XCME", vec!["CME"]);
424 map.insert("XCEC", vec!["CME"]);
425 map.insert("XFXS", vec!["CME"]);
426 map.insert("XCBT", vec!["CBOT"]);
428 map.insert("CBCM", vec!["CBOT"]);
429 map.insert("XNYM", vec!["NYMEX"]);
431 map.insert("NYUM", vec!["NYMEX"]);
432 map.insert("IFUS", vec!["NYBOT"]);
434 map.insert("GLBX", vec!["CBOT", "CME", "NYBOT", "NYMEX"]);
436 map.insert("XNAS", vec!["NASDAQ"]);
438 map.insert("XNYS", vec!["NYSE"]);
439 map.insert("ARCX", vec!["ARCA"]);
440 map.insert("BATS", vec!["BATS"]);
441 map.insert("IEXG", vec!["IEX"]);
442 map.insert("XCBO", vec!["CBOE"]);
443 map.insert("XCBF", vec!["CFE"]);
444 map.insert("XTSE", vec!["TSX"]);
446 map.insert("IFEU", vec!["ICEEU", "ICEEUSOFT", "IPE"]);
448 map.insert("XLON", vec!["LSE"]);
450 map.insert("XPAR", vec!["SBF"]);
451 map.insert("XETR", vec!["IBIS"]);
452 map.insert("XEUR", vec!["DTB", "EUREX", "SOFFEX"]);
453 map.insert("XAMS", vec!["AEB"]);
454 map.insert("XBRU", vec!["EBS"]);
455 map.insert("XBRD", vec!["BELFOX"]);
456 map.insert("XLIS", vec!["BVLP"]);
457 map.insert("XDUB", vec!["IRE"]);
458 map.insert("XOSL", vec!["OSL"]);
459 map.insert("XSWX", vec!["EBS", "SIX", "SWX"]);
460 map.insert("XSVX", vec!["VRTX"]);
461 map.insert("XMIL", vec!["BIT", "BVME", "IDEM"]);
462 map.insert("XMAD", vec!["MDRD", "BME"]);
463 map.insert("DXEX", vec!["BATEEN"]);
464 map.insert("XWBO", vec!["WBAG"]);
465 map.insert("XBUD", vec!["BUX"]);
466 map.insert("XPRA", vec!["PRA"]);
467 map.insert("XWAR", vec!["WSE"]);
468 map.insert("XIST", vec!["ISE"]);
469 map.insert("XSTO", vec!["SFB"]);
471 map.insert("XCSE", vec!["KFB"]);
472 map.insert("XHEL", vec!["HMB"]);
473 map.insert("XICE", vec!["ISB"]);
474 map.insert("XASX", vec!["ASX"]);
476 map.insert("XHKG", vec!["SEHK"]);
477 map.insert("XHKF", vec!["HKFE"]);
478 map.insert("XSES", vec!["SGX"]);
479 map.insert("XOSE", vec!["OSE.JPN"]);
480 map.insert("XTKS", vec!["TSEJ", "TSE.JPN"]);
481 map.insert("XKRX", vec!["KSE", "KRX"]);
482 map.insert("XTAI", vec!["TASE", "TWSE"]);
483 map.insert("XSHG", vec!["SEHKNTL", "SSE"]);
484 map.insert("XSHE", vec!["SEHKSZSE"]);
485 map.insert("XNSE", vec!["NSE"]);
486 map.insert("XBOM", vec!["BSE"]);
487 map.insert("XSFE", vec!["SNFE"]);
489 map.insert("XMEX", vec!["MEXDER"]);
490 map.insert("XJSE", vec!["JSE"]);
492 map.insert("XBOG", vec!["BVC"]);
493 map.insert("XTAE", vec!["TASE"]);
494 map.insert("BVMF", vec!["BVMF"]);
495 map
496 });
497
498#[must_use]
504pub fn is_crypto_contract(contract: &Contract) -> bool {
505 matches!(contract.security_type, SecurityType::Crypto)
506}
507
508#[must_use]
514pub fn derive_crypto_venue(contract: &Contract) -> Venue {
515 if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
516 Venue::from(contract.exchange.as_str())
517 } else {
518 Venue::from("PAXOS")
519 }
520}
521
522#[must_use]
523pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
524 if venue == "OPRA" {
525 return vec!["SMART".to_string()];
526 }
527
528 if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
529 return exchanges
530 .iter()
531 .map(|exchange| (*exchange).to_string())
532 .collect();
533 }
534
535 vec![venue.to_string()]
536}
537
538const VENUES_CASH: &[&str] = &["IDEALPRO"];
540const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
543const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
544const VENUES_FUT: &[&str] = &[
545 "BELFOX",
546 "GLOBEX",
547 "CBOT",
548 "CFE",
549 "CME",
550 "COMEX",
551 "CBOE",
552 "DTB",
553 "EUREX",
554 "HKFE",
555 "ICE",
556 "ICEEU",
557 "ICEEUSOFT",
558 "IDEM",
559 "IPE",
560 "KCBT",
561 "MEXDER",
562 "MGE",
563 "NYBOT",
564 "NYMEX",
565 "OSE.JPN",
566 "SNFE",
567 "SOFFEX",
568 "VRTX",
569 "CMECRYPTO",
570 "NYMEXMETALS",
571 "NYMEXNG",
572 "NYMEXENERGY",
573 "CMEPRECIOUS",
574 "CMECURRENCY",
575 "CMEINDEX",
576 "CMEWEATHER",
577 "CMEINTEREST",
578 "CMEFLOOR",
579 "CBOTFLOOR",
580 "NYMEXFLOOR",
581 "NYBOTFLOOR",
582 "CFEFLOOR",
583 "CMEOPTIONS",
584 "CBOTOPTIONS",
585 "NYMEXOPTIONS",
586 "NYBOTOPTIONS",
587 "ECBOT",
588];
589const VENUES_CFD: &[&str] = &["IBCFD", "SMART"];
590const VENUES_CMDTY: &[&str] = &["IBCMDTY"];
591
592fn venue_matches(venue_str: &str, venues: &[&str]) -> bool {
593 venues.contains(&venue_str)
594 || VENUE_MEMBERS
595 .get(venue_str)
596 .is_some_and(|exchanges| exchanges.iter().any(|exchange| venues.contains(exchange)))
597}
598
599fn is_option_venue(venue: &str) -> bool {
600 venue == "OPRA" || venue_matches(venue, VENUES_OPT)
601}
602
603fn is_canonical_occ_option_symbol(symbol: &str) -> bool {
604 let bytes = symbol.as_bytes();
605 if bytes.len() != 21 || !symbol.is_ascii() {
606 return false;
607 }
608
609 let root = &bytes[..6];
610 let root_len = root.iter().position(|byte| *byte == b' ').unwrap_or(6);
611
612 root_len > 0
613 && root[..root_len].iter().all(u8::is_ascii_graphic)
614 && root[root_len..].iter().all(|byte| *byte == b' ')
615 && bytes[6..12].iter().all(u8::is_ascii_digit)
616 && matches!(bytes[12], b'C' | b'P')
617 && bytes[13..].iter().all(u8::is_ascii_digit)
618}
619
620#[allow(dead_code)]
623const FUTURES_MONTH_CODES: &[(char, &str)] = &[
624 ('F', "01"),
625 ('G', "02"),
626 ('H', "03"),
627 ('J', "04"),
628 ('K', "05"),
629 ('M', "06"),
630 ('N', "07"),
631 ('Q', "08"),
632 ('U', "09"),
633 ('V', "10"),
634 ('X', "11"),
635 ('Z', "12"),
636];
637
638#[must_use]
640pub fn symbol_mic_venue(
641 contract: &Contract,
642 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
643) -> Option<String> {
644 let symbol = contract.symbol.as_str();
645 symbol_to_mic_venue
646 .iter()
647 .find(|(symbol_prefix, _)| symbol.starts_with(symbol_prefix.as_str()))
648 .map(|(_, symbol_venue)| symbol_venue.clone())
649}
650
651pub fn determine_venue_from_contract(
658 contract: &Contract,
659 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
660 convert_exchange_to_mic_venue: bool,
661 valid_exchanges: Option<&str>,
662) -> String {
663 if matches!(contract.security_type, SecurityType::CFD) {
664 return "IBCFD".to_string();
665 }
666
667 if matches!(contract.security_type, SecurityType::Commodity) {
668 return "IBCMDTY".to_string();
669 }
670
671 if let Some(venue) = symbol_mic_venue(contract, symbol_to_mic_venue) {
672 return venue;
673 }
674
675 let mut exchange = if contract.exchange.as_str() == "SMART"
677 && !contract.primary_exchange.as_str().is_empty()
678 && contract.primary_exchange.as_str() != "SMART"
679 {
680 contract.primary_exchange.as_str().to_string()
681 } else {
682 contract.exchange.as_str().to_string()
683 };
684
685 if exchange == "SMART"
686 && let Some(valid_exchanges) = valid_exchanges
687 {
688 let parts: Vec<&str> = valid_exchanges
689 .split(',')
690 .map(str::trim)
691 .filter(|part| !part.is_empty())
692 .collect();
693
694 if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
695 exchange = (*chosen).to_string();
696 } else if let Some(first) = parts.first() {
697 exchange = (*first).to_string();
698 }
699 }
700
701 if convert_exchange_to_mic_venue && let Some(venue) = exchange_to_mic_venue(&exchange) {
702 return venue;
703 }
704
705 exchange
706}
707
708#[must_use]
710pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
711 VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
712 exchanges
713 .contains(&exchange)
714 .then(|| (*venue_member).to_string())
715 })
716}
717
718fn instrument_id_to_contract(
732 instrument_id: InstrumentId,
733 exchange: Option<&str>,
734 futures_year_digits: u8,
735 reference_year: i32,
736 currency: Option<&str>,
737) -> Contract {
738 let venue_str = instrument_id.venue.to_string();
739
740 let derived_exchange = if venue_str == "OPRA" {
741 "SMART"
742 } else {
743 VENUE_MEMBERS
744 .get(venue_str.as_str())
745 .and_then(|exchanges| exchanges.first().copied())
746 .or_else(|| {
747 if venue_matches(venue_str.as_str(), VENUES_CASH)
748 || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
749 || venue_matches(venue_str.as_str(), VENUES_OPT)
750 || venue_matches(venue_str.as_str(), VENUES_FUT)
751 {
752 Some(venue_str.as_str())
753 } else {
754 None
755 }
756 })
757 .unwrap_or("SMART")
758 };
759
760 let exchange_str = exchange.unwrap_or(derived_exchange);
761 let symbol_str = instrument_id.symbol.as_str();
762
763 if let Some(contract) = instrument_id_to_ib_contract_raw(
764 &instrument_id,
765 exchange,
766 futures_year_digits,
767 reference_year,
768 currency,
769 ) {
770 return contract;
771 }
772
773 if venue_matches(venue_str.as_str(), VENUES_CASH)
781 && let Some(captures) = parse_cash_symbol(symbol_str)
782 {
783 return Contract {
784 symbol: Symbol::from(&captures.base),
785 security_type: SecurityType::ForexPair,
786 exchange: Exchange::from(exchange_str),
787 currency: Currency::from(&captures.quote),
788 local_symbol: format!("{}.{}", captures.base, captures.quote),
789 ..Default::default()
790 };
791 }
792
793 if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
795 && let Some(captures) = parse_crypto_symbol(symbol_str)
796 {
797 return Contract {
798 symbol: Symbol::from(&captures.base),
799 security_type: SecurityType::Crypto,
800 exchange: Exchange::from(exchange_str),
801 currency: Currency::from(&captures.quote),
802 local_symbol: format!("{}.{}", captures.base, captures.quote),
803 ..Default::default()
804 };
805 }
806
807 if is_option_venue(venue_str.as_str()) {
809 if venue_str == "OPRA" {
810 return Contract {
811 security_type: SecurityType::Option,
812 exchange: Exchange::from(exchange_str),
813 currency: Currency::from("USD"),
814 local_symbol: symbol_str.to_string(),
815 ..Default::default()
816 };
817 }
818
819 if let Some(opt) = parse_option_symbol(symbol_str) {
820 return Contract {
821 symbol: Symbol::from(&opt.symbol),
822 security_type: SecurityType::Option,
823 exchange: Exchange::from(exchange_str),
824 currency: Currency::from("USD"), local_symbol: opt.local_symbol,
826 last_trade_date_or_contract_month: opt.expiry,
827 strike: opt.strike_value,
828 right: Some(opt.right),
829 ..Default::default()
830 };
831 }
832
833 if let Some(opt) = parse_named_option_symbol(symbol_str) {
834 return Contract {
835 symbol: Symbol::from(&opt.trading_class),
836 security_type: SecurityType::Option,
837 exchange: Exchange::from(exchange_str),
838 currency: Currency::from("USD"),
839 trading_class: opt.trading_class,
840 last_trade_date_or_contract_month: opt.expiry,
841 strike: opt.strike_value,
842 right: Some(opt.right),
843 ..Default::default()
844 };
845 }
846 }
847
848 if venue_matches(venue_str.as_str(), VENUES_FUT) {
850 if let Some(fut) = parse_named_futures_symbol(symbol_str) {
851 return Contract {
852 symbol: Symbol::from(&fut.underlying),
853 security_type: SecurityType::Future,
854 exchange: Exchange::from(exchange_str),
855 currency: Currency::from("USD"),
856 trading_class: fut.trading_class,
857 last_trade_date_or_contract_month: fut.expiry,
858 ..Default::default()
859 };
860 }
861
862 if is_futures_underlying(symbol_str) {
865 return Contract {
866 symbol: Symbol::from(symbol_str),
867 security_type: SecurityType::ContinuousFuture,
868 exchange: Exchange::from(exchange_str),
869 currency: Currency::from("USD"), ..Default::default()
871 };
872 }
873
874 if futures_year_digits == 2
875 && let Some(contract) = futures_contract_from_symbol(
876 symbol_str,
877 SecurityType::FuturesOption,
878 exchange_str,
879 reference_year,
880 currency,
881 )
882 {
883 return contract;
884 }
885
886 if validate_futures_option_symbol(symbol_str).is_some() {
888 return Contract {
889 security_type: SecurityType::FuturesOption,
890 exchange: Exchange::from(exchange_str),
891 currency: Currency::from("USD"),
892 local_symbol: symbol_str.to_string(),
893 ..Default::default()
894 };
895 }
896
897 if futures_year_digits == 2
898 && let Some(contract) = futures_contract_from_symbol(
899 symbol_str,
900 SecurityType::Future,
901 exchange_str,
902 reference_year,
903 currency,
904 )
905 {
906 return contract;
907 }
908
909 if parse_futures_month_and_year(symbol_str).is_some() {
911 return Contract {
912 security_type: SecurityType::Future,
913 exchange: Exchange::from(exchange_str),
914 currency: Currency::from("USD"),
915 local_symbol: symbol_str.to_string(),
916 ..Default::default()
917 };
918 }
919 }
920
921 if venue_matches(venue_str.as_str(), VENUES_CFD) {
923 if let Some(captures) =
924 parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
925 {
926 return Contract {
927 symbol: Symbol::from(&captures.base),
928 security_type: SecurityType::CFD,
929 exchange: Exchange::from("SMART"),
930 currency: Currency::from(&captures.quote),
931 local_symbol: format!("{}.{}", captures.base, captures.quote),
932 ..Default::default()
933 };
934 } else {
935 let symbol_clean = symbol_str.replace('-', " ");
937
938 return Contract {
939 symbol: Symbol::from(&symbol_clean),
940 security_type: SecurityType::CFD,
941 exchange: Exchange::from("SMART"),
942 currency: Currency::from(""),
943 ..Default::default()
944 };
945 }
946 }
947
948 if VENUES_CMDTY.contains(&venue_str.as_str()) {
950 let symbol_clean = symbol_str.replace('-', " ");
951
952 return Contract {
953 symbol: Symbol::from(&symbol_clean),
954 security_type: SecurityType::Commodity,
955 exchange: Exchange::from("SMART"),
956 currency: Currency::from(""),
957 ..Default::default()
958 };
959 }
960
961 if let Some(local_symbol) = symbol_str.strip_prefix('^') {
963 return Contract {
964 symbol: Symbol::from(local_symbol),
965 security_type: SecurityType::Index,
966 exchange: Exchange::from(exchange_str),
967 currency: Currency::from("USD"),
968 local_symbol: local_symbol.into(),
969 ..Default::default()
970 };
971 }
972
973 let symbol_clean = symbol_str.replace('-', " ");
975
976 Contract {
977 symbol: Symbol::from(&symbol_clean),
978 security_type: SecurityType::Stock,
979 exchange: Exchange::from("SMART"),
980 currency: Currency::from(""), primary_exchange: Exchange::from(exchange_str),
982 ..Default::default()
983 }
984}
985
986fn instrument_id_to_ib_contract_raw(
987 instrument_id: &InstrumentId,
988 exchange: Option<&str>,
989 futures_year_digits: u8,
990 reference_year: i32,
991 currency: Option<&str>,
992) -> Option<Contract> {
993 let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
994
995 let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
996 let security_type = IbSecurityType::from_str(sec_type_code)
997 .ok()
998 .map(IbSecurityType::ibapi_security_type)?;
999
1000 let default_exchange =
1001 if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
1002 "SMART"
1003 } else {
1004 venue_exchange.as_str()
1005 };
1006
1007 let exchange_str = exchange.unwrap_or(default_exchange);
1008
1009 if futures_year_digits == 2
1010 && matches!(
1011 security_type,
1012 SecurityType::Future | SecurityType::FuturesOption
1013 )
1014 && let Some(contract) = futures_contract_from_symbol(
1015 local_symbol,
1016 security_type.clone(),
1017 exchange_str,
1018 reference_year,
1019 currency,
1020 )
1021 {
1022 return Some(contract);
1023 }
1024
1025 let contract = match security_type {
1026 SecurityType::Stock => Contract {
1027 security_type,
1028 exchange: Exchange::from("SMART"),
1029 primary_exchange: Exchange::from(exchange_str),
1030 local_symbol: local_symbol.to_string(),
1031 ..Default::default()
1032 },
1033 SecurityType::CFD | SecurityType::Commodity => Contract {
1034 security_type,
1035 exchange: Exchange::from("SMART"),
1036 currency: Currency::from(""),
1037 local_symbol: local_symbol.to_string(),
1038 ..Default::default()
1039 },
1040 SecurityType::Index => Contract {
1041 security_type,
1042 exchange: Exchange::from(exchange_str),
1043 local_symbol: local_symbol.to_string(),
1044 ..Default::default()
1045 },
1046 _ => Contract {
1047 security_type,
1048 exchange: Exchange::from(exchange_str),
1049 local_symbol: local_symbol.to_string(),
1050 ..Default::default()
1051 },
1052 };
1053
1054 Some(contract)
1055}
1056
1057struct CurrencyPair {
1059 base: String,
1060 quote: String,
1061}
1062
1063fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1065 if let Some((base, quote)) = symbol.split_once('/')
1066 && base.len() == 3
1067 && quote.len() == 3
1068 {
1069 return Some(CurrencyPair {
1070 base: base.to_string(),
1071 quote: quote.to_string(),
1072 });
1073 }
1074
1075 None
1076}
1077
1078fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
1080 if let Some((base, quote)) = symbol.split_once('/')
1081 && !base.is_empty()
1082 && base.chars().all(|ch| ch.is_ascii_uppercase())
1083 && quote.len() == 3
1084 && quote.chars().all(|ch| ch.is_ascii_uppercase())
1085 {
1086 return Some(CurrencyPair {
1087 base: base.to_string(),
1088 quote: quote.to_string(),
1089 });
1090 }
1091
1092 None
1093}
1094
1095fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1097 if let Some((base, quote)) = symbol.split_once('.')
1098 && base.len() == 3
1099 && quote.len() == 3
1100 {
1101 return Some(CurrencyPair {
1102 base: base.to_string(),
1103 quote: quote.to_string(),
1104 });
1105 }
1106
1107 None
1108}
1109
1110struct OptionSymbol {
1112 symbol: String,
1113 expiry: String,
1114 right: OptionRight,
1115 local_symbol: String,
1116 strike_value: f64,
1117}
1118
1119fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
1121 if symbol.len() < 21 {
1124 return None;
1125 }
1126
1127 let symbol_part = symbol[..6.min(symbol.len())].trim();
1129 let remaining = &symbol[6.min(symbol.len())..];
1130
1131 if remaining.len() < 15 {
1132 return None;
1133 }
1134
1135 let expiry = &remaining[..6];
1136 let right_char = remaining.chars().nth(6)?;
1137 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1138
1139 let strike_str = &remaining[7..];
1140 if strike_str.len() < 8 {
1141 return None;
1142 }
1143
1144 let strike_value = if strike_str.contains('.') {
1146 strike_str.parse().ok()?
1147 } else {
1148 let strike_int: i32 = strike_str.parse().ok()?;
1150 strike_int as f64 / 1000.0
1151 };
1152
1153 Some(OptionSymbol {
1154 symbol: symbol_part.to_string(),
1155 expiry: expiry.to_string(),
1156 right: ib_option_right_to_option_right(right),
1157 local_symbol: symbol.to_string(),
1158 strike_value,
1159 })
1160}
1161
1162struct NamedOptionSymbol {
1164 trading_class: String,
1165 expiry: String,
1166 right: OptionRight,
1167 strike_value: f64,
1168}
1169
1170struct NamedFuturesSymbol {
1172 underlying: String,
1173 trading_class: String,
1174 expiry: String,
1175}
1176
1177fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
1178 let parts: Vec<&str> = symbol.split_whitespace().collect();
1179 if parts.len() != 3 {
1180 return None;
1181 }
1182
1183 let expiry = parts[2];
1184 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1185 return None;
1186 }
1187
1188 Some(NamedFuturesSymbol {
1189 underlying: parts[0].to_string(),
1190 trading_class: parts[1].to_string(),
1191 expiry: expiry.to_string(),
1192 })
1193}
1194
1195fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1196 let parts: Vec<&str> = symbol.split_whitespace().collect();
1197 if !(parts.len() == 4 || parts.len() == 5) {
1198 return None;
1199 }
1200
1201 let right = IbOptionRight::from_str(parts[0]).ok()?;
1202
1203 let expiry = parts[2];
1204 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1205 return None;
1206 }
1207
1208 Some(NamedOptionSymbol {
1209 trading_class: parts[1].to_string(),
1210 expiry: expiry.to_string(),
1211 right: ib_option_right_to_option_right(right),
1212 strike_value: parts[3].parse::<f64>().ok()?,
1213 })
1214}
1215
1216fn normalize_option_symbol(local_symbol: &str) -> String {
1217 if local_symbol.len() >= 15 {
1218 let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1219 let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1220 && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1221 && suffix[7..].chars().all(|c| c.is_ascii_digit());
1222
1223 if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1224 return format!("{:<6}{}", root.trim_end(), suffix);
1225 }
1226 }
1227
1228 local_symbol.to_string()
1229}
1230
1231fn format_option_strike(strike: f64) -> String {
1232 if strike.fract() == 0.0 {
1233 format!("{strike:.0}")
1234 } else {
1235 format!("{strike}")
1236 }
1237}
1238
1239fn futures_symbol_from_contract(
1240 contract: &Contract,
1241 futures_year_digits: u8,
1242) -> anyhow::Result<String> {
1243 let expiry = contract.last_trade_date_or_contract_month.as_str();
1244 let year = expiry
1245 .get(..4)
1246 .filter(|value| value.bytes().all(|b| b.is_ascii_digit()))
1247 .and_then(|value| value.parse::<i32>().ok())
1248 .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1249
1250 let (futures_symbol, suffix) = if contract.security_type == SecurityType::FuturesOption {
1251 contract
1252 .local_symbol
1253 .split_once(' ')
1254 .map(|(symbol, suffix)| (symbol, Some(suffix)))
1255 .ok_or_else(|| {
1256 anyhow::anyhow!(
1257 "Invalid futures option local symbol '{}'",
1258 contract.local_symbol
1259 )
1260 })?
1261 } else if contract.local_symbol.is_empty() {
1262 let month = expiry
1263 .get(4..6)
1264 .and_then(|value| value.parse::<u8>().ok())
1265 .and_then(futures_month_code)
1266 .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1267
1268 let formatted =
1269 format_futures_symbol(contract.symbol.as_str(), month, year, futures_year_digits)
1270 .ok_or_else(|| {
1271 anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1272 })?;
1273
1274 return Ok(formatted);
1275 } else {
1276 (contract.local_symbol.as_str(), None)
1277 };
1278
1279 let parsed = parse_futures_code(futures_symbol)
1280 .ok_or_else(|| anyhow::anyhow!("Invalid futures local symbol '{futures_symbol}'"))?;
1281 let year = resolve_futures_year(parsed.year_digits, year)
1286 .ok_or_else(|| anyhow::anyhow!("Invalid futures year digits '{}'", parsed.year_digits))?;
1287
1288 let formatted =
1289 format_futures_symbol(parsed.root, parsed.month_code, year, futures_year_digits)
1290 .ok_or_else(|| {
1291 anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1292 })?;
1293
1294 Ok(suffix.map_or(formatted.clone(), |suffix| format!("{formatted} {suffix}")))
1295}
1296
1297fn futures_contract_from_symbol(
1298 symbol: &str,
1299 security_type: SecurityType,
1300 exchange: &str,
1301 reference_year: i32,
1302 currency: Option<&str>,
1303) -> Option<Contract> {
1304 let (futures_symbol, option) = if security_type == SecurityType::FuturesOption {
1305 let (futures_symbol, rest) = symbol.split_once(' ')?;
1306 let mut chars = rest.chars();
1307 let right_char = chars.next()?;
1308 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1309 let strike = chars.as_str().parse::<f64>().ok()?;
1310 (futures_symbol, Some((right, strike)))
1311 } else {
1312 (symbol, None)
1313 };
1314
1315 let parsed = parse_futures_code(futures_symbol)?;
1316 let year = resolve_futures_year(parsed.year_digits, reference_year)?;
1317 let month = futures_month(parsed.month_code)?;
1318
1319 let currency = currency.unwrap_or_else(|| {
1320 log::warn!("Falling back to USD for unresolved IB futures contract {symbol} on {exchange}");
1321 "USD"
1322 });
1323
1324 Some(Contract {
1325 symbol: Symbol::from(parsed.root),
1326 trading_class: parsed.root.to_string(),
1327 security_type,
1328 exchange: Exchange::from(exchange),
1329 currency: Currency::from(currency),
1330 last_trade_date_or_contract_month: format!("{year:04}{month:02}"),
1331 right: option.map(|(right, _)| ib_option_right_to_option_right(right)),
1332 strike: option.map_or(0.0, |(_, strike)| strike),
1333 ..Default::default()
1334 })
1335}
1336
1337fn is_futures_underlying(symbol: &str) -> bool {
1338 symbol.len() <= 3 && symbol.chars().all(char::is_alphabetic)
1339}
1340
1341fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1342 let parsed = parse_futures_code(symbol)?;
1343 Some((
1344 parsed.root.len(),
1345 parsed.month_code,
1346 parsed.year_digits.to_string(),
1347 ))
1348}
1349
1350fn validate_futures_option_symbol(symbol: &str) -> Option<()> {
1351 let (futures_symbol, rest) = symbol.split_once(' ')?;
1352 parse_futures_month_and_year(futures_symbol)?;
1353
1354 let right_char = rest.chars().next()?;
1355 IbOptionRight::from_str(&right_char.to_string()).ok()?;
1356
1357 let strike_str = &rest[1..];
1358 strike_str.parse::<f64>().ok()?;
1359
1360 Some(())
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1366 use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
1367 use rstest::rstest;
1368
1369 use super::{Symbology, exchange_to_mic_venue, possible_exchanges_for_venue};
1370 use crate::config::SymbologyMethod;
1371
1372 #[rstest]
1373 fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1374 let contract = Contract {
1375 symbol: Symbol::from("SPXW"),
1376 security_type: SecurityType::Option,
1377 exchange: Exchange::from("SMART"),
1378 currency: Currency::from("USD"),
1379 local_symbol: "SPXW260313P06630000".to_string(),
1380 last_trade_date_or_contract_month: "260313".to_string(),
1381 right: Some(OptionRight::Put),
1382 strike: 6630.0,
1383 ..Default::default()
1384 };
1385
1386 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1387 .instrument_id(&contract, None)
1388 .unwrap();
1389
1390 assert_eq!(
1391 instrument_id,
1392 InstrumentId::from("SPXW 260313P06630000.SMART")
1393 );
1394 }
1395
1396 #[rstest]
1397 fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1398 let contract = Contract {
1399 symbol: Symbol::from("OESX"),
1400 security_type: SecurityType::Option,
1401 exchange: Exchange::from("EUREX"),
1402 currency: Currency::from("EUR"),
1403 trading_class: "OESX".to_string(),
1404 local_symbol: String::new(),
1405 last_trade_date_or_contract_month: "20260213".to_string(),
1406 right: Some(OptionRight::Call),
1407 strike: 4775.0,
1408 ..Default::default()
1409 };
1410
1411 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1412 .instrument_id(&contract, None)
1413 .unwrap();
1414
1415 assert_eq!(
1416 instrument_id,
1417 InstrumentId::from("C OESX 20260213 4775.EUREX")
1418 );
1419 }
1420
1421 #[rstest]
1422 fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1423 let contract = Contract {
1424 symbol: Symbol::from("EX2"),
1425 security_type: SecurityType::FuturesOption,
1426 exchange: Exchange::from("NYBOT"),
1427 currency: Currency::from("USD"),
1428 local_symbol: "EX2G3 P4080".to_string(),
1429 ..Default::default()
1430 };
1431
1432 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1433 .instrument_id(&contract, None)
1434 .unwrap();
1435
1436 assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1437 }
1438
1439 #[rstest]
1440 #[case(SecurityType::Future, "ESZ6", "ESZ6.XCME")]
1441 #[case(SecurityType::FuturesOption, "ESZ6 P5000", "ESZ6 P5000.XCME")]
1442 fn test_ib_contract_to_instrument_id_keeps_the_local_symbol_year(
1443 #[case] security_type: SecurityType,
1444 #[case] local_symbol: &str,
1445 #[case] expected: &str,
1446 ) {
1447 let contract = Contract {
1448 symbol: Symbol::from("ES"),
1449 security_type,
1450 exchange: Exchange::from("CME"),
1451 currency: Currency::from("USD"),
1452 local_symbol: local_symbol.to_string(),
1453 last_trade_date_or_contract_month: "202612".to_string(),
1454 ..Default::default()
1455 };
1456
1457 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1458 .instrument_id(&contract, Some(Venue::from("XCME")))
1459 .unwrap();
1460
1461 assert_eq!(instrument_id, InstrumentId::from(expected));
1462 }
1463
1464 #[rstest]
1465 #[case(SecurityType::Future, "CLF6", "20251219", "CLF6.XNYM")]
1466 #[case(SecurityType::Future, "ESZ6", "20261218", "ESZ6.XNYM")]
1467 #[case(
1468 SecurityType::FuturesOption,
1469 "CLF6 P7000",
1470 "20251216",
1471 "CLF6 P7000.XNYM"
1472 )]
1473 fn test_local_symbol_year_is_not_rewritten_from_last_trade_date(
1474 #[case] security_type: SecurityType,
1475 #[case] local_symbol: &str,
1476 #[case] last_trade_date: &str,
1477 #[case] expected: &str,
1478 ) {
1479 let contract = Contract {
1480 symbol: Symbol::from("CL"),
1481 security_type,
1482 exchange: Exchange::from("NYMEX"),
1483 currency: Currency::from("USD"),
1484 local_symbol: local_symbol.to_string(),
1485 last_trade_date_or_contract_month: last_trade_date.to_string(),
1486 ..Default::default()
1487 };
1488
1489 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1490 .instrument_id(&contract, Some(Venue::from("XNYM")))
1491 .unwrap();
1492
1493 assert_eq!(instrument_id, InstrumentId::from(expected));
1494 }
1495
1496 #[rstest]
1497 fn test_non_grammar_local_symbol_is_emitted_verbatim() {
1498 let contract = Contract {
1499 symbol: Symbol::from("ZC"),
1500 security_type: SecurityType::Future,
1501 exchange: Exchange::from("CBOT"),
1502 currency: Currency::from("USD"),
1503 local_symbol: "ZC DEC 26".to_string(),
1504 last_trade_date_or_contract_month: "20261214".to_string(),
1505 ..Default::default()
1506 };
1507
1508 let simplified = Symbology::new(SymbologyMethod::Simplified)
1509 .instrument_id(&contract, Some(Venue::from("XCBT")))
1510 .unwrap();
1511 let raw = Symbology::new(SymbologyMethod::Raw)
1512 .instrument_id(&contract, Some(Venue::from("XCBT")))
1513 .unwrap();
1514
1515 assert_eq!(simplified, InstrumentId::from("ZC DEC 26.XCBT"));
1516 assert_eq!(raw, InstrumentId::from("ZC DEC 26=FUT.XCBT"));
1517 }
1518
1519 #[rstest]
1520 fn test_fop_without_local_symbol_falls_back_to_contract_fields() {
1521 let contract = Contract {
1522 symbol: Symbol::from("ES"),
1523 security_type: SecurityType::FuturesOption,
1524 exchange: Exchange::from("CME"),
1525 currency: Currency::from("USD"),
1526 local_symbol: String::new(),
1527 last_trade_date_or_contract_month: "20261218".to_string(),
1528 right: Some(OptionRight::Put),
1529 strike: 5000.0,
1530 ..Default::default()
1531 };
1532
1533 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1534 .instrument_id(&contract, Some(Venue::from("XCME")))
1535 .unwrap();
1536
1537 assert_eq!(instrument_id, InstrumentId::from("ES20261218 P5000.XCME"));
1538 }
1539
1540 #[rstest]
1541 #[case("ESZ6", SecurityType::Future)]
1542 #[case("ESZ26", SecurityType::Future)]
1543 #[case("ESZ2026", SecurityType::Future)]
1544 #[case("ESZ26 P5000", SecurityType::FuturesOption)]
1545 fn test_futures_id_keeps_its_symbol_as_the_contract_local_symbol_outbound(
1546 #[case] symbol: &str,
1547 #[case] security_type: SecurityType,
1548 ) {
1549 let instrument_id = InstrumentId::new(NautilusSymbol::from(symbol), Venue::from("XCME"));
1550
1551 let contract = Symbology::new(SymbologyMethod::Simplified)
1552 .contract(instrument_id, None)
1553 .unwrap();
1554
1555 assert_eq!(contract.security_type, security_type);
1556 assert_eq!(contract.local_symbol.as_str(), symbol);
1557 assert_eq!(contract.exchange.as_str(), "CME");
1558 assert_eq!(contract.currency.as_str(), "USD");
1559 assert!(contract.symbol.as_str().is_empty());
1560 assert!(contract.trading_class.is_empty());
1561 assert!(contract.last_trade_date_or_contract_month.is_empty());
1562 assert_eq!(contract.right, None);
1563 assert_eq!(contract.strike, 0.0);
1564 }
1565
1566 #[rstest]
1567 fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1568 let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1569
1570 let contract = Symbology::new(SymbologyMethod::Simplified)
1571 .contract(instrument_id, None)
1572 .unwrap();
1573
1574 assert_eq!(contract.security_type, SecurityType::Option);
1575 assert_eq!(contract.exchange.as_str(), "EUREX");
1576 assert_eq!(contract.symbol.as_str(), "OESX");
1577 assert_eq!(contract.trading_class.as_str(), "OESX");
1578 assert_eq!(
1579 contract.last_trade_date_or_contract_month.as_str(),
1580 "20260213"
1581 );
1582 assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1583 assert_eq!(contract.strike, 4775.0);
1584 }
1585
1586 #[rstest]
1587 fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1588 let instrument_id = InstrumentId::from("AAPL 230217P00155000.SMART");
1589
1590 let contract = Symbology::new(SymbologyMethod::Simplified)
1591 .contract(instrument_id, None)
1592 .unwrap();
1593
1594 assert_eq!(contract.security_type, SecurityType::Option);
1595 assert_eq!(contract.exchange.as_str(), "SMART");
1596 assert_eq!(contract.symbol.as_str(), "AAPL");
1597 assert_eq!(contract.local_symbol.as_str(), "AAPL 230217P00155000");
1598 assert_eq!(
1599 contract.last_trade_date_or_contract_month.as_str(),
1600 "230217"
1601 );
1602 assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1603 assert_eq!(contract.strike, 155.0);
1604 }
1605
1606 #[rstest]
1607 fn test_possible_exchanges_for_opra_routes_to_smart() {
1608 assert_eq!(
1609 possible_exchanges_for_venue("OPRA"),
1610 vec!["SMART".to_string()]
1611 );
1612 }
1613
1614 #[rstest]
1615 fn test_opra_occ_option_default_contract_routes_to_smart() {
1616 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1617
1618 let contract = Symbology::new(SymbologyMethod::Simplified)
1619 .contract(instrument_id, None)
1620 .unwrap();
1621
1622 assert_eq!(contract.security_type, SecurityType::Option);
1623 assert_eq!(contract.exchange.as_str(), "SMART");
1624 assert!(contract.symbol.as_str().is_empty());
1625 assert_eq!(contract.currency.as_str(), "USD");
1626 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1627 assert!(contract.last_trade_date_or_contract_month.is_empty());
1628 assert!(contract.right.is_none());
1629 assert_eq!(contract.strike, 0.0);
1630 }
1631
1632 #[rstest]
1633 fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1634 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1635 let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1636
1637 assert_eq!(exchanges, vec!["SMART".to_string()]);
1638
1639 let contract = Symbology::new(SymbologyMethod::Simplified)
1640 .contract(instrument_id, exchanges.first().map(String::as_str))
1641 .unwrap();
1642
1643 assert_eq!(contract.security_type, SecurityType::Option);
1644 assert_eq!(contract.exchange.as_str(), "SMART");
1645 assert!(contract.symbol.as_str().is_empty());
1646 assert_eq!(contract.currency.as_str(), "USD");
1647 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1648 assert!(contract.last_trade_date_or_contract_month.is_empty());
1649 assert!(contract.right.is_none());
1650 assert_eq!(contract.strike, 0.0);
1651 }
1652
1653 #[rstest]
1654 fn test_opra_raw_option_default_contract_routes_to_smart() {
1655 let instrument_id = InstrumentId::from("SPY 240319P00511000=OPT.OPRA");
1656
1657 let contract = Symbology::new(SymbologyMethod::Raw)
1658 .contract(instrument_id, None)
1659 .unwrap();
1660
1661 assert_eq!(contract.security_type, SecurityType::Option);
1662 assert_eq!(contract.exchange.as_str(), "SMART");
1663 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1664 }
1665
1666 #[rstest]
1667 #[case("SPY 240319P00511000.OPRA")]
1668 #[case("SPY 240319P00511000=OPT.OPRA")]
1669 fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1670 let instrument_id = InstrumentId::from(value);
1671
1672 let method = if value.contains("=OPT") {
1673 SymbologyMethod::Raw
1674 } else {
1675 SymbologyMethod::Simplified
1676 };
1677
1678 let contract = Symbology::new(method)
1679 .contract(instrument_id, Some("CBOE"))
1680 .unwrap();
1681
1682 assert_eq!(contract.security_type, SecurityType::Option);
1683 assert_eq!(contract.exchange.as_str(), "CBOE");
1684 }
1685
1686 #[rstest]
1687 #[case("AAPL.OPRA")]
1688 #[case("SPY abcdefP00511000.OPRA")]
1689 #[case("P SPY 20240319 511.OPRA")]
1690 fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1691 let instrument_id = InstrumentId::from(value);
1692
1693 let result = Symbology::new(SymbologyMethod::Simplified).contract(instrument_id, None);
1694
1695 assert!(result.is_err());
1696 }
1697
1698 #[rstest]
1699 fn test_opra_route_does_not_create_reverse_smart_mapping() {
1700 assert_eq!(exchange_to_mic_venue("SMART"), None);
1701 }
1702
1703 #[rstest]
1704 fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1705 let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1706
1707 let contract = Symbology::new(SymbologyMethod::Simplified)
1708 .contract(instrument_id, None)
1709 .unwrap();
1710
1711 assert_eq!(contract.security_type, SecurityType::Future);
1712 assert_eq!(contract.exchange.as_str(), "EUREX");
1713 assert_eq!(contract.symbol.as_str(), "ESTX50");
1714 assert_eq!(contract.trading_class.as_str(), "FESX");
1715 assert_eq!(
1716 contract.last_trade_date_or_contract_month.as_str(),
1717 "20240315"
1718 );
1719 }
1720
1721 #[rstest]
1722 fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1723 let instrument_id = InstrumentId::from("YMM6.XCBT");
1724
1725 let contract = Symbology::new(SymbologyMethod::Simplified)
1726 .contract(instrument_id, None)
1727 .unwrap();
1728
1729 assert_eq!(contract.security_type, SecurityType::Future);
1730 assert_eq!(contract.exchange.as_str(), "CBOT");
1731 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1732 assert!(contract.symbol.as_str().is_empty());
1733 assert!(contract.last_trade_date_or_contract_month.is_empty());
1734 }
1735
1736 #[rstest]
1737 fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1738 let instrument_id = InstrumentId::from("OESXH6.XEUR");
1739
1740 let contract = Symbology::new(SymbologyMethod::Simplified)
1741 .contract(instrument_id, None)
1742 .unwrap();
1743
1744 assert_eq!(contract.security_type, SecurityType::Future);
1745 assert_eq!(contract.exchange.as_str(), "DTB");
1746 assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1747 assert!(contract.symbol.as_str().is_empty());
1748 assert!(contract.last_trade_date_or_contract_month.is_empty());
1749 }
1750
1751 #[rstest]
1752 fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1753 let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1754
1755 let contract = Symbology::new(SymbologyMethod::Simplified)
1756 .contract(instrument_id, None)
1757 .unwrap();
1758
1759 assert_eq!(contract.security_type, SecurityType::FuturesOption);
1760 assert_eq!(contract.exchange.as_str(), "CBOT");
1761 assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1762 assert!(contract.symbol.as_str().is_empty());
1763 assert!(contract.last_trade_date_or_contract_month.is_empty());
1764 assert_eq!(contract.right, None);
1765 assert_eq!(contract.strike, 0.0);
1766 }
1767
1768 #[rstest]
1769 fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1770 let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1771
1772 let contract = Symbology::new(SymbologyMethod::Simplified)
1773 .contract(instrument_id, None)
1774 .unwrap();
1775
1776 assert_eq!(contract.security_type, SecurityType::CFD);
1777 assert_eq!(contract.exchange.as_str(), "SMART");
1778 assert_eq!(contract.symbol.as_str(), "EUR");
1779 assert_eq!(contract.currency.as_str(), "USD");
1780 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1781 }
1782
1783 #[rstest]
1784 #[case(
1785 SymbologyMethod::Simplified,
1786 SecurityType::CFD,
1787 "IBDE30",
1788 "EUR",
1789 "IBDE30.IBCFD"
1790 )]
1791 #[case(
1792 SymbologyMethod::Raw,
1793 SecurityType::CFD,
1794 "IBDE30",
1795 "EUR",
1796 "IBDE30=CFD.IBCFD"
1797 )]
1798 #[case(
1799 SymbologyMethod::Simplified,
1800 SecurityType::Commodity,
1801 "XAUUSD",
1802 "GBP",
1803 "XAUUSD.IBCMDTY"
1804 )]
1805 #[case(
1806 SymbologyMethod::Raw,
1807 SecurityType::Commodity,
1808 "XAUUSD",
1809 "GBP",
1810 "XAUUSD=CMDTY.IBCMDTY"
1811 )]
1812 fn test_non_cash_pseudo_venue_round_trip_defers_currency_to_contract_details(
1813 #[case] method: SymbologyMethod,
1814 #[case] security_type: SecurityType,
1815 #[case] symbol: &str,
1816 #[case] currency: &str,
1817 #[case] expected_instrument_id: &str,
1818 ) {
1819 let contract = Contract {
1820 symbol: Symbol::from(symbol),
1821 security_type: security_type.clone(),
1822 exchange: Exchange::from("SMART"),
1823 currency: Currency::from(currency),
1824 local_symbol: symbol.to_string(),
1825 ..Default::default()
1826 };
1827
1828 let symbology = Symbology::new(method);
1829
1830 let instrument_id = symbology.instrument_id(&contract, None).unwrap();
1831 let unresolved = symbology.contract(instrument_id, None).unwrap();
1832 let round_trip_id = symbology.instrument_id(&unresolved, None).unwrap();
1833
1834 assert_eq!(instrument_id, InstrumentId::from(expected_instrument_id));
1835 assert_eq!(unresolved.security_type, security_type);
1836 assert_eq!(unresolved.exchange.as_str(), "SMART");
1837 assert!(unresolved.currency.as_str().is_empty());
1838 assert_eq!(round_trip_id, instrument_id);
1839 }
1840
1841 #[rstest]
1842 fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1843 let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1844
1845 let contract = Symbology::new(SymbologyMethod::Simplified)
1846 .contract(instrument_id, None)
1847 .unwrap();
1848
1849 assert_eq!(contract.security_type, SecurityType::Crypto);
1850 assert_eq!(contract.exchange.as_str(), "PAXOS");
1851 assert_eq!(contract.symbol.as_str(), "DOGE");
1852 assert_eq!(contract.currency.as_str(), "USD");
1853 assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1854 }
1855
1856 #[rstest]
1857 fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1858 let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1860
1861 let contract = Symbology::new(SymbologyMethod::Simplified)
1862 .contract(instrument_id, None)
1863 .unwrap();
1864
1865 assert_eq!(contract.security_type, SecurityType::Crypto);
1866 assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1867 assert_eq!(contract.symbol.as_str(), "BTC");
1868 assert_eq!(contract.currency.as_str(), "USD");
1869 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1870 }
1871
1872 #[rstest]
1873 fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1874 let contract = Contract {
1876 symbol: Symbol::from("BTC"),
1877 security_type: SecurityType::Crypto,
1878 exchange: Exchange::from("ZEROHASH"),
1879 currency: Currency::from("USD"),
1880 local_symbol: "BTC.USD".to_string(),
1881 ..Default::default()
1882 };
1883
1884 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1885 .instrument_id(&contract, None)
1886 .unwrap();
1887
1888 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1889 }
1890
1891 #[rstest]
1892 fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1893 let contract = Contract {
1896 symbol: Symbol::from("DOGE"),
1897 security_type: SecurityType::Crypto,
1898 currency: Currency::from("USD"),
1899 local_symbol: "DOGE.USD".to_string(),
1900 ..Default::default()
1901 };
1902
1903 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1904 .instrument_id(&contract, None)
1905 .unwrap();
1906
1907 assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1908 }
1909
1910 #[rstest]
1911 fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1912 let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1914
1915 let contract = Symbology::new(SymbologyMethod::Simplified)
1916 .contract(instrument_id, None)
1917 .unwrap();
1918
1919 assert_eq!(contract.security_type, SecurityType::Crypto);
1920 assert_eq!(contract.exchange.as_str(), "PAXOS");
1921 assert_eq!(contract.symbol.as_str(), "BTC");
1922 assert_eq!(contract.currency.as_str(), "USD");
1923 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1924 }
1925
1926 #[rstest]
1927 fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1928 let contract = Contract {
1931 symbol: Symbol::from("BTC"),
1932 security_type: SecurityType::Crypto,
1933 exchange: Exchange::from("PAXOS"),
1934 currency: Currency::from("USD"),
1935 local_symbol: "BTC.USD".to_string(),
1936 ..Default::default()
1937 };
1938
1939 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1940 .instrument_id(&contract, None)
1941 .unwrap();
1942
1943 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1944 }
1945
1946 #[rstest]
1947 fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1948 let instrument_id = InstrumentId::from("ES.XCME");
1949
1950 let contract = Symbology::new(SymbologyMethod::Simplified)
1951 .contract(instrument_id, None)
1952 .unwrap();
1953
1954 assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1955 assert_eq!(contract.exchange.as_str(), "CME");
1956 assert_eq!(contract.symbol.as_str(), "ES");
1957 }
1958
1959 #[rstest]
1960 fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1961 let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1962
1963 let contract = Symbology::new(SymbologyMethod::Simplified)
1964 .contract(instrument_id, None)
1965 .unwrap();
1966
1967 assert_eq!(contract.security_type, SecurityType::Stock);
1968 assert_eq!(contract.exchange.as_str(), "SMART");
1969 assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1970 assert_eq!(contract.local_symbol.as_str(), "AAPL");
1971 assert!(contract.symbol.as_str().is_empty());
1972 }
1973
1974 #[rstest]
1975 fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1976 let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1977
1978 let contract = Symbology::new(SymbologyMethod::Simplified)
1979 .contract(instrument_id, None)
1980 .unwrap();
1981
1982 assert_eq!(contract.security_type, SecurityType::ForexPair);
1983 assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1984 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1985 assert!(contract.symbol.as_str().is_empty());
1986 }
1987
1988 #[rstest]
1989 fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1990 let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1991
1992 let contract = Symbology::new(SymbologyMethod::Simplified)
1993 .contract(instrument_id, Some("CBOT"))
1994 .unwrap();
1995
1996 assert_eq!(contract.security_type, SecurityType::Future);
1997 assert_eq!(contract.exchange.as_str(), "CBOT");
1998 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1999 assert!(contract.symbol.as_str().is_empty());
2000 assert!(contract.last_trade_date_or_contract_month.is_empty());
2001 }
2002
2003 #[rstest]
2004 fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
2005 let instrument_id = InstrumentId::from("IUSA.IBIS");
2006
2007 let contract = Symbology::new(SymbologyMethod::Simplified)
2008 .contract(instrument_id, Some("IBIS"))
2009 .unwrap();
2010
2011 assert_eq!(contract.security_type, SecurityType::Stock);
2012 assert_eq!(contract.symbol.as_str(), "IUSA");
2013 assert_eq!(contract.exchange.as_str(), "SMART");
2014 assert_eq!(contract.primary_exchange.as_str(), "IBIS");
2015 assert!(contract.currency.as_str().is_empty());
2016 assert!(contract.local_symbol.is_empty());
2017 }
2018}