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];
637pub fn determine_venue_from_contract(
644 contract: &Contract,
645 symbol_to_mic_venue: &std::collections::HashMap<String, String>,
646 convert_exchange_to_mic_venue: bool,
647 valid_exchanges: Option<&str>,
648) -> String {
649 if matches!(contract.security_type, SecurityType::CFD) {
650 return "IBCFD".to_string();
651 }
652
653 if matches!(contract.security_type, SecurityType::Commodity) {
654 return "IBCMDTY".to_string();
655 }
656
657 if !symbol_to_mic_venue.is_empty() {
658 let symbol = contract.symbol.as_str();
659 for (symbol_prefix, symbol_venue) in symbol_to_mic_venue {
660 if symbol.starts_with(symbol_prefix) {
661 return symbol_venue.clone();
662 }
663 }
664 }
665
666 let mut exchange = if contract.exchange.as_str() == "SMART"
668 && !contract.primary_exchange.as_str().is_empty()
669 && contract.primary_exchange.as_str() != "SMART"
670 {
671 contract.primary_exchange.as_str().to_string()
672 } else {
673 contract.exchange.as_str().to_string()
674 };
675
676 if exchange == "SMART"
677 && let Some(valid_exchanges) = valid_exchanges
678 {
679 let parts: Vec<&str> = valid_exchanges
680 .split(',')
681 .map(str::trim)
682 .filter(|part| !part.is_empty())
683 .collect();
684
685 if let Some(chosen) = parts.iter().find(|part| **part != "SMART") {
686 exchange = (*chosen).to_string();
687 } else if let Some(first) = parts.first() {
688 exchange = (*first).to_string();
689 }
690 }
691
692 if convert_exchange_to_mic_venue && let Some(venue) = exchange_to_mic_venue(&exchange) {
693 return venue;
694 }
695
696 exchange
697}
698
699#[must_use]
701pub fn exchange_to_mic_venue(exchange: &str) -> Option<String> {
702 VENUE_MEMBERS.iter().find_map(|(venue_member, exchanges)| {
703 exchanges
704 .contains(&exchange)
705 .then(|| (*venue_member).to_string())
706 })
707}
708
709fn instrument_id_to_contract(
723 instrument_id: InstrumentId,
724 exchange: Option<&str>,
725 futures_year_digits: u8,
726 reference_year: i32,
727 currency: Option<&str>,
728) -> Contract {
729 let venue_str = instrument_id.venue.to_string();
730
731 let derived_exchange = if venue_str == "OPRA" {
732 "SMART"
733 } else {
734 VENUE_MEMBERS
735 .get(venue_str.as_str())
736 .and_then(|exchanges| exchanges.first().copied())
737 .or_else(|| {
738 if venue_matches(venue_str.as_str(), VENUES_CASH)
739 || venue_matches(venue_str.as_str(), VENUES_CRYPTO)
740 || venue_matches(venue_str.as_str(), VENUES_OPT)
741 || venue_matches(venue_str.as_str(), VENUES_FUT)
742 {
743 Some(venue_str.as_str())
744 } else {
745 None
746 }
747 })
748 .unwrap_or("SMART")
749 };
750
751 let exchange_str = exchange.unwrap_or(derived_exchange);
752 let symbol_str = instrument_id.symbol.as_str();
753
754 if let Some(contract) = instrument_id_to_ib_contract_raw(
755 &instrument_id,
756 exchange,
757 futures_year_digits,
758 reference_year,
759 currency,
760 ) {
761 return contract;
762 }
763
764 if venue_matches(venue_str.as_str(), VENUES_CASH)
772 && let Some(captures) = parse_cash_symbol(symbol_str)
773 {
774 return Contract {
775 symbol: Symbol::from(&captures.base),
776 security_type: SecurityType::ForexPair,
777 exchange: Exchange::from(exchange_str),
778 currency: Currency::from(&captures.quote),
779 local_symbol: format!("{}.{}", captures.base, captures.quote),
780 ..Default::default()
781 };
782 }
783
784 if venue_matches(venue_str.as_str(), VENUES_CRYPTO)
786 && let Some(captures) = parse_crypto_symbol(symbol_str)
787 {
788 return Contract {
789 symbol: Symbol::from(&captures.base),
790 security_type: SecurityType::Crypto,
791 exchange: Exchange::from(exchange_str),
792 currency: Currency::from(&captures.quote),
793 local_symbol: format!("{}.{}", captures.base, captures.quote),
794 ..Default::default()
795 };
796 }
797
798 if is_option_venue(venue_str.as_str()) {
800 if venue_str == "OPRA" {
801 return Contract {
802 security_type: SecurityType::Option,
803 exchange: Exchange::from(exchange_str),
804 currency: Currency::from("USD"),
805 local_symbol: symbol_str.to_string(),
806 ..Default::default()
807 };
808 }
809
810 if let Some(opt) = parse_option_symbol(symbol_str) {
811 return Contract {
812 symbol: Symbol::from(&opt.symbol),
813 security_type: SecurityType::Option,
814 exchange: Exchange::from(exchange_str),
815 currency: Currency::from("USD"), local_symbol: opt.local_symbol,
817 last_trade_date_or_contract_month: opt.expiry,
818 strike: opt.strike_value,
819 right: Some(opt.right),
820 ..Default::default()
821 };
822 }
823
824 if let Some(opt) = parse_named_option_symbol(symbol_str) {
825 return Contract {
826 symbol: Symbol::from(&opt.trading_class),
827 security_type: SecurityType::Option,
828 exchange: Exchange::from(exchange_str),
829 currency: Currency::from("USD"),
830 trading_class: opt.trading_class,
831 last_trade_date_or_contract_month: opt.expiry,
832 strike: opt.strike_value,
833 right: Some(opt.right),
834 ..Default::default()
835 };
836 }
837 }
838
839 if venue_matches(venue_str.as_str(), VENUES_FUT) {
841 if let Some(fut) = parse_named_futures_symbol(symbol_str) {
842 return Contract {
843 symbol: Symbol::from(&fut.underlying),
844 security_type: SecurityType::Future,
845 exchange: Exchange::from(exchange_str),
846 currency: Currency::from("USD"),
847 trading_class: fut.trading_class,
848 last_trade_date_or_contract_month: fut.expiry,
849 ..Default::default()
850 };
851 }
852
853 if is_futures_underlying(symbol_str) {
856 return Contract {
857 symbol: Symbol::from(symbol_str),
858 security_type: SecurityType::ContinuousFuture,
859 exchange: Exchange::from(exchange_str),
860 currency: Currency::from("USD"), ..Default::default()
862 };
863 }
864
865 if futures_year_digits == 2
866 && let Some(contract) = futures_contract_from_symbol(
867 symbol_str,
868 SecurityType::FuturesOption,
869 exchange_str,
870 reference_year,
871 currency,
872 )
873 {
874 return contract;
875 }
876
877 if validate_futures_option_symbol(symbol_str).is_some() {
879 return Contract {
880 security_type: SecurityType::FuturesOption,
881 exchange: Exchange::from(exchange_str),
882 currency: Currency::from("USD"),
883 local_symbol: symbol_str.to_string(),
884 ..Default::default()
885 };
886 }
887
888 if futures_year_digits == 2
889 && let Some(contract) = futures_contract_from_symbol(
890 symbol_str,
891 SecurityType::Future,
892 exchange_str,
893 reference_year,
894 currency,
895 )
896 {
897 return contract;
898 }
899
900 if parse_futures_month_and_year(symbol_str).is_some() {
902 return Contract {
903 security_type: SecurityType::Future,
904 exchange: Exchange::from(exchange_str),
905 currency: Currency::from("USD"),
906 local_symbol: symbol_str.to_string(),
907 ..Default::default()
908 };
909 }
910 }
911
912 if venue_matches(venue_str.as_str(), VENUES_CFD) {
914 if let Some(captures) =
915 parse_cash_symbol(symbol_str).or_else(|| parse_cfd_cash_symbol(symbol_str))
916 {
917 return Contract {
918 symbol: Symbol::from(&captures.base),
919 security_type: SecurityType::CFD,
920 exchange: Exchange::from("SMART"),
921 currency: Currency::from(&captures.quote),
922 local_symbol: format!("{}.{}", captures.base, captures.quote),
923 ..Default::default()
924 };
925 } else {
926 let symbol_clean = symbol_str.replace('-', " ");
928
929 return Contract {
930 symbol: Symbol::from(&symbol_clean),
931 security_type: SecurityType::CFD,
932 exchange: Exchange::from("SMART"),
933 currency: Currency::from(""),
934 ..Default::default()
935 };
936 }
937 }
938
939 if VENUES_CMDTY.contains(&venue_str.as_str()) {
941 let symbol_clean = symbol_str.replace('-', " ");
942
943 return Contract {
944 symbol: Symbol::from(&symbol_clean),
945 security_type: SecurityType::Commodity,
946 exchange: Exchange::from("SMART"),
947 currency: Currency::from(""),
948 ..Default::default()
949 };
950 }
951
952 if let Some(local_symbol) = symbol_str.strip_prefix('^') {
954 return Contract {
955 symbol: Symbol::from(local_symbol),
956 security_type: SecurityType::Index,
957 exchange: Exchange::from(exchange_str),
958 currency: Currency::from("USD"),
959 local_symbol: local_symbol.into(),
960 ..Default::default()
961 };
962 }
963
964 let symbol_clean = symbol_str.replace('-', " ");
966
967 Contract {
968 symbol: Symbol::from(&symbol_clean),
969 security_type: SecurityType::Stock,
970 exchange: Exchange::from("SMART"),
971 currency: Currency::from(""), primary_exchange: Exchange::from(exchange_str),
973 ..Default::default()
974 }
975}
976
977fn instrument_id_to_ib_contract_raw(
978 instrument_id: &InstrumentId,
979 exchange: Option<&str>,
980 futures_year_digits: u8,
981 reference_year: i32,
982 currency: Option<&str>,
983) -> Option<Contract> {
984 let (local_symbol, sec_type_code) = instrument_id.symbol.as_str().rsplit_once('=')?;
985
986 let venue_exchange = instrument_id.venue.as_str().replace('/', ".");
987 let security_type = IbSecurityType::from_str(sec_type_code)
988 .ok()
989 .map(IbSecurityType::ibapi_security_type)?;
990
991 let default_exchange =
992 if security_type == SecurityType::Option && instrument_id.venue.as_str() == "OPRA" {
993 "SMART"
994 } else {
995 venue_exchange.as_str()
996 };
997
998 let exchange_str = exchange.unwrap_or(default_exchange);
999
1000 if futures_year_digits == 2
1001 && matches!(
1002 security_type,
1003 SecurityType::Future | SecurityType::FuturesOption
1004 )
1005 && let Some(contract) = futures_contract_from_symbol(
1006 local_symbol,
1007 security_type.clone(),
1008 exchange_str,
1009 reference_year,
1010 currency,
1011 )
1012 {
1013 return Some(contract);
1014 }
1015
1016 let contract = match security_type {
1017 SecurityType::Stock => Contract {
1018 security_type,
1019 exchange: Exchange::from("SMART"),
1020 primary_exchange: Exchange::from(exchange_str),
1021 local_symbol: local_symbol.to_string(),
1022 ..Default::default()
1023 },
1024 SecurityType::CFD | SecurityType::Commodity => Contract {
1025 security_type,
1026 exchange: Exchange::from("SMART"),
1027 currency: Currency::from(""),
1028 local_symbol: local_symbol.to_string(),
1029 ..Default::default()
1030 },
1031 SecurityType::Index => Contract {
1032 security_type,
1033 exchange: Exchange::from(exchange_str),
1034 local_symbol: local_symbol.to_string(),
1035 ..Default::default()
1036 },
1037 _ => Contract {
1038 security_type,
1039 exchange: Exchange::from(exchange_str),
1040 local_symbol: local_symbol.to_string(),
1041 ..Default::default()
1042 },
1043 };
1044
1045 Some(contract)
1046}
1047
1048struct CurrencyPair {
1050 base: String,
1051 quote: String,
1052}
1053
1054fn parse_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1056 if let Some((base, quote)) = symbol.split_once('/')
1057 && base.len() == 3
1058 && quote.len() == 3
1059 {
1060 return Some(CurrencyPair {
1061 base: base.to_string(),
1062 quote: quote.to_string(),
1063 });
1064 }
1065
1066 None
1067}
1068
1069fn parse_crypto_symbol(symbol: &str) -> Option<CurrencyPair> {
1071 if let Some((base, quote)) = symbol.split_once('/')
1072 && !base.is_empty()
1073 && base.chars().all(|ch| ch.is_ascii_uppercase())
1074 && quote.len() == 3
1075 && quote.chars().all(|ch| ch.is_ascii_uppercase())
1076 {
1077 return Some(CurrencyPair {
1078 base: base.to_string(),
1079 quote: quote.to_string(),
1080 });
1081 }
1082
1083 None
1084}
1085
1086fn parse_cfd_cash_symbol(symbol: &str) -> Option<CurrencyPair> {
1088 if let Some((base, quote)) = symbol.split_once('.')
1089 && base.len() == 3
1090 && quote.len() == 3
1091 {
1092 return Some(CurrencyPair {
1093 base: base.to_string(),
1094 quote: quote.to_string(),
1095 });
1096 }
1097
1098 None
1099}
1100
1101struct OptionSymbol {
1103 symbol: String,
1104 expiry: String,
1105 right: OptionRight,
1106 local_symbol: String,
1107 strike_value: f64,
1108}
1109
1110fn parse_option_symbol(symbol: &str) -> Option<OptionSymbol> {
1112 if symbol.len() < 21 {
1115 return None;
1116 }
1117
1118 let symbol_part = symbol[..6.min(symbol.len())].trim();
1120 let remaining = &symbol[6.min(symbol.len())..];
1121
1122 if remaining.len() < 15 {
1123 return None;
1124 }
1125
1126 let expiry = &remaining[..6];
1127 let right_char = remaining.chars().nth(6)?;
1128 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1129
1130 let strike_str = &remaining[7..];
1131 if strike_str.len() < 8 {
1132 return None;
1133 }
1134
1135 let strike_value = if strike_str.contains('.') {
1137 strike_str.parse().ok()?
1138 } else {
1139 let strike_int: i32 = strike_str.parse().ok()?;
1141 strike_int as f64 / 1000.0
1142 };
1143
1144 Some(OptionSymbol {
1145 symbol: symbol_part.to_string(),
1146 expiry: expiry.to_string(),
1147 right: ib_option_right_to_option_right(right),
1148 local_symbol: symbol.to_string(),
1149 strike_value,
1150 })
1151}
1152
1153struct NamedOptionSymbol {
1155 trading_class: String,
1156 expiry: String,
1157 right: OptionRight,
1158 strike_value: f64,
1159}
1160
1161struct NamedFuturesSymbol {
1163 underlying: String,
1164 trading_class: String,
1165 expiry: String,
1166}
1167
1168fn parse_named_futures_symbol(symbol: &str) -> Option<NamedFuturesSymbol> {
1169 let parts: Vec<&str> = symbol.split_whitespace().collect();
1170 if parts.len() != 3 {
1171 return None;
1172 }
1173
1174 let expiry = parts[2];
1175 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1176 return None;
1177 }
1178
1179 Some(NamedFuturesSymbol {
1180 underlying: parts[0].to_string(),
1181 trading_class: parts[1].to_string(),
1182 expiry: expiry.to_string(),
1183 })
1184}
1185
1186fn parse_named_option_symbol(symbol: &str) -> Option<NamedOptionSymbol> {
1187 let parts: Vec<&str> = symbol.split_whitespace().collect();
1188 if !(parts.len() == 4 || parts.len() == 5) {
1189 return None;
1190 }
1191
1192 let right = IbOptionRight::from_str(parts[0]).ok()?;
1193
1194 let expiry = parts[2];
1195 if expiry.len() != 8 || !expiry.chars().all(|c| c.is_ascii_digit()) {
1196 return None;
1197 }
1198
1199 Some(NamedOptionSymbol {
1200 trading_class: parts[1].to_string(),
1201 expiry: expiry.to_string(),
1202 right: ib_option_right_to_option_right(right),
1203 strike_value: parts[3].parse::<f64>().ok()?,
1204 })
1205}
1206
1207fn normalize_option_symbol(local_symbol: &str) -> String {
1208 if local_symbol.len() >= 15 {
1209 let (root, suffix) = local_symbol.split_at(local_symbol.len() - 15);
1210 let is_occ_suffix = suffix[..6].chars().all(|c| c.is_ascii_digit())
1211 && matches!(suffix.chars().nth(6), Some('C' | 'P'))
1212 && suffix[7..].chars().all(|c| c.is_ascii_digit());
1213
1214 if !root.is_empty() && root.len() <= 6 && is_occ_suffix {
1215 return format!("{:<6}{}", root.trim_end(), suffix);
1216 }
1217 }
1218
1219 local_symbol.to_string()
1220}
1221
1222fn format_option_strike(strike: f64) -> String {
1223 if strike.fract() == 0.0 {
1224 format!("{strike:.0}")
1225 } else {
1226 format!("{strike}")
1227 }
1228}
1229
1230fn futures_symbol_from_contract(
1231 contract: &Contract,
1232 futures_year_digits: u8,
1233) -> anyhow::Result<String> {
1234 let expiry = contract.last_trade_date_or_contract_month.as_str();
1235 let year = expiry
1236 .get(..4)
1237 .filter(|value| value.bytes().all(|b| b.is_ascii_digit()))
1238 .and_then(|value| value.parse::<i32>().ok())
1239 .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1240
1241 let (futures_symbol, suffix) = if contract.security_type == SecurityType::FuturesOption {
1242 contract
1243 .local_symbol
1244 .split_once(' ')
1245 .map(|(symbol, suffix)| (symbol, Some(suffix)))
1246 .ok_or_else(|| {
1247 anyhow::anyhow!(
1248 "Invalid futures option local symbol '{}'",
1249 contract.local_symbol
1250 )
1251 })?
1252 } else if contract.local_symbol.is_empty() {
1253 let month = expiry
1254 .get(4..6)
1255 .and_then(|value| value.parse::<u8>().ok())
1256 .and_then(futures_month_code)
1257 .ok_or_else(|| anyhow::anyhow!("Invalid futures contract month '{expiry}'"))?;
1258
1259 let formatted =
1260 format_futures_symbol(contract.symbol.as_str(), month, year, futures_year_digits)
1261 .ok_or_else(|| {
1262 anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1263 })?;
1264
1265 return Ok(formatted);
1266 } else {
1267 (contract.local_symbol.as_str(), None)
1268 };
1269
1270 let parsed = parse_futures_code(futures_symbol)
1271 .ok_or_else(|| anyhow::anyhow!("Invalid futures local symbol '{futures_symbol}'"))?;
1272 let year = resolve_futures_year(parsed.year_digits, year)
1277 .ok_or_else(|| anyhow::anyhow!("Invalid futures year digits '{}'", parsed.year_digits))?;
1278
1279 let formatted =
1280 format_futures_symbol(parsed.root, parsed.month_code, year, futures_year_digits)
1281 .ok_or_else(|| {
1282 anyhow::anyhow!("Invalid futures year digit count {futures_year_digits}")
1283 })?;
1284
1285 Ok(suffix.map_or(formatted.clone(), |suffix| format!("{formatted} {suffix}")))
1286}
1287
1288fn futures_contract_from_symbol(
1289 symbol: &str,
1290 security_type: SecurityType,
1291 exchange: &str,
1292 reference_year: i32,
1293 currency: Option<&str>,
1294) -> Option<Contract> {
1295 let (futures_symbol, option) = if security_type == SecurityType::FuturesOption {
1296 let (futures_symbol, rest) = symbol.split_once(' ')?;
1297 let mut chars = rest.chars();
1298 let right_char = chars.next()?;
1299 let right = IbOptionRight::from_str(&right_char.to_string()).ok()?;
1300 let strike = chars.as_str().parse::<f64>().ok()?;
1301 (futures_symbol, Some((right, strike)))
1302 } else {
1303 (symbol, None)
1304 };
1305
1306 let parsed = parse_futures_code(futures_symbol)?;
1307 let year = resolve_futures_year(parsed.year_digits, reference_year)?;
1308 let month = futures_month(parsed.month_code)?;
1309
1310 let currency = currency.unwrap_or_else(|| {
1311 log::warn!("Falling back to USD for unresolved IB futures contract {symbol} on {exchange}");
1312 "USD"
1313 });
1314
1315 Some(Contract {
1316 symbol: Symbol::from(parsed.root),
1317 trading_class: parsed.root.to_string(),
1318 security_type,
1319 exchange: Exchange::from(exchange),
1320 currency: Currency::from(currency),
1321 last_trade_date_or_contract_month: format!("{year:04}{month:02}"),
1322 right: option.map(|(right, _)| ib_option_right_to_option_right(right)),
1323 strike: option.map_or(0.0, |(_, strike)| strike),
1324 ..Default::default()
1325 })
1326}
1327
1328fn is_futures_underlying(symbol: &str) -> bool {
1329 symbol.len() <= 3 && symbol.chars().all(char::is_alphabetic)
1330}
1331
1332fn parse_futures_month_and_year(symbol: &str) -> Option<(usize, char, String)> {
1333 let parsed = parse_futures_code(symbol)?;
1334 Some((
1335 parsed.root.len(),
1336 parsed.month_code,
1337 parsed.year_digits.to_string(),
1338 ))
1339}
1340
1341fn validate_futures_option_symbol(symbol: &str) -> Option<()> {
1342 let (futures_symbol, rest) = symbol.split_once(' ')?;
1343 parse_futures_month_and_year(futures_symbol)?;
1344
1345 let right_char = rest.chars().next()?;
1346 IbOptionRight::from_str(&right_char.to_string()).ok()?;
1347
1348 let strike_str = &rest[1..];
1349 strike_str.parse::<f64>().ok()?;
1350
1351 Some(())
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
1357 use nautilus_model::identifiers::{InstrumentId, Symbol as NautilusSymbol, Venue};
1358 use rstest::rstest;
1359
1360 use super::{Symbology, exchange_to_mic_venue, possible_exchanges_for_venue};
1361 use crate::config::SymbologyMethod;
1362
1363 #[rstest]
1364 fn test_ib_contract_to_instrument_id_simplified_normalizes_occ_option_root() {
1365 let contract = Contract {
1366 symbol: Symbol::from("SPXW"),
1367 security_type: SecurityType::Option,
1368 exchange: Exchange::from("SMART"),
1369 currency: Currency::from("USD"),
1370 local_symbol: "SPXW260313P06630000".to_string(),
1371 last_trade_date_or_contract_month: "260313".to_string(),
1372 right: Some(OptionRight::Put),
1373 strike: 6630.0,
1374 ..Default::default()
1375 };
1376
1377 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1378 .instrument_id(&contract, None)
1379 .unwrap();
1380
1381 assert_eq!(
1382 instrument_id,
1383 InstrumentId::from("SPXW 260313P06630000.SMART")
1384 );
1385 }
1386
1387 #[rstest]
1388 fn test_ib_contract_to_instrument_id_simplified_formats_named_option_without_local_symbol() {
1389 let contract = Contract {
1390 symbol: Symbol::from("OESX"),
1391 security_type: SecurityType::Option,
1392 exchange: Exchange::from("EUREX"),
1393 currency: Currency::from("EUR"),
1394 trading_class: "OESX".to_string(),
1395 local_symbol: String::new(),
1396 last_trade_date_or_contract_month: "20260213".to_string(),
1397 right: Some(OptionRight::Call),
1398 strike: 4775.0,
1399 ..Default::default()
1400 };
1401
1402 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1403 .instrument_id(&contract, None)
1404 .unwrap();
1405
1406 assert_eq!(
1407 instrument_id,
1408 InstrumentId::from("C OESX 20260213 4775.EUREX")
1409 );
1410 }
1411
1412 #[rstest]
1413 fn test_ib_contract_to_instrument_id_simplified_preserves_fop_spacing() {
1414 let contract = Contract {
1415 symbol: Symbol::from("EX2"),
1416 security_type: SecurityType::FuturesOption,
1417 exchange: Exchange::from("NYBOT"),
1418 currency: Currency::from("USD"),
1419 local_symbol: "EX2G3 P4080".to_string(),
1420 ..Default::default()
1421 };
1422
1423 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1424 .instrument_id(&contract, None)
1425 .unwrap();
1426
1427 assert_eq!(instrument_id, InstrumentId::from("EX2G3 P4080.NYBOT"));
1428 }
1429
1430 #[rstest]
1431 #[case(SecurityType::Future, "ESZ6", "ESZ6.XCME")]
1432 #[case(SecurityType::FuturesOption, "ESZ6 P5000", "ESZ6 P5000.XCME")]
1433 fn test_ib_contract_to_instrument_id_keeps_the_local_symbol_year(
1434 #[case] security_type: SecurityType,
1435 #[case] local_symbol: &str,
1436 #[case] expected: &str,
1437 ) {
1438 let contract = Contract {
1439 symbol: Symbol::from("ES"),
1440 security_type,
1441 exchange: Exchange::from("CME"),
1442 currency: Currency::from("USD"),
1443 local_symbol: local_symbol.to_string(),
1444 last_trade_date_or_contract_month: "202612".to_string(),
1445 ..Default::default()
1446 };
1447
1448 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1449 .instrument_id(&contract, Some(Venue::from("XCME")))
1450 .unwrap();
1451
1452 assert_eq!(instrument_id, InstrumentId::from(expected));
1453 }
1454
1455 #[rstest]
1456 #[case(SecurityType::Future, "CLF6", "20251219", "CLF6.XNYM")]
1457 #[case(SecurityType::Future, "ESZ6", "20261218", "ESZ6.XNYM")]
1458 #[case(
1459 SecurityType::FuturesOption,
1460 "CLF6 P7000",
1461 "20251216",
1462 "CLF6 P7000.XNYM"
1463 )]
1464 fn test_local_symbol_year_is_not_rewritten_from_last_trade_date(
1465 #[case] security_type: SecurityType,
1466 #[case] local_symbol: &str,
1467 #[case] last_trade_date: &str,
1468 #[case] expected: &str,
1469 ) {
1470 let contract = Contract {
1471 symbol: Symbol::from("CL"),
1472 security_type,
1473 exchange: Exchange::from("NYMEX"),
1474 currency: Currency::from("USD"),
1475 local_symbol: local_symbol.to_string(),
1476 last_trade_date_or_contract_month: last_trade_date.to_string(),
1477 ..Default::default()
1478 };
1479
1480 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1481 .instrument_id(&contract, Some(Venue::from("XNYM")))
1482 .unwrap();
1483
1484 assert_eq!(instrument_id, InstrumentId::from(expected));
1485 }
1486
1487 #[rstest]
1488 fn test_non_grammar_local_symbol_is_emitted_verbatim() {
1489 let contract = Contract {
1490 symbol: Symbol::from("ZC"),
1491 security_type: SecurityType::Future,
1492 exchange: Exchange::from("CBOT"),
1493 currency: Currency::from("USD"),
1494 local_symbol: "ZC DEC 26".to_string(),
1495 last_trade_date_or_contract_month: "20261214".to_string(),
1496 ..Default::default()
1497 };
1498
1499 let simplified = Symbology::new(SymbologyMethod::Simplified)
1500 .instrument_id(&contract, Some(Venue::from("XCBT")))
1501 .unwrap();
1502 let raw = Symbology::new(SymbologyMethod::Raw)
1503 .instrument_id(&contract, Some(Venue::from("XCBT")))
1504 .unwrap();
1505
1506 assert_eq!(simplified, InstrumentId::from("ZC DEC 26.XCBT"));
1507 assert_eq!(raw, InstrumentId::from("ZC DEC 26=FUT.XCBT"));
1508 }
1509
1510 #[rstest]
1511 fn test_fop_without_local_symbol_falls_back_to_contract_fields() {
1512 let contract = Contract {
1513 symbol: Symbol::from("ES"),
1514 security_type: SecurityType::FuturesOption,
1515 exchange: Exchange::from("CME"),
1516 currency: Currency::from("USD"),
1517 local_symbol: String::new(),
1518 last_trade_date_or_contract_month: "20261218".to_string(),
1519 right: Some(OptionRight::Put),
1520 strike: 5000.0,
1521 ..Default::default()
1522 };
1523
1524 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1525 .instrument_id(&contract, Some(Venue::from("XCME")))
1526 .unwrap();
1527
1528 assert_eq!(instrument_id, InstrumentId::from("ES20261218 P5000.XCME"));
1529 }
1530
1531 #[rstest]
1532 #[case("ESZ6", SecurityType::Future)]
1533 #[case("ESZ26", SecurityType::Future)]
1534 #[case("ESZ2026", SecurityType::Future)]
1535 #[case("ESZ26 P5000", SecurityType::FuturesOption)]
1536 fn test_futures_id_keeps_its_symbol_as_the_contract_local_symbol_outbound(
1537 #[case] symbol: &str,
1538 #[case] security_type: SecurityType,
1539 ) {
1540 let instrument_id = InstrumentId::new(NautilusSymbol::from(symbol), Venue::from("XCME"));
1541
1542 let contract = Symbology::new(SymbologyMethod::Simplified)
1543 .contract(instrument_id, None)
1544 .unwrap();
1545
1546 assert_eq!(contract.security_type, security_type);
1547 assert_eq!(contract.local_symbol.as_str(), symbol);
1548 assert_eq!(contract.exchange.as_str(), "CME");
1549 assert_eq!(contract.currency.as_str(), "USD");
1550 assert!(contract.symbol.as_str().is_empty());
1551 assert!(contract.trading_class.is_empty());
1552 assert!(contract.last_trade_date_or_contract_month.is_empty());
1553 assert_eq!(contract.right, None);
1554 assert_eq!(contract.strike, 0.0);
1555 }
1556
1557 #[rstest]
1558 fn test_instrument_id_to_ib_contract_parses_named_option_symbol() {
1559 let instrument_id = InstrumentId::from("C OESX 20260213 4775.EUREX");
1560
1561 let contract = Symbology::new(SymbologyMethod::Simplified)
1562 .contract(instrument_id, None)
1563 .unwrap();
1564
1565 assert_eq!(contract.security_type, SecurityType::Option);
1566 assert_eq!(contract.exchange.as_str(), "EUREX");
1567 assert_eq!(contract.symbol.as_str(), "OESX");
1568 assert_eq!(contract.trading_class.as_str(), "OESX");
1569 assert_eq!(
1570 contract.last_trade_date_or_contract_month.as_str(),
1571 "20260213"
1572 );
1573 assert_eq!(contract.right.map(|right| right.as_str()), Some("C"));
1574 assert_eq!(contract.strike, 4775.0);
1575 }
1576
1577 #[rstest]
1578 fn test_instrument_id_to_ib_contract_preserves_occ_local_symbol() {
1579 let instrument_id = InstrumentId::from("AAPL 230217P00155000.SMART");
1580
1581 let contract = Symbology::new(SymbologyMethod::Simplified)
1582 .contract(instrument_id, None)
1583 .unwrap();
1584
1585 assert_eq!(contract.security_type, SecurityType::Option);
1586 assert_eq!(contract.exchange.as_str(), "SMART");
1587 assert_eq!(contract.symbol.as_str(), "AAPL");
1588 assert_eq!(contract.local_symbol.as_str(), "AAPL 230217P00155000");
1589 assert_eq!(
1590 contract.last_trade_date_or_contract_month.as_str(),
1591 "230217"
1592 );
1593 assert_eq!(contract.right.map(|right| right.as_str()), Some("P"));
1594 assert_eq!(contract.strike, 155.0);
1595 }
1596
1597 #[rstest]
1598 fn test_possible_exchanges_for_opra_routes_to_smart() {
1599 assert_eq!(
1600 possible_exchanges_for_venue("OPRA"),
1601 vec!["SMART".to_string()]
1602 );
1603 }
1604
1605 #[rstest]
1606 fn test_opra_occ_option_default_contract_routes_to_smart() {
1607 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1608
1609 let contract = Symbology::new(SymbologyMethod::Simplified)
1610 .contract(instrument_id, None)
1611 .unwrap();
1612
1613 assert_eq!(contract.security_type, SecurityType::Option);
1614 assert_eq!(contract.exchange.as_str(), "SMART");
1615 assert!(contract.symbol.as_str().is_empty());
1616 assert_eq!(contract.currency.as_str(), "USD");
1617 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1618 assert!(contract.last_trade_date_or_contract_month.is_empty());
1619 assert!(contract.right.is_none());
1620 assert_eq!(contract.strike, 0.0);
1621 }
1622
1623 #[rstest]
1624 fn test_opra_occ_option_qualification_contract_uses_opt_smart() {
1625 let instrument_id = InstrumentId::from("SPY 240319P00511000.OPRA");
1626 let exchanges = possible_exchanges_for_venue(instrument_id.venue.as_str());
1627
1628 assert_eq!(exchanges, vec!["SMART".to_string()]);
1629
1630 let contract = Symbology::new(SymbologyMethod::Simplified)
1631 .contract(instrument_id, exchanges.first().map(String::as_str))
1632 .unwrap();
1633
1634 assert_eq!(contract.security_type, SecurityType::Option);
1635 assert_eq!(contract.exchange.as_str(), "SMART");
1636 assert!(contract.symbol.as_str().is_empty());
1637 assert_eq!(contract.currency.as_str(), "USD");
1638 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1639 assert!(contract.last_trade_date_or_contract_month.is_empty());
1640 assert!(contract.right.is_none());
1641 assert_eq!(contract.strike, 0.0);
1642 }
1643
1644 #[rstest]
1645 fn test_opra_raw_option_default_contract_routes_to_smart() {
1646 let instrument_id = InstrumentId::from("SPY 240319P00511000=OPT.OPRA");
1647
1648 let contract = Symbology::new(SymbologyMethod::Raw)
1649 .contract(instrument_id, None)
1650 .unwrap();
1651
1652 assert_eq!(contract.security_type, SecurityType::Option);
1653 assert_eq!(contract.exchange.as_str(), "SMART");
1654 assert_eq!(contract.local_symbol.as_str(), "SPY 240319P00511000");
1655 }
1656
1657 #[rstest]
1658 #[case("SPY 240319P00511000.OPRA")]
1659 #[case("SPY 240319P00511000=OPT.OPRA")]
1660 fn test_opra_option_respects_exchange_override(#[case] value: &str) {
1661 let instrument_id = InstrumentId::from(value);
1662
1663 let method = if value.contains("=OPT") {
1664 SymbologyMethod::Raw
1665 } else {
1666 SymbologyMethod::Simplified
1667 };
1668
1669 let contract = Symbology::new(method)
1670 .contract(instrument_id, Some("CBOE"))
1671 .unwrap();
1672
1673 assert_eq!(contract.security_type, SecurityType::Option);
1674 assert_eq!(contract.exchange.as_str(), "CBOE");
1675 }
1676
1677 #[rstest]
1678 #[case("AAPL.OPRA")]
1679 #[case("SPY abcdefP00511000.OPRA")]
1680 #[case("P SPY 20240319 511.OPRA")]
1681 fn test_invalid_opra_occ_option_returns_error(#[case] value: &str) {
1682 let instrument_id = InstrumentId::from(value);
1683
1684 let result = Symbology::new(SymbologyMethod::Simplified).contract(instrument_id, None);
1685
1686 assert!(result.is_err());
1687 }
1688
1689 #[rstest]
1690 fn test_opra_route_does_not_create_reverse_smart_mapping() {
1691 assert_eq!(exchange_to_mic_venue("SMART"), None);
1692 }
1693
1694 #[rstest]
1695 fn test_instrument_id_to_ib_contract_parses_named_futures_symbol() {
1696 let instrument_id = InstrumentId::from("ESTX50 FESX 20240315.EUREX");
1697
1698 let contract = Symbology::new(SymbologyMethod::Simplified)
1699 .contract(instrument_id, None)
1700 .unwrap();
1701
1702 assert_eq!(contract.security_type, SecurityType::Future);
1703 assert_eq!(contract.exchange.as_str(), "EUREX");
1704 assert_eq!(contract.symbol.as_str(), "ESTX50");
1705 assert_eq!(contract.trading_class.as_str(), "FESX");
1706 assert_eq!(
1707 contract.last_trade_date_or_contract_month.as_str(),
1708 "20240315"
1709 );
1710 }
1711
1712 #[rstest]
1713 fn test_instrument_id_to_ib_contract_maps_xcbt_to_cbot_exchange() {
1714 let instrument_id = InstrumentId::from("YMM6.XCBT");
1715
1716 let contract = Symbology::new(SymbologyMethod::Simplified)
1717 .contract(instrument_id, None)
1718 .unwrap();
1719
1720 assert_eq!(contract.security_type, SecurityType::Future);
1721 assert_eq!(contract.exchange.as_str(), "CBOT");
1722 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1723 assert!(contract.symbol.as_str().is_empty());
1724 assert!(contract.last_trade_date_or_contract_month.is_empty());
1725 }
1726
1727 #[rstest]
1728 fn test_instrument_id_to_ib_contract_maps_mic_future_to_member_exchange() {
1729 let instrument_id = InstrumentId::from("OESXH6.XEUR");
1730
1731 let contract = Symbology::new(SymbologyMethod::Simplified)
1732 .contract(instrument_id, None)
1733 .unwrap();
1734
1735 assert_eq!(contract.security_type, SecurityType::Future);
1736 assert_eq!(contract.exchange.as_str(), "DTB");
1737 assert_eq!(contract.local_symbol.as_str(), "OESXH6");
1738 assert!(contract.symbol.as_str().is_empty());
1739 assert!(contract.last_trade_date_or_contract_month.is_empty());
1740 }
1741
1742 #[rstest]
1743 fn test_instrument_id_to_ib_contract_parses_futures_option_with_month_code_in_symbol() {
1744 let instrument_id = InstrumentId::from("YMM6 C45000.XCBT");
1745
1746 let contract = Symbology::new(SymbologyMethod::Simplified)
1747 .contract(instrument_id, None)
1748 .unwrap();
1749
1750 assert_eq!(contract.security_type, SecurityType::FuturesOption);
1751 assert_eq!(contract.exchange.as_str(), "CBOT");
1752 assert_eq!(contract.local_symbol.as_str(), "YMM6 C45000");
1753 assert!(contract.symbol.as_str().is_empty());
1754 assert!(contract.last_trade_date_or_contract_month.is_empty());
1755 assert_eq!(contract.right, None);
1756 assert_eq!(contract.strike, 0.0);
1757 }
1758
1759 #[rstest]
1760 fn test_instrument_id_to_ib_contract_parses_ibcfd_cash_symbol() {
1761 let instrument_id = InstrumentId::from("EUR/USD.IBCFD");
1762
1763 let contract = Symbology::new(SymbologyMethod::Simplified)
1764 .contract(instrument_id, None)
1765 .unwrap();
1766
1767 assert_eq!(contract.security_type, SecurityType::CFD);
1768 assert_eq!(contract.exchange.as_str(), "SMART");
1769 assert_eq!(contract.symbol.as_str(), "EUR");
1770 assert_eq!(contract.currency.as_str(), "USD");
1771 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1772 }
1773
1774 #[rstest]
1775 #[case(
1776 SymbologyMethod::Simplified,
1777 SecurityType::CFD,
1778 "IBDE30",
1779 "EUR",
1780 "IBDE30.IBCFD"
1781 )]
1782 #[case(
1783 SymbologyMethod::Raw,
1784 SecurityType::CFD,
1785 "IBDE30",
1786 "EUR",
1787 "IBDE30=CFD.IBCFD"
1788 )]
1789 #[case(
1790 SymbologyMethod::Simplified,
1791 SecurityType::Commodity,
1792 "XAUUSD",
1793 "GBP",
1794 "XAUUSD.IBCMDTY"
1795 )]
1796 #[case(
1797 SymbologyMethod::Raw,
1798 SecurityType::Commodity,
1799 "XAUUSD",
1800 "GBP",
1801 "XAUUSD=CMDTY.IBCMDTY"
1802 )]
1803 fn test_non_cash_pseudo_venue_round_trip_defers_currency_to_contract_details(
1804 #[case] method: SymbologyMethod,
1805 #[case] security_type: SecurityType,
1806 #[case] symbol: &str,
1807 #[case] currency: &str,
1808 #[case] expected_instrument_id: &str,
1809 ) {
1810 let contract = Contract {
1811 symbol: Symbol::from(symbol),
1812 security_type: security_type.clone(),
1813 exchange: Exchange::from("SMART"),
1814 currency: Currency::from(currency),
1815 local_symbol: symbol.to_string(),
1816 ..Default::default()
1817 };
1818
1819 let symbology = Symbology::new(method);
1820
1821 let instrument_id = symbology.instrument_id(&contract, None).unwrap();
1822 let unresolved = symbology.contract(instrument_id, None).unwrap();
1823 let round_trip_id = symbology.instrument_id(&unresolved, None).unwrap();
1824
1825 assert_eq!(instrument_id, InstrumentId::from(expected_instrument_id));
1826 assert_eq!(unresolved.security_type, security_type);
1827 assert_eq!(unresolved.exchange.as_str(), "SMART");
1828 assert!(unresolved.currency.as_str().is_empty());
1829 assert_eq!(round_trip_id, instrument_id);
1830 }
1831
1832 #[rstest]
1833 fn test_instrument_id_to_ib_contract_parses_long_crypto_symbol() {
1834 let instrument_id = InstrumentId::from("DOGE/USD.PAXOS");
1835
1836 let contract = Symbology::new(SymbologyMethod::Simplified)
1837 .contract(instrument_id, None)
1838 .unwrap();
1839
1840 assert_eq!(contract.security_type, SecurityType::Crypto);
1841 assert_eq!(contract.exchange.as_str(), "PAXOS");
1842 assert_eq!(contract.symbol.as_str(), "DOGE");
1843 assert_eq!(contract.currency.as_str(), "USD");
1844 assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
1845 }
1846
1847 #[rstest]
1848 fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1849 let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1851
1852 let contract = Symbology::new(SymbologyMethod::Simplified)
1853 .contract(instrument_id, None)
1854 .unwrap();
1855
1856 assert_eq!(contract.security_type, SecurityType::Crypto);
1857 assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1858 assert_eq!(contract.symbol.as_str(), "BTC");
1859 assert_eq!(contract.currency.as_str(), "USD");
1860 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1861 }
1862
1863 #[rstest]
1864 fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1865 let contract = Contract {
1867 symbol: Symbol::from("BTC"),
1868 security_type: SecurityType::Crypto,
1869 exchange: Exchange::from("ZEROHASH"),
1870 currency: Currency::from("USD"),
1871 local_symbol: "BTC.USD".to_string(),
1872 ..Default::default()
1873 };
1874
1875 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1876 .instrument_id(&contract, None)
1877 .unwrap();
1878
1879 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1880 }
1881
1882 #[rstest]
1883 fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1884 let contract = Contract {
1887 symbol: Symbol::from("DOGE"),
1888 security_type: SecurityType::Crypto,
1889 currency: Currency::from("USD"),
1890 local_symbol: "DOGE.USD".to_string(),
1891 ..Default::default()
1892 };
1893
1894 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1895 .instrument_id(&contract, None)
1896 .unwrap();
1897
1898 assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1899 }
1900
1901 #[rstest]
1902 fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1903 let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1905
1906 let contract = Symbology::new(SymbologyMethod::Simplified)
1907 .contract(instrument_id, None)
1908 .unwrap();
1909
1910 assert_eq!(contract.security_type, SecurityType::Crypto);
1911 assert_eq!(contract.exchange.as_str(), "PAXOS");
1912 assert_eq!(contract.symbol.as_str(), "BTC");
1913 assert_eq!(contract.currency.as_str(), "USD");
1914 assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1915 }
1916
1917 #[rstest]
1918 fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1919 let contract = Contract {
1922 symbol: Symbol::from("BTC"),
1923 security_type: SecurityType::Crypto,
1924 exchange: Exchange::from("PAXOS"),
1925 currency: Currency::from("USD"),
1926 local_symbol: "BTC.USD".to_string(),
1927 ..Default::default()
1928 };
1929
1930 let instrument_id = Symbology::new(SymbologyMethod::Simplified)
1931 .instrument_id(&contract, None)
1932 .unwrap();
1933
1934 assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1935 }
1936
1937 #[rstest]
1938 fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
1939 let instrument_id = InstrumentId::from("ES.XCME");
1940
1941 let contract = Symbology::new(SymbologyMethod::Simplified)
1942 .contract(instrument_id, None)
1943 .unwrap();
1944
1945 assert_eq!(contract.security_type, SecurityType::ContinuousFuture);
1946 assert_eq!(contract.exchange.as_str(), "CME");
1947 assert_eq!(contract.symbol.as_str(), "ES");
1948 }
1949
1950 #[rstest]
1951 fn test_instrument_id_to_ib_contract_parses_raw_stock_symbol() {
1952 let instrument_id = InstrumentId::from("AAPL=STK.NASDAQ");
1953
1954 let contract = Symbology::new(SymbologyMethod::Simplified)
1955 .contract(instrument_id, None)
1956 .unwrap();
1957
1958 assert_eq!(contract.security_type, SecurityType::Stock);
1959 assert_eq!(contract.exchange.as_str(), "SMART");
1960 assert_eq!(contract.primary_exchange.as_str(), "NASDAQ");
1961 assert_eq!(contract.local_symbol.as_str(), "AAPL");
1962 assert!(contract.symbol.as_str().is_empty());
1963 }
1964
1965 #[rstest]
1966 fn test_instrument_id_to_ib_contract_parses_raw_forex_symbol() {
1967 let instrument_id = InstrumentId::from("EUR.USD=CASH.IDEALPRO");
1968
1969 let contract = Symbology::new(SymbologyMethod::Simplified)
1970 .contract(instrument_id, None)
1971 .unwrap();
1972
1973 assert_eq!(contract.security_type, SecurityType::ForexPair);
1974 assert_eq!(contract.exchange.as_str(), "IDEALPRO");
1975 assert_eq!(contract.local_symbol.as_str(), "EUR.USD");
1976 assert!(contract.symbol.as_str().is_empty());
1977 }
1978
1979 #[rstest]
1980 fn test_instrument_id_to_ib_contract_raw_respects_exchange_override() {
1981 let instrument_id = InstrumentId::from("YMM6=FUT.XCBT");
1982
1983 let contract = Symbology::new(SymbologyMethod::Simplified)
1984 .contract(instrument_id, Some("CBOT"))
1985 .unwrap();
1986
1987 assert_eq!(contract.security_type, SecurityType::Future);
1988 assert_eq!(contract.exchange.as_str(), "CBOT");
1989 assert_eq!(contract.local_symbol.as_str(), "YMM6");
1990 assert!(contract.symbol.as_str().is_empty());
1991 assert!(contract.last_trade_date_or_contract_month.is_empty());
1992 }
1993
1994 #[rstest]
1995 fn test_instrument_id_to_ib_contract_default_stock_omits_local_symbol_and_currency() {
1996 let instrument_id = InstrumentId::from("IUSA.IBIS");
1997
1998 let contract = Symbology::new(SymbologyMethod::Simplified)
1999 .contract(instrument_id, Some("IBIS"))
2000 .unwrap();
2001
2002 assert_eq!(contract.security_type, SecurityType::Stock);
2003 assert_eq!(contract.symbol.as_str(), "IUSA");
2004 assert_eq!(contract.exchange.as_str(), "SMART");
2005 assert_eq!(contract.primary_exchange.as_str(), "IBIS");
2006 assert!(contract.currency.as_str().is_empty());
2007 assert!(contract.local_symbol.is_empty());
2008 }
2009}