1use std::{collections::HashMap, fs, path::Path, str::FromStr, sync::Arc};
19
20use anyhow::Context;
21use dashmap::DashMap;
22use ibapi::{
23 contracts::{ComboLegOpenClose, Contract, Exchange, LegAction, SecurityType, Symbol},
24 prelude::StreamExt,
25 subscriptions::SubscriptionItem,
26};
27use jiff::{Span, Timestamp, tz::Offset};
28use nautilus_common::cache::Cache;
29use nautilus_model::{
30 identifiers::{InstrumentId, Venue},
31 instruments::{Instrument, InstrumentAny},
32};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 common::{
37 Symbology,
38 contracts::{
39 ConfiguredContract, KEY_BUILD_FUTURES_CHAIN, KEY_BUILD_OPTIONS_CHAIN,
40 KEY_MAX_EXPIRY_DAYS, KEY_MIN_EXPIRY_DAYS, KEY_OPTIONS_CHAIN_EXCHANGE,
41 KEY_OPTIONS_CHAIN_EXCHANGE_ALT, parse_contract_from_json,
42 },
43 enums::{IbAction, IbSecurityType},
44 spreads::{
45 create_spread_instrument_id, is_spread_instrument_id,
46 parse_spread_instrument_id_to_legs,
47 },
48 symbology::{
49 determine_venue_from_contract, exchange_to_mic_venue, possible_exchanges_for_venue,
50 },
51 },
52 config::{InteractiveBrokersInstrumentProviderConfig, SymbologyMethod},
53 providers::parse::{
54 expiry_timestring_to_unix_nanos, parse_ib_contract_to_instrument,
55 parse_spread_instrument_any,
56 },
57};
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61struct InstrumentCache {
62 cache_timestamp: Timestamp,
64 contract_id_to_instrument_id: Vec<(i32, String)>,
66 price_magnifiers: Vec<(String, i32)>,
68 #[serde(default)]
70 contracts: Vec<(String, Contract)>,
71 #[serde(default)]
73 contract_details: Vec<(String, ibapi::contracts::ContractDetails)>,
74 instruments: Vec<(String, String)>, }
77
78#[cfg_attr(
83 feature = "python",
84 pyo3::pyclass(
85 module = "nautilus_trader.adapters.interactive_brokers",
86 unsendable,
87 from_py_object
88 )
89)]
90#[cfg_attr(
91 feature = "python",
92 pyo3_stub_gen::derive::gen_stub_pyclass(
93 module = "nautilus_trader.adapters.interactive_brokers"
94 )
95)]
96#[derive(Debug, Clone)]
97pub struct InteractiveBrokersInstrumentProvider {
98 config: InteractiveBrokersInstrumentProviderConfig,
100 symbology: Symbology,
102 contract_id_to_instrument_id: Arc<DashMap<i32, InstrumentId>>,
104 instruments: Arc<DashMap<InstrumentId, InstrumentAny>>,
106 contract_details: Arc<DashMap<InstrumentId, ibapi::contracts::ContractDetails>>,
108 contracts: Arc<DashMap<InstrumentId, Contract>>,
110 price_magnifiers: Arc<DashMap<InstrumentId, i32>>,
112 startup_initialized: Arc<tokio::sync::Mutex<bool>>,
114}
115
116trait StartupInstrumentLoader {
117 async fn load_instrument_id(
118 &self,
119 instrument_id: InstrumentId,
120 ) -> anyhow::Result<Option<InstrumentId>>;
121
122 async fn load_contract(
123 &self,
124 configured: &ConfiguredContract,
125 ) -> anyhow::Result<Vec<InstrumentId>>;
126}
127
128struct IbStartupInstrumentLoader<'a> {
129 provider: &'a InteractiveBrokersInstrumentProvider,
130 client: &'a ibapi::Client,
131}
132
133impl StartupInstrumentLoader for IbStartupInstrumentLoader<'_> {
134 async fn load_instrument_id(
135 &self,
136 instrument_id: InstrumentId,
137 ) -> anyhow::Result<Option<InstrumentId>> {
138 self.provider
139 .load_with_return_async(self.client, instrument_id, None)
140 .await
141 }
142
143 async fn load_contract(
144 &self,
145 configured: &ConfiguredContract,
146 ) -> anyhow::Result<Vec<InstrumentId>> {
147 let spec = configured.chain_spec_json();
148 self.provider
149 .load_contract_spec(self.client, &configured.contract, spec.as_ref())
150 .await
151 }
152}
153
154impl InteractiveBrokersInstrumentProvider {
155 pub fn new(config: InteractiveBrokersInstrumentProviderConfig) -> Self {
157 let symbology = Symbology::new(config.symbology_method);
158
159 Self {
160 config,
161 symbology,
162 contract_id_to_instrument_id: Arc::new(DashMap::new()),
163 instruments: Arc::new(DashMap::new()),
164 contract_details: Arc::new(DashMap::new()),
165 contracts: Arc::new(DashMap::new()),
166 price_magnifiers: Arc::new(DashMap::new()),
167 startup_initialized: Arc::new(tokio::sync::Mutex::new(false)),
168 }
169 }
170
171 #[cfg(test)]
172 pub(crate) fn insert_test_instrument(
173 &self,
174 instrument: InstrumentAny,
175 contract_id: i32,
176 price_magnifier: i32,
177 ) {
178 let instrument_id = instrument.id();
179 self.instruments.insert(instrument_id, instrument);
180 self.contract_id_to_instrument_id
181 .insert(contract_id, instrument_id);
182 self.contracts.insert(
183 instrument_id,
184 Contract {
185 contract_id,
186 ..Default::default()
187 },
188 );
189
190 self.price_magnifiers.insert(instrument_id, price_magnifier);
191 }
192
193 #[cfg(test)]
194 pub(crate) fn insert_test_contract_id_mapping(
195 &self,
196 contract_id: i32,
197 instrument_id: InstrumentId,
198 ) {
199 self.contract_id_to_instrument_id
200 .insert(contract_id, instrument_id);
201 }
202
203 pub async fn initialize(&self) -> anyhow::Result<()> {
212 if let Some(ref cache_path) = self.config.cache_path {
213 match self.load_cache(cache_path).await {
214 Ok(cache_loaded) => {
215 if cache_loaded {
216 tracing::debug!(
217 "Initialized provider with {} instruments from cache",
218 self.count()
219 );
220 } else {
221 tracing::debug!(
222 "Cache file not found or expired, starting with empty cache"
223 );
224 }
225 }
226 Err(e) => {
227 tracing::warn!("Failed to load cache during initialization: {}", e);
228 }
229 }
230 }
231
232 Ok(())
233 }
234
235 pub async fn initialize_with_client(
243 &self,
244 client: &ibapi::Client,
245 ) -> anyhow::Result<Vec<InstrumentId>> {
246 let loader = IbStartupInstrumentLoader {
247 provider: self,
248 client,
249 };
250
251 self.initialize_with_loader(&loader).await
252 }
253
254 async fn initialize_with_loader<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
255 where
256 L: StartupInstrumentLoader + Sync,
257 {
258 let mut initialized = self.startup_initialized.lock().await;
259 if *initialized {
260 return Ok(Vec::new());
261 }
262
263 self.initialize().await?;
264 let loaded_ids = self.load_configured_instruments(loader).await?;
265 *initialized = true;
266 Ok(loaded_ids)
267 }
268
269 async fn load_configured_instruments<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
270 where
271 L: StartupInstrumentLoader + Sync,
272 {
273 let mut loaded_ids = Vec::new();
274 let mut unresolved = Vec::new();
275 let mut configured_ids: Vec<_> = self.config.load_ids.iter().copied().collect();
276 configured_ids.sort_unstable();
277
278 for instrument_id in configured_ids {
279 match loader
280 .load_instrument_id(instrument_id)
281 .await
282 .with_context(|| {
283 format!("Failed to load configured IB instrument ID {instrument_id}")
284 })? {
285 Some(loaded_id) => loaded_ids.push(loaded_id),
286 None => unresolved.push(format!("instrument ID {instrument_id}")),
287 }
288 }
289
290 for (index, configured) in self.config.load_contracts.iter().enumerate() {
291 let contract = &configured.contract;
292
293 let mut contract_ids = loader.load_contract(configured).await.with_context(|| {
294 format!("Failed to load configured IB contract at index {index}: {contract:?}")
295 })?;
296
297 if contract_ids.is_empty() {
298 unresolved.push(format!("contract at index {index}: {contract:?}"));
299 } else {
300 loaded_ids.append(&mut contract_ids);
301 }
302 }
303
304 if !unresolved.is_empty() {
305 anyhow::bail!(
306 "Unable to resolve configured Interactive Brokers instruments: {}",
307 unresolved.join(", ")
308 );
309 }
310
311 loaded_ids.sort_unstable();
312 loaded_ids.dedup();
313 Ok(loaded_ids)
314 }
315
316 pub(crate) fn seed_from_cache(&self, cache: &Cache) -> usize {
317 let instruments = cache
318 .instrument_ids(None)
319 .into_iter()
320 .filter_map(|instrument_id| cache.instrument(instrument_id).cloned());
321 let count = self.add_cached_instruments(instruments);
322 if count > 0 {
323 tracing::debug!("Seeded IB instrument provider with {count} cached instruments");
324 }
325
326 count
327 }
328
329 pub fn add_cached_instruments<I>(&self, instruments: I) -> usize
334 where
335 I: IntoIterator<Item = InstrumentAny>,
336 {
337 let mut added = 0;
338
339 for instrument in instruments {
340 let instrument_id = instrument.id();
341
342 let Some(contract) = contract_from_instrument_info(&instrument) else {
343 continue;
344 };
345
346 let Some(price_magnifier) =
347 price_magnifier_from_instrument_info(&instrument).filter(|value| *value >= 0)
348 else {
349 continue;
350 };
351
352 if self.cache_instrument(
353 instrument_id,
354 instrument,
355 None,
356 Some(contract),
357 Some(price_magnifier),
358 false,
359 ) {
360 added += 1;
361 }
362 }
363
364 added
365 }
366
367 pub fn determine_venue(
376 &self,
377 contract: &Contract,
378 contract_details: Option<&ibapi::contracts::ContractDetails>,
379 ) -> Venue {
380 if matches!(contract.security_type, SecurityType::Stock) {
381 return Venue::from(self.resolve_stock_exchange_from_contract(contract).as_str());
382 }
383
384 let valid_exchanges = contract_details.map(|details| details.valid_exchanges.join(","));
385 let venue_str = determine_venue_from_contract(
386 contract,
387 &self.config.symbol_to_mic_venue,
388 self.config.convert_exchange_to_mic_venue,
389 valid_exchanges.as_deref(),
390 );
391 Venue::from(venue_str.as_str())
392 }
393
394 fn resolve_stock_exchange_from_contract(&self, contract: &Contract) -> String {
395 let cached_venue = self.resolve_cached_symbol_venue(contract);
396 if let Some(venue) = cached_venue.as_deref()
397 && Self::is_compatible_cached_stock_venue(venue, contract.primary_exchange.as_str())
398 {
399 return venue.to_string();
400 }
401
402 if !contract.primary_exchange.as_str().is_empty()
403 && contract.primary_exchange.as_str() != "SMART"
404 {
405 return if self.config.convert_exchange_to_mic_venue {
406 exchange_to_mic_venue(contract.primary_exchange.as_str())
407 .unwrap_or_else(|| contract.primary_exchange.as_str().to_string())
408 } else {
409 contract.primary_exchange.as_str().to_string()
410 };
411 }
412
413 if contract.exchange.as_str() == "SMART"
414 && let Some(venue) = cached_venue
415 {
416 return venue;
417 }
418
419 let exchange = contract.exchange.as_str();
420 if self.config.convert_exchange_to_mic_venue {
421 exchange_to_mic_venue(exchange).unwrap_or_else(|| exchange.to_string())
422 } else {
423 exchange.to_string()
424 }
425 }
426
427 fn is_compatible_cached_stock_venue(venue: &str, primary_exchange: &str) -> bool {
428 if primary_exchange.is_empty() || primary_exchange == "SMART" {
429 return true;
430 }
431
432 venue == primary_exchange
433 || exchange_to_mic_venue(primary_exchange).is_some_and(|mic| mic == venue)
434 }
435
436 fn resolve_cached_symbol_venue(&self, contract: &Contract) -> Option<String> {
437 self.instruments.iter().find_map(|entry| {
438 let instrument = entry.value();
439 let instrument_id = instrument.id();
440 (instrument_id.symbol.as_str() == contract.symbol.as_str())
441 .then(|| instrument_id.venue.to_string())
442 })
443 }
444
445 pub fn symbology_method(&self) -> SymbologyMethod {
447 self.symbology.method()
448 }
449
450 #[must_use]
456 pub fn find(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
457 self.instruments
458 .get(instrument_id)
459 .map(|entry| entry.value().clone())
460 }
461
462 #[must_use]
463 pub(crate) fn find_all(&self, instrument_ids: &[InstrumentId]) -> Vec<InstrumentAny> {
464 instrument_ids
465 .iter()
466 .filter_map(|instrument_id| self.find(instrument_id))
467 .collect()
468 }
469
470 #[must_use]
476 pub fn find_by_contract_id(&self, contract_id: i32) -> Option<InstrumentAny> {
477 self.get_instrument_id_by_contract_id(contract_id)
478 .and_then(|instrument_id| self.find(&instrument_id))
479 }
480
481 #[must_use]
487 pub fn get_instrument_id_by_contract_id(&self, contract_id: i32) -> Option<InstrumentId> {
488 self.contract_id_to_instrument_id
489 .get(&contract_id)
490 .map(|entry| *entry.value())
491 }
492
493 pub fn resolve_instrument_id_for_contract(
502 &self,
503 contract: &Contract,
504 ) -> anyhow::Result<InstrumentId> {
505 if contract.contract_id != 0
506 && let Some(instrument_id) = self.get_instrument_id_by_contract_id(contract.contract_id)
507 {
508 return Ok(instrument_id);
509 }
510
511 if contract.security_type == SecurityType::Spread {
512 return self.resolve_spread_instrument_id_for_contract(contract);
513 }
514
515 let venue = self.determine_venue(contract, None);
516
517 self.symbology.instrument_id(contract, Some(venue))
518 }
519
520 fn resolve_spread_instrument_id_for_contract(
521 &self,
522 contract: &Contract,
523 ) -> anyhow::Result<InstrumentId> {
524 let spread_instrument_id = self.spread_instrument_id_for_contract(contract)?;
525
526 if self.find(&spread_instrument_id).is_none() {
527 anyhow::bail!("Resolved BAG spread {spread_instrument_id} is not cached");
528 }
529
530 Ok(spread_instrument_id)
531 }
532
533 pub fn spread_instrument_id_for_contract(
540 &self,
541 contract: &Contract,
542 ) -> anyhow::Result<InstrumentId> {
543 if contract.combo_legs.is_empty() {
544 anyhow::bail!("Cannot resolve BAG contract without combo legs or cached contract ID");
545 }
546
547 let mut leg_tuples = Vec::with_capacity(contract.combo_legs.len());
548
549 for combo_leg in &contract.combo_legs {
550 let leg_instrument_id = self
551 .get_instrument_id_by_contract_id(combo_leg.contract_id)
552 .with_context(|| {
553 format!(
554 "Cannot resolve BAG leg con_id {} to cached instrument ID",
555 combo_leg.contract_id
556 )
557 })?;
558
559 let ratio = IbAction::from_str(combo_leg.action.as_str())
560 .context("Invalid BAG combo leg action")?
561 .signed_multiplier()
562 * combo_leg.ratio;
563
564 leg_tuples.push((leg_instrument_id, ratio));
565 }
566
567 create_spread_instrument_id(&leg_tuples)
568 .context("Failed to create spread instrument ID from BAG combo legs")
569 }
570
571 #[must_use]
577 pub fn is_filtered_sec_type(&self, sec_type: &str) -> bool {
578 IbSecurityType::from_str(sec_type)
579 .is_ok_and(|sec_type| self.config.filter_sec_types.contains(&sec_type))
580 }
581
582 #[must_use]
588 pub fn get_all(&self) -> Vec<InstrumentAny> {
589 self.instruments
590 .iter()
591 .map(|entry| entry.value().clone())
592 .collect()
593 }
594
595 #[must_use]
601 pub fn count(&self) -> usize {
602 self.instruments.len()
603 }
604
605 #[must_use]
618 pub fn get_price_magnifier(&self, instrument_id: &InstrumentId) -> i32 {
619 if let Some(magnifier) = self.price_magnifiers.get(instrument_id) {
621 return normalize_price_magnifier(*magnifier.value());
622 }
623
624 if let Some(details) = self.contract_details.get(instrument_id) {
626 let magnifier = normalize_price_magnifier(details.value().price_magnifier);
627 self.price_magnifiers.insert(*instrument_id, magnifier);
629 return magnifier;
630 }
631
632 if self.instruments.contains_key(instrument_id) {
634 tracing::debug!(
635 "Price magnifier not found for instrument {} (has instrument but no contract details), using default 1",
636 instrument_id
637 );
638 } else {
639 tracing::trace!(
640 "Price magnifier not found for instrument {} (instrument not loaded), using default 1",
641 instrument_id
642 );
643 }
644
645 1
647 }
648
649 pub async fn get_instrument(
662 &self,
663 client: &ibapi::Client,
664 contract: &Contract,
665 ) -> anyhow::Result<Option<InstrumentAny>> {
666 log::debug!(
667 "IB get_instrument request sec_type={:?} con_id={} symbol={} local_symbol={} exchange={} expiry={}",
668 contract.security_type,
669 contract.contract_id,
670 contract.symbol.as_str(),
671 contract.local_symbol.as_str(),
672 contract.exchange.as_str(),
673 contract.last_trade_date_or_contract_month.as_str()
674 );
675 let sec_type_str = contract.security_type.to_string();
677 if self.is_filtered_sec_type(&sec_type_str) {
678 tracing::debug!(
679 "Skipping filtered security type {} for contract",
680 sec_type_str
681 );
682 return Ok(None);
683 }
684
685 let contract_id = contract.contract_id;
686
687 if let Some(cached_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id) {
689 log::debug!(
690 "IB get_instrument cache hit for contract_id={} -> {}",
691 contract_id,
692 cached_instrument_id.value()
693 );
694
695 if let Some(instrument) = self.find(cached_instrument_id.value()) {
696 return Ok(Some(instrument));
697 }
698 }
699
700 if contract.security_type == SecurityType::Spread && !contract.combo_legs.is_empty() {
702 self.fetch_bag_contract(client, contract).await?;
704
705 if let Some(spread_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id)
707 {
708 return Ok(self.find(spread_instrument_id.value()));
709 }
710
711 if let Ok(spread_instrument_id) =
712 self.resolve_spread_instrument_id_for_contract(contract)
713 {
714 return Ok(self.find(&spread_instrument_id));
715 }
716 }
717
718 let details_vec = client
720 .contract_details(contract)
721 .await
722 .context("Failed to fetch contract details from IB")?;
723
724 log::debug!(
725 "IB get_instrument received {} contract details for sec_type={:?} symbol={} local_symbol={}",
726 details_vec.len(),
727 contract.security_type,
728 contract.symbol.as_str(),
729 contract.local_symbol.as_str()
730 );
731
732 if details_vec.is_empty() {
733 tracing::warn!("No contract details returned for contract {}", contract_id);
734 return Ok(None);
735 }
736
737 let loaded_ids = self.process_contract_details(details_vec, None, false);
738
739 if contract_id != 0
740 && let Some(instrument) = self.find_by_contract_id(contract_id)
741 {
742 return Ok(Some(instrument));
743 }
744
745 Ok(loaded_ids
746 .first()
747 .and_then(|instrument_id| self.find(instrument_id)))
748 }
749
750 pub(crate) async fn load_contract_spec(
751 &self,
752 client: &ibapi::Client,
753 contract: &Contract,
754 spec: Option<&serde_json::Value>,
755 ) -> anyhow::Result<Vec<InstrumentId>> {
756 let mut loaded_ids = Vec::new();
757 let build_futures_chain = json_opt_bool(spec, KEY_BUILD_FUTURES_CHAIN)
758 .or(self.config.build_futures_chain)
759 .unwrap_or(false);
760 let build_options_chain = json_opt_bool(spec, KEY_BUILD_OPTIONS_CHAIN)
761 .or(self.config.build_options_chain)
762 .unwrap_or(false);
763 let min_expiry_days = json_u32(spec, KEY_MIN_EXPIRY_DAYS).or(self.config.min_expiry_days);
764 let max_expiry_days = json_u32(spec, KEY_MAX_EXPIRY_DAYS).or(self.config.max_expiry_days);
765 let options_chain_exchange = json_string(spec, KEY_OPTIONS_CHAIN_EXCHANGE)
766 .or_else(|| json_string(spec, KEY_OPTIONS_CHAIN_EXCHANGE_ALT));
767
768 let chain_contract = if contract.security_type == SecurityType::ContinuousFuture
769 && (build_futures_chain || build_options_chain)
770 {
771 match client.contract_details(contract).await {
772 Ok(details_vec) => details_vec
773 .into_iter()
774 .next().map_or_else(|| contract.clone(), |details| {
775 tracing::debug!(
776 "Qualified continuous future contract {}.{} as local_symbol={} trading_class={} con_id={}",
777 contract.symbol.as_str(),
778 contract.exchange.as_str(),
779 details.contract.local_symbol.as_str(),
780 details.contract.trading_class.as_str(),
781 details.contract.contract_id,
782 );
783 details.contract
784 }),
785 Err(e) if e.is_connection_lost() => {
786 return Err(e).context("Failed to qualify continuous future contract");
787 }
788 Err(e) => {
789 tracing::warn!(
790 "Failed to qualify continuous future contract {:?}: {}",
791 contract,
792 e
793 );
794 contract.clone()
795 }
796 }
797 } else {
798 contract.clone()
799 };
800
801 let chain_trading_class = (!chain_contract.trading_class.is_empty())
802 .then_some(chain_contract.trading_class.as_str());
803
804 if build_futures_chain {
805 let loaded = self
806 .fetch_futures_chain(
807 client,
808 chain_contract.symbol.as_str(),
809 chain_contract.exchange.as_str(),
810 chain_contract.currency.as_str(),
811 chain_trading_class,
812 contract.security_type == SecurityType::ContinuousFuture,
813 min_expiry_days,
814 max_expiry_days,
815 )
816 .await?;
817 tracing::debug!(
818 "Loaded {} futures instruments for chain request {}.{}",
819 loaded,
820 chain_contract.symbol.as_str(),
821 chain_contract.exchange.as_str(),
822 );
823 loaded_ids.extend(self.cached_contract_ids_for(
824 chain_contract.symbol.as_str(),
825 chain_contract.exchange.as_str(),
826 &[SecurityType::Future],
827 ));
828 }
829
830 if build_options_chain {
831 let expiry_min = expiry_bound_from_days(min_expiry_days);
832 let expiry_max = expiry_bound_from_days(max_expiry_days);
833 let options_exchange = options_chain_exchange
836 .as_deref()
837 .unwrap_or_else(|| contract.exchange.as_str());
838 let mut underlyings = Vec::new();
839
840 if contract.security_type == SecurityType::ContinuousFuture {
841 self.fetch_futures_chain(
844 client,
845 chain_contract.symbol.as_str(),
846 chain_contract.exchange.as_str(),
847 chain_contract.currency.as_str(),
848 chain_trading_class,
849 true,
850 Some(0),
851 Some(u32::MAX),
852 )
853 .await?;
854
855 underlyings.extend(
856 self.cached_contracts_for(
857 contract.symbol.as_str(),
858 chain_contract.exchange.as_str(),
859 &[SecurityType::Future],
860 )
861 .into_iter()
862 .map(|(_, contract)| contract),
863 );
864 } else if let Some(instrument) = self.get_instrument(client, contract).await? {
865 let instrument_id = instrument.id();
866 loaded_ids.push(instrument_id);
867 if let Some(underlying) = self.instrument_id_to_ib_contract(&instrument_id) {
868 underlyings.push(underlying);
869 }
870 }
871
872 for underlying in underlyings {
873 let loaded = self
874 .fetch_option_chain_by_range(
875 client,
876 &underlying,
877 expiry_min.as_deref(),
878 expiry_max.as_deref(),
879 Some(options_exchange),
880 )
881 .await?;
882 tracing::debug!(
883 "Loaded {} option instruments for chain request {}.{}",
884 loaded,
885 underlying.symbol.as_str(),
886 underlying.exchange.as_str(),
887 );
888 }
889
890 loaded_ids.extend(self.cached_contract_ids_for(
891 contract.symbol.as_str(),
892 options_exchange,
893 &[SecurityType::Option, SecurityType::FuturesOption],
894 ));
895 }
896
897 if !build_futures_chain
898 && !build_options_chain
899 && let Some(instrument) = self.get_instrument(client, contract).await?
900 {
901 loaded_ids.push(instrument.id());
902 }
903
904 loaded_ids.sort_unstable();
905 loaded_ids.dedup();
906 Ok(loaded_ids)
907 }
908
909 fn cached_contract_ids_for(
910 &self,
911 symbol: &str,
912 exchange: &str,
913 security_types: &[SecurityType],
914 ) -> Vec<InstrumentId> {
915 self.cached_contracts_for(symbol, exchange, security_types)
916 .into_iter()
917 .map(|(instrument_id, _)| instrument_id)
918 .collect()
919 }
920
921 fn cached_contracts_for(
922 &self,
923 symbol: &str,
924 exchange: &str,
925 security_types: &[SecurityType],
926 ) -> Vec<(InstrumentId, Contract)> {
927 self.contracts
928 .iter()
929 .filter_map(|entry| {
930 let instrument_id = *entry.key();
931 let contract = entry.value();
932 let exchange_matches =
933 exchange.is_empty() || contract.exchange.as_str() == exchange;
934 if contract.symbol.as_str() == symbol
935 && exchange_matches
936 && security_types.contains(&contract.security_type)
937 {
938 Some((instrument_id, contract.clone()))
939 } else {
940 None
941 }
942 })
943 .collect()
944 }
945
946 #[must_use]
954 pub fn instrument_id_to_ib_contract_details(
955 &self,
956 instrument_id: &InstrumentId,
957 ) -> Option<ibapi::contracts::ContractDetails> {
958 self.contract_details
959 .get(instrument_id)
960 .map(|entry| entry.value().clone())
961 }
962
963 #[must_use]
964 pub fn instrument_id_to_ib_contract(&self, instrument_id: &InstrumentId) -> Option<Contract> {
965 self.contracts
966 .get(instrument_id)
967 .map(|entry| entry.value().clone())
968 }
969
970 pub fn resolve_contract_for_instrument(
971 &self,
972 instrument_id: InstrumentId,
973 ) -> anyhow::Result<Contract> {
974 let cached_contract = self.instrument_id_to_ib_contract(&instrument_id);
975 if let Some(contract) = cached_contract.as_ref()
976 && (contract.contract_id != 0 || is_spread_instrument_id(&instrument_id))
977 {
978 return Ok(contract.clone());
979 }
980
981 if let Some(details) = self.instrument_id_to_ib_contract_details(&instrument_id) {
982 return Ok(details.contract);
983 }
984
985 if let Some(contract) = cached_contract {
986 return Ok(contract);
987 }
988
989 let currency = self
990 .instruments
991 .get(&instrument_id)
992 .map(|instrument| instrument.quote_currency().code.to_string());
993 self.symbology
994 .contract_with_currency(instrument_id, None, currency.as_deref())
995 }
996
997 pub async fn resolve_contract_for_instrument_async(
998 &self,
999 client: &ibapi::Client,
1000 instrument_id: InstrumentId,
1001 ) -> anyhow::Result<Contract> {
1002 if let Ok(contract) = self.resolve_contract_for_instrument(instrument_id)
1003 && (contract.contract_id != 0 || self.contract_details.contains_key(&instrument_id))
1004 {
1005 return Ok(contract);
1006 }
1007
1008 if is_spread_instrument_id(&instrument_id) {
1009 self.fetch_spread_instrument(client, instrument_id, false, None)
1010 .await?;
1011 } else {
1012 self.fetch_contract_details(client, instrument_id, false, None)
1013 .await?;
1014 }
1015
1016 self.resolve_contract_for_instrument(instrument_id)
1017 }
1018
1019 pub async fn load_with_return_async(
1031 &self,
1032 client: &ibapi::Client,
1033 instrument_id: InstrumentId,
1034 filters: Option<HashMap<String, String>>,
1035 ) -> anyhow::Result<Option<InstrumentId>> {
1036 let filters: Option<HashMap<String, String>> = filters;
1037 let force_instrument_update = filters
1038 .as_ref()
1039 .and_then(|f| f.get("force_instrument_update"))
1040 .is_some_and(|v| v == "true");
1041
1042 if is_spread_instrument_id(&instrument_id) {
1043 self.fetch_spread_instrument(client, instrument_id, force_instrument_update, filters)
1044 .await?;
1045 } else {
1046 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
1047 .await?;
1048 }
1049
1050 if self.instruments.contains_key(&instrument_id) {
1051 Ok(Some(instrument_id))
1052 } else {
1053 Ok(None)
1054 }
1055 }
1056
1057 pub async fn load_ids_with_return_async(
1069 &self,
1070 client: &ibapi::Client,
1071 instrument_ids: Vec<InstrumentId>,
1072 filters: Option<HashMap<String, String>>,
1073 ) -> anyhow::Result<Vec<InstrumentId>> {
1074 let mut loaded_ids = Vec::new();
1075
1076 for instrument_id in instrument_ids {
1077 match self
1078 .load_with_return_async(client, instrument_id, filters.clone())
1079 .await
1080 {
1081 Ok(Some(loaded_id)) => loaded_ids.push(loaded_id),
1082 Ok(None) => {}
1083 Err(e) => {
1084 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1085 }
1086 }
1087 }
1088
1089 Ok(loaded_ids)
1090 }
1091
1092 fn create_bag_contract_from_legs(
1093 &self,
1094 leg_contract_details: &[(ibapi::contracts::ContractDetails, i32)],
1095 instrument_id: Option<InstrumentId>,
1096 bag_contract: Option<&Contract>,
1097 ) -> anyhow::Result<Contract> {
1098 if let Some(bag_contract) = bag_contract {
1099 return Ok(bag_contract.clone());
1100 }
1101
1102 let (first_details, _) = leg_contract_details
1103 .first()
1104 .ok_or_else(|| anyhow::anyhow!("Cannot create BAG contract without leg details"))?;
1105
1106 let combo_legs = leg_contract_details
1107 .iter()
1108 .map(|(details, ratio)| ibapi::contracts::ComboLeg {
1109 contract_id: details.contract.contract_id,
1110 ratio: ratio.abs(),
1111 action: if *ratio > 0 {
1112 LegAction::Buy
1113 } else {
1114 LegAction::Sell
1115 },
1116 exchange: details.contract.exchange.to_string(),
1117 open_close: ComboLegOpenClose::Same,
1118 short_sale_slot: 0,
1119 designated_location: String::new(),
1120 exempt_code: -1,
1121 })
1122 .collect();
1123
1124 Ok(Contract {
1125 symbol: first_details.contract.symbol.clone(),
1126 security_type: SecurityType::Spread,
1127 exchange: Exchange::from("SMART"),
1128 currency: first_details.contract.currency.clone(),
1129 local_symbol: instrument_id.map_or_else(String::new, |id| id.symbol.to_string()),
1130 combo_legs_description: instrument_id.map_or_else(
1131 || "Spread".to_string(),
1132 |id| format!("Spread: {}", id.symbol),
1133 ),
1134 combo_legs,
1135 ..Default::default()
1136 })
1137 }
1138
1139 pub async fn fetch_spread_instrument(
1153 &self,
1154 client: &ibapi::Client,
1155 spread_instrument_id: InstrumentId,
1156 force_instrument_update: bool,
1157 filters: Option<HashMap<String, String>>,
1158 ) -> anyhow::Result<bool> {
1159 if !force_instrument_update && self.instruments.contains_key(&spread_instrument_id) {
1161 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
1162 return Ok(true);
1163 }
1164
1165 let leg_tuples = parse_spread_instrument_id_to_legs(&spread_instrument_id)
1167 .context("Failed to parse spread instrument ID to leg tuples")?;
1168
1169 if leg_tuples.is_empty() {
1170 tracing::error!("Spread instrument {} has no legs", spread_instrument_id);
1171 return Ok(false);
1172 }
1173
1174 tracing::debug!(
1175 "Loading spread instrument {} with {} legs",
1176 spread_instrument_id,
1177 leg_tuples.len()
1178 );
1179
1180 let mut leg_contract_details = Vec::new();
1182
1183 for (leg_instrument_id, ratio) in &leg_tuples {
1184 tracing::debug!(
1185 "Loading leg instrument: {} (ratio: {})",
1186 leg_instrument_id,
1187 ratio
1188 );
1189
1190 self.fetch_contract_details(
1192 client,
1193 *leg_instrument_id,
1194 force_instrument_update,
1195 filters.clone(),
1196 )
1197 .await
1198 .with_context(|| format!("Failed to load leg instrument: {leg_instrument_id}"))?;
1199
1200 let leg_details = self
1202 .contract_details
1203 .get(leg_instrument_id)
1204 .map(|entry| entry.value().clone())
1205 .ok_or_else(|| {
1206 anyhow::anyhow!(
1207 "Leg instrument {leg_instrument_id} not found in contract details after loading"
1208 )
1209 })?;
1210
1211 leg_contract_details.push((leg_details, *ratio));
1212 }
1213
1214 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1216 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
1217 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
1218
1219 let bag_contract = self.create_bag_contract_from_legs(
1220 &leg_contract_details,
1221 Some(spread_instrument_id),
1222 None,
1223 )?;
1224 let spread_instrument = parse_spread_instrument_any(
1225 spread_instrument_id,
1226 &leg_details_refs,
1227 Some(&bag_contract),
1228 Some(timestamp),
1229 )
1230 .context("Failed to parse spread instrument")?;
1231
1232 self.instruments
1234 .insert(spread_instrument_id, spread_instrument);
1235 self.contracts.insert(spread_instrument_id, bag_contract);
1236
1237 if let Some((first_details, _)) = leg_contract_details.first() {
1238 self.price_magnifiers
1239 .insert(spread_instrument_id, first_details.price_magnifier);
1240 }
1241
1242 tracing::debug!(
1243 "Successfully loaded spread instrument {}",
1244 spread_instrument_id
1245 );
1246 Ok(true)
1247 }
1248
1249 pub async fn load_all_async(
1259 &self,
1260 client: &ibapi::Client,
1261 instrument_ids: Option<Vec<InstrumentId>>,
1262 contracts: Option<Vec<Contract>>,
1263 force_instrument_update: bool,
1264 ) -> anyhow::Result<Vec<InstrumentId>> {
1265 let mut loaded_ids = Vec::new();
1266
1267 let ids_to_load =
1269 instrument_ids.unwrap_or_else(|| self.config.load_ids.iter().copied().collect());
1270
1271 if !ids_to_load.is_empty() {
1272 let mut filters = std::collections::HashMap::new();
1273 if force_instrument_update {
1274 filters.insert("force_instrument_update".to_string(), "true".to_string());
1275 }
1276
1277 let filters = if filters.is_empty() {
1278 None
1279 } else {
1280 Some(filters)
1281 };
1282
1283 let ids_result = self
1284 .load_ids_with_return_async(client, ids_to_load, filters)
1285 .await
1286 .context("Failed to load instruments from IDs")?;
1287 loaded_ids.extend(ids_result);
1288 }
1289
1290 if let Some(contracts_to_load) = contracts {
1292 for contract in contracts_to_load {
1293 match self.load_contract_spec(client, &contract, None).await {
1294 Ok(mut instrument_ids) => {
1295 loaded_ids.append(&mut instrument_ids);
1296 }
1297 Err(e) => {
1298 tracing::warn!(
1299 "Error loading instrument from contract {:?}: {}",
1300 contract,
1301 e
1302 );
1303 }
1304 }
1305 }
1306 } else {
1307 for configured in &self.config.load_contracts {
1308 let spec = configured.chain_spec_json();
1309
1310 match self
1311 .load_contract_spec(client, &configured.contract, spec.as_ref())
1312 .await
1313 {
1314 Ok(mut instrument_ids) => {
1315 loaded_ids.append(&mut instrument_ids);
1316 }
1317 Err(e) => {
1318 tracing::warn!(
1319 "Error loading instrument from contract {:?}: {}",
1320 configured.contract,
1321 e
1322 );
1323 }
1324 }
1325 }
1326 }
1327
1328 if loaded_ids.is_empty() {
1329 tracing::debug!("load_all_async called but no instruments were loaded");
1330 } else {
1331 tracing::debug!("load_all_async loaded {} instruments", loaded_ids.len());
1332 }
1333
1334 Ok(loaded_ids)
1335 }
1336}
1337
1338fn normalize_price_magnifier(price_magnifier: i32) -> i32 {
1339 if price_magnifier > 0 {
1340 price_magnifier
1341 } else {
1342 1
1343 }
1344}
1345
1346fn json_opt_bool(spec: Option<&serde_json::Value>, key: &str) -> Option<bool> {
1347 spec.and_then(|value| value.get(key))
1348 .and_then(serde_json::Value::as_bool)
1349}
1350
1351fn json_u32(spec: Option<&serde_json::Value>, key: &str) -> Option<u32> {
1352 spec.and_then(|value| value.get(key))
1353 .and_then(serde_json::Value::as_u64)
1354 .and_then(|value| u32::try_from(value).ok())
1355}
1356
1357fn json_string(spec: Option<&serde_json::Value>, key: &str) -> Option<String> {
1358 spec.and_then(|value| value.get(key))
1359 .and_then(serde_json::Value::as_str)
1360 .filter(|value| !value.is_empty())
1361 .map(ToString::to_string)
1362}
1363
1364fn contract_from_instrument_info(instrument: &InstrumentAny) -> Option<Contract> {
1365 let value = serde_json::to_value(instrument).ok()?;
1366 let contract_json = find_contract_json(&value)?;
1367 parse_contract_from_json(contract_json).ok()
1368}
1369
1370fn price_magnifier_from_instrument_info(instrument: &InstrumentAny) -> Option<i32> {
1371 let value = serde_json::to_value(instrument).ok()?;
1372 let price_magnifier = find_price_magnifier_json(&value)?;
1373 parse_i32_json(price_magnifier)
1374}
1375
1376fn find_contract_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1377 if let Some(contract_json) = value.get("info").and_then(|info| info.get("contract")) {
1378 return Some(contract_json);
1379 }
1380
1381 value.as_object()?.values().find_map(find_contract_json)
1382}
1383
1384fn find_price_magnifier_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1385 if let Some(info) = value.get("info")
1386 && let Some(price_magnifier) = info
1387 .get("priceMagnifier")
1388 .or_else(|| info.get("price_magnifier"))
1389 {
1390 return Some(price_magnifier);
1391 }
1392
1393 value
1394 .as_object()?
1395 .values()
1396 .find_map(find_price_magnifier_json)
1397}
1398
1399fn parse_i32_json(value: &serde_json::Value) -> Option<i32> {
1400 if let Some(value) = value.as_i64() {
1401 return i32::try_from(value).ok();
1402 }
1403
1404 if let Some(value) = value.as_u64() {
1405 return i32::try_from(value).ok();
1406 }
1407
1408 value.as_str()?.parse::<i32>().ok()
1409}
1410
1411fn expiry_bound_from_days(days: Option<u32>) -> Option<String> {
1412 days.map(|days| {
1413 Offset::UTC
1414 .to_datetime(Timestamp::now())
1415 .date()
1416 .checked_add(Span::new().days(i64::from(days)))
1417 .expect("expiry bound date in range")
1418 .strftime("%Y%m%d")
1419 .to_string()
1420 })
1421}
1422
1423impl InteractiveBrokersInstrumentProvider {
1424 pub async fn fetch_contract_details(
1430 &self,
1431 client: &ibapi::Client,
1432 instrument_id: InstrumentId,
1433 force_instrument_update: bool,
1434 filters: Option<HashMap<String, String>>,
1435 ) -> anyhow::Result<()> {
1436 if !force_instrument_update
1437 && self.instruments.contains_key(&instrument_id)
1438 && (self.contract_details.contains_key(&instrument_id)
1439 || self.contracts.contains_key(&instrument_id))
1440 {
1441 tracing::debug!(
1442 "Instrument {} already cached, skipping fetch",
1443 instrument_id
1444 );
1445 return Ok(());
1446 }
1447
1448 let exchange = filters
1450 .as_ref()
1451 .and_then(|f| f.get("exchange"))
1452 .map(|s| s.as_str());
1453
1454 let exchanges_to_try: Vec<String> = if let Some(exchange) = exchange {
1455 vec![exchange.to_string()]
1456 } else {
1457 possible_exchanges_for_venue(instrument_id.venue.as_str())
1458 };
1459
1460 let mut details_vec = Vec::new();
1461 let mut last_error = None;
1462
1463 for candidate_exchange in exchanges_to_try {
1464 let contract = self
1465 .symbology
1466 .contract(instrument_id, Some(candidate_exchange.as_str()))
1467 .with_context(|| format!("Failed to convert instrument_id {instrument_id} to IB contract. Check that the instrument ID format is correct and the venue/symbol are valid."))?;
1468
1469 match client.contract_details(&contract).await {
1470 Ok(result) if !result.is_empty() => {
1471 details_vec = result;
1472 break;
1473 }
1474 Ok(_) => {}
1475 Err(e) => {
1476 last_error = Some((candidate_exchange.clone(), e));
1477 }
1478 }
1479 }
1480
1481 if details_vec.is_empty() {
1482 if let Some((candidate_exchange, e)) = last_error {
1483 return Err(e).with_context(|| {
1484 format!(
1485 "Failed to fetch contract details for {instrument_id} on {candidate_exchange}"
1486 )
1487 });
1488 } else {
1489 tracing::warn!(
1490 "No contract details returned for {} - instrument may not exist in IB or contract specification is incomplete",
1491 instrument_id
1492 );
1493 }
1494
1495 return Ok(());
1496 }
1497
1498 let loaded_ids = self.process_contract_details(
1499 details_vec,
1500 Some(instrument_id.venue),
1501 force_instrument_update,
1502 );
1503
1504 if loaded_ids.is_empty() {
1505 tracing::warn!("No contract details were processed for {}", instrument_id);
1506 } else {
1507 tracing::debug!(
1508 "Successfully loaded {} instrument(s) for {}",
1509 loaded_ids.len(),
1510 instrument_id
1511 );
1512 }
1513
1514 Ok(())
1515 }
1516
1517 fn process_contract_details(
1518 &self,
1519 details_vec: Vec<ibapi::contracts::ContractDetails>,
1520 venue: Option<Venue>,
1521 force_instrument_update: bool,
1522 ) -> Vec<InstrumentId> {
1523 let mut processed_ids = Vec::new();
1524
1525 for details in details_vec {
1526 match self.process_contract_detail(&details, venue, force_instrument_update) {
1527 Ok(Some(instrument_id)) => processed_ids.push(instrument_id),
1528 Ok(None) => {}
1529 Err(e) => {
1530 tracing::warn!(
1531 "Failed to process IB contract details con_id={} sec_type={}: {}",
1532 details.contract.contract_id,
1533 details.contract.security_type,
1534 e
1535 );
1536 }
1537 }
1538 }
1539
1540 processed_ids
1541 }
1542
1543 fn process_contract_detail(
1544 &self,
1545 details: &ibapi::contracts::ContractDetails,
1546 venue: Option<Venue>,
1547 force_instrument_update: bool,
1548 ) -> anyhow::Result<Option<InstrumentId>> {
1549 let sec_type = details.contract.security_type.to_string();
1550 if self.is_filtered_sec_type(&sec_type) {
1551 tracing::debug!(
1552 "Skipping filtered security type {} for contract {:?}",
1553 sec_type,
1554 details.contract
1555 );
1556 return Ok(None);
1557 }
1558
1559 let resolved_venue =
1560 venue.unwrap_or_else(|| self.determine_venue(&details.contract, Some(details)));
1561 let instrument_id = self
1562 .instrument_id_from_contract(&details.contract, resolved_venue)
1563 .context("Failed to convert IB contract to instrument ID")?;
1564
1565 let instrument = match parse_ib_contract_to_instrument(details, instrument_id) {
1566 Ok(instrument) => instrument,
1567 Err(e) => {
1568 tracing::warn!(
1569 "Failed to parse IB contract details for {}: {}",
1570 instrument_id,
1571 e
1572 );
1573 return Ok(None);
1574 }
1575 };
1576
1577 if !self.passes_filter_callable(&instrument)? {
1578 return Ok(None);
1579 }
1580
1581 self.cache_instrument(
1582 instrument_id,
1583 instrument,
1584 Some(details.clone()),
1585 None,
1586 None,
1587 force_instrument_update,
1588 );
1589
1590 Ok(Some(instrument_id))
1591 }
1592
1593 pub(crate) fn instrument_id_from_contract(
1594 &self,
1595 contract: &Contract,
1596 venue: Venue,
1597 ) -> anyhow::Result<InstrumentId> {
1598 self.symbology.instrument_id(contract, Some(venue))
1599 }
1600
1601 fn cache_instrument(
1602 &self,
1603 instrument_id: InstrumentId,
1604 instrument: InstrumentAny,
1605 details: Option<ibapi::contracts::ContractDetails>,
1606 contract: Option<Contract>,
1607 price_magnifier: Option<i32>,
1608 force_instrument_update: bool,
1609 ) -> bool {
1610 let should_update =
1611 force_instrument_update || !self.instruments.contains_key(&instrument_id);
1612 if should_update {
1613 self.instruments.insert(instrument_id, instrument);
1614 }
1615
1616 if let Some(details) = details {
1617 let contract_id = details.contract.contract_id;
1618 self.contracts
1619 .insert(instrument_id, details.contract.clone());
1620 self.contract_details.insert(instrument_id, details.clone());
1621
1622 if contract_id != 0 {
1623 self.contract_id_to_instrument_id
1624 .insert(contract_id, instrument_id);
1625 }
1626
1627 self.price_magnifiers.insert(
1628 instrument_id,
1629 normalize_price_magnifier(details.price_magnifier),
1630 );
1631 } else if let Some(contract) = contract {
1632 if contract.contract_id != 0 {
1633 self.contract_id_to_instrument_id
1634 .insert(contract.contract_id, instrument_id);
1635 }
1636
1637 self.contracts.insert(instrument_id, contract);
1638 }
1639
1640 if let Some(price_magnifier) = price_magnifier {
1641 self.price_magnifiers
1642 .insert(instrument_id, normalize_price_magnifier(price_magnifier));
1643 }
1644
1645 should_update
1646 }
1647
1648 fn passes_filter_callable(&self, instrument: &InstrumentAny) -> anyhow::Result<bool> {
1649 let Some(filter_callable) = self.config.filter_callable.as_deref() else {
1650 return Ok(true);
1651 };
1652
1653 #[cfg(feature = "python")]
1654 {
1655 use nautilus_model::python::instruments::instrument_any_to_pyobject;
1656 use pyo3::{prelude::*, types::PyModule};
1657
1658 Python::attach(|py| {
1659 let (module_name, callable_name) =
1660 filter_callable.rsplit_once('.').ok_or_else(|| {
1661 anyhow::anyhow!(
1662 "Invalid filter_callable path {filter_callable:?}; expected module.callable"
1663 )
1664 })?;
1665
1666 let callable = PyModule::import(py, module_name)
1667 .map_err(|e| anyhow::anyhow!("Failed to import {module_name}: {e}"))?
1668 .getattr(callable_name)
1669 .map_err(|e| anyhow::anyhow!("Failed to resolve {filter_callable}: {e}"))?;
1670 let py_instrument = instrument_any_to_pyobject(py, instrument.clone())
1671 .map_err(|e| anyhow::anyhow!("Failed to convert instrument to Python: {e}"))?;
1672 callable
1673 .call1((py_instrument,))
1674 .and_then(|result| result.extract::<bool>())
1675 .map_err(|e| anyhow::anyhow!("filter_callable {filter_callable} failed: {e}"))
1676 })
1677 }
1678
1679 #[cfg(not(feature = "python"))]
1680 {
1681 let _ = instrument;
1682 anyhow::bail!(
1683 "filter_callable {filter_callable:?} requires the Interactive Brokers adapter to be built with the python feature"
1684 );
1685 }
1686 }
1687
1688 pub async fn batch_load(
1700 &self,
1701 client: &ibapi::Client,
1702 instrument_ids: Vec<InstrumentId>,
1703 filters: Option<&[String]>,
1704 ) -> anyhow::Result<Vec<InstrumentId>> {
1705 let mut loaded_ids = Vec::new();
1706
1707 let filtered_ids: Vec<InstrumentId> = if let Some(filter_list) = filters {
1709 instrument_ids
1715 .into_iter()
1716 .filter(|instrument_id| {
1717 for filter in filter_list {
1719 if instrument_id
1721 .symbol
1722 .as_str()
1723 .to_lowercase()
1724 .contains(&filter.to_lowercase())
1725 {
1726 return true;
1727 }
1728
1729 if instrument_id.venue.as_str() == filter {
1731 return true;
1732 }
1733
1734 if let Some(contract_details) = self.contract_details.get(instrument_id) {
1736 let sec_type_str = contract_details.contract.security_type.to_string();
1737
1738 if sec_type_str.to_uppercase().contains(&filter.to_uppercase()) {
1739 return true;
1740 }
1741 }
1742 }
1743
1744 false
1745 })
1746 .collect()
1747 } else {
1748 instrument_ids
1749 };
1750
1751 let filtered_count = filtered_ids.len();
1753 for instrument_id in filtered_ids {
1754 match self
1755 .fetch_contract_details(client, instrument_id, false, None)
1756 .await
1757 {
1758 Ok(()) => loaded_ids.push(instrument_id),
1759 Err(e) => {
1760 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1761 }
1762 }
1763 }
1764
1765 tracing::debug!(
1766 "Batch loaded {} instruments ({} after filtering)",
1767 loaded_ids.len(),
1768 filtered_count
1769 );
1770
1771 if !loaded_ids.is_empty()
1773 && let Some(ref cache_path) = self.config.cache_path
1774 && let Err(e) = self.save_cache(cache_path).await
1775 {
1776 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
1777 }
1778
1779 Ok(loaded_ids)
1780 }
1781
1782 pub async fn fetch_option_chain_by_range(
1796 &self,
1797 client: &ibapi::Client,
1798 underlying: &Contract,
1799 expiry_min: Option<&str>,
1800 expiry_max: Option<&str>,
1801 option_chain_exchange: Option<&str>,
1802 ) -> anyhow::Result<usize> {
1803 let exchange = option_chain_exchange.unwrap_or_else(|| underlying.exchange.as_str());
1804 tracing::debug!(
1805 "Building option chain for {}.{} (sec_type={:?}, contract_id={}, expiry_min={:?}, expiry_max={:?}, config_min_days={:?}, config_max_days={:?})",
1806 underlying.symbol.as_str(),
1807 exchange,
1808 underlying.security_type,
1809 underlying.contract_id,
1810 expiry_min,
1811 expiry_max,
1812 self.config.min_expiry_days,
1813 self.config.max_expiry_days,
1814 );
1815
1816 let symbol = underlying.symbol.as_str();
1818 let mut option_chain_request = client.option_chain(
1819 symbol,
1820 underlying.security_type.clone(),
1821 underlying.contract_id,
1822 );
1823
1824 if underlying.security_type == SecurityType::Future {
1827 option_chain_request = option_chain_request.exchange(exchange);
1828 }
1829
1830 let mut option_chain_stream = option_chain_request
1831 .subscribe()
1832 .await
1833 .context("Failed to request option chain from IB")?;
1834
1835 let mut total_loaded = 0;
1836
1837 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1839
1840 let mut all_expirations = Vec::new();
1842
1843 while let Some(result) = option_chain_stream.next().await {
1844 match result {
1845 Ok(SubscriptionItem::Data(chain)) => {
1846 tracing::debug!(
1847 "Received option chain metadata exchange={} trading_class={} expirations={} strikes={}",
1848 chain.exchange,
1849 chain.trading_class,
1850 chain.expirations.len(),
1851 chain.strikes.len(),
1852 );
1853
1854 if chain.exchange != exchange {
1855 continue;
1856 }
1857
1858 for expiration in &chain.expirations {
1859 let date_filter_pass = match (expiry_min, expiry_max) {
1861 (Some(min), Some(max)) => {
1862 expiration.as_str() >= min && expiration.as_str() <= max
1863 }
1864 (Some(min), None) => expiration.as_str() >= min,
1865 (None, Some(max)) => expiration.as_str() <= max,
1866 (None, None) => true,
1867 };
1868
1869 let days_filter_pass = {
1871 let expiry_ns =
1872 expiry_timestring_to_unix_nanos(expiration.as_str(), None)
1873 .unwrap_or(now);
1874 let days_until_expiry =
1875 (expiry_ns.as_u64().saturating_sub(now.as_u64()))
1876 / (24 * 60 * 60 * 1_000_000_000);
1877
1878 let min_days_ok = self
1879 .config
1880 .min_expiry_days
1881 .is_none_or(|min| days_until_expiry >= min as u64);
1882 let max_days_ok = self
1883 .config
1884 .max_expiry_days
1885 .is_none_or(|max| days_until_expiry <= max as u64);
1886
1887 min_days_ok && max_days_ok
1888 };
1889
1890 if date_filter_pass
1891 && days_filter_pass
1892 && !all_expirations.contains(expiration)
1893 {
1894 all_expirations.push(expiration.clone());
1895 }
1896 }
1897 }
1898 Ok(SubscriptionItem::Notice(notice)) => {
1899 tracing::debug!("Received option chain notice: {notice:?}");
1900 }
1901 Err(e) => {
1902 tracing::warn!("Error receiving option chain metadata: {e}");
1903 }
1904 }
1905 }
1906
1907 all_expirations.sort_unstable();
1908
1909 tracing::debug!(
1910 "Filtered {} option expirations for {}.{}",
1911 all_expirations.len(),
1912 underlying.symbol.as_str(),
1913 exchange,
1914 );
1915
1916 for expiration in all_expirations {
1918 tracing::debug!(
1919 "Requesting option contract details for {}.{} expiry {}",
1920 underlying.symbol.as_str(),
1921 exchange,
1922 expiration,
1923 );
1924
1925 let option_contract = Contract {
1926 contract_id: 0,
1927 symbol: underlying.symbol.clone(),
1928 security_type: if underlying.security_type == SecurityType::Future {
1929 SecurityType::FuturesOption
1930 } else {
1931 SecurityType::Option
1932 },
1933 last_trade_date_or_contract_month: expiration.clone(),
1934 strike: f64::MAX,
1935 right: None,
1936 multiplier: String::new(),
1937 exchange: Exchange::from(exchange),
1938 currency: underlying.currency.clone(),
1939 local_symbol: String::new(),
1940 primary_exchange: Exchange::from(""),
1941 trading_class: String::new(),
1942 include_expired: false,
1943 security_id_type: None,
1944 security_id: String::new(),
1945 combo_legs_description: String::new(),
1946 combo_legs: Vec::new(),
1947 delta_neutral_contract: None,
1948 issuer_id: String::new(),
1949 description: String::new(),
1950 last_trade_date: None,
1951 };
1952
1953 match client.contract_details(&option_contract).await {
1954 Ok(details_vec) => {
1955 tracing::debug!(
1956 "Received {} raw option contract details for {}.{} expiry {}",
1957 details_vec.len(),
1958 underlying.symbol.as_str(),
1959 exchange,
1960 expiration,
1961 );
1962
1963 for details in details_vec {
1964 if details.under_contract_id != underlying.contract_id {
1966 continue;
1967 }
1968
1969 let contract_id = details.contract.contract_id;
1970
1971 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
1972 continue;
1973 }
1974
1975 match self.process_contract_detail(&details, None, false) {
1976 Ok(Some(_instrument_id)) => {
1977 total_loaded += 1;
1978 }
1979 Ok(None) => {}
1980 Err(e) => {
1981 tracing::warn!("Failed to parse option instrument: {}", e);
1982 }
1983 }
1984 }
1985 }
1986 Err(e) => {
1987 tracing::warn!(
1988 "Failed to fetch contract details for expiration {}: {}",
1989 expiration,
1990 e
1991 );
1992 }
1993 }
1994 }
1995
1996 tracing::debug!(
1997 "Successfully loaded {} option instruments from chain for {}.{}",
1998 total_loaded,
1999 underlying.symbol.as_str(),
2000 exchange,
2001 );
2002
2003 if total_loaded > 0
2005 && let Some(ref cache_path) = self.config.cache_path
2006 && let Err(e) = self.save_cache(cache_path).await
2007 {
2008 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2009 }
2010
2011 Ok(total_loaded)
2012 }
2013
2014 #[allow(clippy::too_many_arguments)] pub async fn fetch_futures_chain(
2028 &self,
2029 client: &ibapi::Client,
2030 symbol: &str,
2031 exchange: &str,
2032 currency: &str,
2033 trading_class: Option<&str>,
2034 include_expired: bool,
2035 min_expiry_days: Option<u32>,
2036 max_expiry_days: Option<u32>,
2037 ) -> anyhow::Result<usize> {
2038 tracing::debug!(
2039 "Building futures chain for {}.{} (currency={}, trading_class={:?}, include_expired={}, min_days={:?}, max_days={:?}, config_min_days={:?}, config_max_days={:?})",
2040 symbol,
2041 exchange,
2042 currency,
2043 trading_class,
2044 include_expired,
2045 min_expiry_days,
2046 max_expiry_days,
2047 self.config.min_expiry_days,
2048 self.config.max_expiry_days,
2049 );
2050
2051 let futures_contract = Contract {
2053 contract_id: 0,
2054 symbol: Symbol::from(symbol.to_string()),
2055 security_type: SecurityType::Future,
2056 last_trade_date_or_contract_month: String::new(),
2057 strike: f64::MAX,
2058 right: None,
2059 multiplier: String::new(),
2060 exchange: Exchange::from(exchange.to_string()),
2061 currency: ibapi::contracts::Currency::from(currency.to_string()),
2062 local_symbol: String::new(),
2063 primary_exchange: Exchange::from(""),
2064 trading_class: trading_class.unwrap_or_default().to_string(),
2065 include_expired,
2066 security_id_type: None,
2067 security_id: String::new(),
2068 combo_legs_description: String::new(),
2069 combo_legs: Vec::new(),
2070 delta_neutral_contract: None,
2071 issuer_id: String::new(),
2072 description: String::new(),
2073 last_trade_date: None,
2074 };
2075
2076 let details_vec = client
2078 .contract_details(&futures_contract)
2079 .await
2080 .context("Failed to fetch futures chain from IB")?;
2081
2082 tracing::debug!(
2083 "Received {} raw futures contract details for {}.{}",
2084 details_vec.len(),
2085 symbol,
2086 exchange,
2087 );
2088
2089 let mut total_loaded = 0;
2090 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2091
2092 for details in details_vec {
2093 let contract_id = details.contract.contract_id;
2094
2095 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2097 continue;
2098 }
2099
2100 let sec_type_str = details.contract.security_type.to_string();
2102 if self.is_filtered_sec_type(&sec_type_str) {
2103 continue;
2104 }
2105
2106 if !details
2108 .contract
2109 .last_trade_date_or_contract_month
2110 .is_empty()
2111 && let Ok(expiry_ns) = expiry_timestring_to_unix_nanos(
2112 &details.contract.last_trade_date_or_contract_month,
2113 Some(&details),
2114 )
2115 {
2116 let days_until_expiry = (expiry_ns.as_u64().saturating_sub(now.as_u64()))
2117 / (24 * 60 * 60 * 1_000_000_000);
2118
2119 let min_days_ok = min_expiry_days
2120 .or(self.config.min_expiry_days)
2121 .is_none_or(|min| days_until_expiry >= min as u64);
2122 let max_days_ok = max_expiry_days
2123 .or(self.config.max_expiry_days)
2124 .is_none_or(|max| days_until_expiry <= max as u64);
2125
2126 if !min_days_ok || !max_days_ok {
2127 continue;
2128 }
2129 }
2130
2131 match self.process_contract_detail(&details, None, false) {
2132 Ok(Some(_instrument_id)) => {
2133 total_loaded += 1;
2134 }
2135 Ok(None) => {}
2136 Err(e) => {
2137 tracing::warn!("Failed to parse futures instrument: {}", e);
2138 }
2139 }
2140 }
2141
2142 tracing::debug!(
2143 "Successfully loaded {} futures instruments from chain",
2144 total_loaded
2145 );
2146
2147 if total_loaded > 0
2149 && let Some(ref cache_path) = self.config.cache_path
2150 && let Err(e) = self.save_cache(cache_path).await
2151 {
2152 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2153 }
2154
2155 Ok(total_loaded)
2156 }
2157
2158 pub async fn fetch_bag_contract(
2177 &self,
2178 client: &ibapi::Client,
2179 bag_contract: &Contract,
2180 ) -> anyhow::Result<usize> {
2181 if bag_contract.security_type != SecurityType::Spread || bag_contract.combo_legs.is_empty()
2183 {
2184 anyhow::bail!(
2185 "Invalid BAG contract: must have security_type=Spread and non-empty combo_legs"
2186 );
2187 }
2188
2189 tracing::debug!(
2190 "Loading BAG contract with {} legs",
2191 bag_contract.combo_legs.len()
2192 );
2193
2194 let mut leg_contract_details = Vec::new();
2196 let mut leg_tuples = Vec::new();
2197
2198 for combo_leg in &bag_contract.combo_legs {
2199 let leg_contract = Contract {
2201 contract_id: combo_leg.contract_id, symbol: bag_contract.symbol.clone(), security_type: SecurityType::Option, last_trade_date_or_contract_month: String::new(),
2205 strike: 0.0,
2206 right: None,
2207 multiplier: String::new(),
2208 exchange: Exchange::from(combo_leg.exchange.as_str()),
2209 currency: bag_contract.currency.clone(), local_symbol: String::new(),
2211 primary_exchange: Exchange::default(),
2212 trading_class: String::new(),
2213 include_expired: false,
2214 security_id_type: None,
2215 security_id: String::new(),
2216 combo_legs_description: String::new(),
2217 combo_legs: Vec::new(),
2218 delta_neutral_contract: None,
2219 issuer_id: String::new(),
2220 description: String::new(),
2221 last_trade_date: None,
2222 };
2223
2224 let leg_details_vec =
2226 client
2227 .contract_details(&leg_contract)
2228 .await
2229 .with_context(|| {
2230 format!(
2231 "Failed to fetch contract details for leg conId {}",
2232 combo_leg.contract_id
2233 )
2234 })?;
2235
2236 if leg_details_vec.is_empty() {
2237 tracing::warn!(
2238 "No contract details returned for leg conId {}",
2239 combo_leg.contract_id
2240 );
2241 continue;
2242 }
2243
2244 let leg_details = &leg_details_vec[0];
2245 let leg_contract_id = leg_details.contract.contract_id;
2246
2247 let leg_instrument_id =
2249 if let Some(cached_id) = self.contract_id_to_instrument_id.get(&leg_contract_id) {
2250 *cached_id.value()
2251 } else {
2252 let leg_venue = self.determine_venue(&leg_details.contract, Some(leg_details));
2254 let leg_instrument_id = self
2255 .symbology
2256 .instrument_id(&leg_details.contract, Some(leg_venue))
2257 .context("Failed to convert leg contract to instrument ID")?;
2258
2259 let leg_instrument =
2261 parse_ib_contract_to_instrument(leg_details, leg_instrument_id)
2262 .context("Failed to parse leg instrument")?;
2263
2264 self.instruments.insert(leg_instrument_id, leg_instrument);
2265 self.contract_details
2266 .insert(leg_instrument_id, leg_details.clone());
2267 self.contracts
2268 .insert(leg_instrument_id, leg_details.contract.clone());
2269 self.contract_id_to_instrument_id
2270 .insert(leg_contract_id, leg_instrument_id);
2271 self.price_magnifiers
2272 .insert(leg_instrument_id, leg_details.price_magnifier);
2273
2274 leg_instrument_id
2275 };
2276
2277 let ratio = IbAction::from_str(combo_leg.action.as_str())
2279 .context("Invalid combo leg action")?
2280 .signed_multiplier()
2281 * combo_leg.ratio;
2282
2283 let leg_details_clone = self
2285 .contract_details
2286 .get(&leg_instrument_id)
2287 .map(|entry| entry.value().clone())
2288 .ok_or_else(|| {
2289 anyhow::anyhow!(
2290 "Contract details not found for leg {leg_instrument_id} after loading"
2291 )
2292 })?;
2293
2294 leg_contract_details.push((leg_details_clone, ratio));
2295 leg_tuples.push((leg_instrument_id, ratio));
2296 }
2297
2298 if leg_tuples.is_empty() {
2299 anyhow::bail!("No valid legs loaded for BAG contract");
2300 }
2301
2302 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
2304 .context("Failed to create spread instrument ID from leg tuples")?;
2305
2306 let bag_details_vec = client
2308 .contract_details(bag_contract)
2309 .await
2310 .context("Failed to fetch BAG contract details from IB")?;
2311
2312 if bag_details_vec.is_empty() {
2313 tracing::warn!("No contract details returned for BAG contract");
2314
2315 if bag_contract.contract_id != 0 && self.instruments.contains_key(&spread_instrument_id)
2316 {
2317 self.contract_id_to_instrument_id
2318 .insert(bag_contract.contract_id, spread_instrument_id);
2319 }
2320
2321 return Ok(0);
2322 }
2323
2324 let bag_details = &bag_details_vec[0];
2325 let bag_contract_id = bag_details.contract.contract_id;
2326
2327 if bag_contract_id != 0 {
2328 self.contract_id_to_instrument_id
2329 .insert(bag_contract_id, spread_instrument_id);
2330 }
2331
2332 if self.instruments.contains_key(&spread_instrument_id) {
2334 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
2335 self.contract_details
2336 .insert(spread_instrument_id, bag_details.clone());
2337 self.contracts
2338 .insert(spread_instrument_id, bag_details.contract.clone());
2339 self.price_magnifiers
2340 .insert(spread_instrument_id, bag_details.price_magnifier);
2341 return Ok(0);
2342 }
2343
2344 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2346
2347 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
2349 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
2350
2351 let spread_instrument = parse_spread_instrument_any(
2352 spread_instrument_id,
2353 &leg_details_refs,
2354 Some(&bag_details.contract),
2355 Some(timestamp),
2356 )
2357 .context("Failed to parse spread instrument")?;
2358
2359 self.instruments
2361 .insert(spread_instrument_id, spread_instrument);
2362 self.contract_details
2363 .insert(spread_instrument_id, bag_details.clone());
2364 self.contracts
2365 .insert(spread_instrument_id, bag_details.contract.clone());
2366 self.price_magnifiers
2367 .insert(spread_instrument_id, bag_details.price_magnifier);
2368
2369 tracing::debug!(
2370 "Successfully loaded spread instrument {} with {} legs",
2371 spread_instrument_id,
2372 leg_tuples.len()
2373 );
2374
2375 if let Some(ref cache_path) = self.config.cache_path
2377 && let Err(e) = self.save_cache(cache_path).await
2378 {
2379 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2380 }
2381
2382 Ok(1)
2383 }
2384
2385 pub async fn save_cache(&self, cache_path: &str) -> anyhow::Result<()> {
2391 let cache = InstrumentCache {
2392 cache_timestamp: Timestamp::now(),
2393 contract_id_to_instrument_id: self
2394 .contract_id_to_instrument_id
2395 .iter()
2396 .map(|entry| (*entry.key(), entry.value().to_string()))
2397 .collect(),
2398 price_magnifiers: self
2399 .price_magnifiers
2400 .iter()
2401 .map(|entry| (entry.key().to_string(), *entry.value()))
2402 .collect(),
2403 contracts: self
2404 .contracts
2405 .iter()
2406 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2407 .collect(),
2408 contract_details: self
2409 .contract_details
2410 .iter()
2411 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2412 .collect(),
2413 instruments: self
2414 .instruments
2415 .iter()
2416 .map(|entry| {
2417 let instrument_id = entry.key().to_string();
2418 let json =
2419 serde_json::to_string(entry.value()).unwrap_or_else(|_| String::new());
2420 (instrument_id, json)
2421 })
2422 .collect(),
2423 };
2424
2425 if let Some(parent) = Path::new(cache_path).parent() {
2427 fs::create_dir_all(parent)?;
2428 }
2429
2430 let json = serde_json::to_string_pretty(&cache)?;
2432 fs::write(cache_path, json)?;
2433 tracing::debug!(
2434 "Saved instrument cache to {} ({} instruments)",
2435 cache_path,
2436 cache.instruments.len()
2437 );
2438 Ok(())
2439 }
2440
2441 pub async fn load_cache(&self, cache_path: &str) -> anyhow::Result<bool> {
2451 if !Path::new(cache_path).exists() {
2453 tracing::debug!("Cache file does not exist: {}", cache_path);
2454 return Ok(false);
2455 }
2456
2457 let json = fs::read_to_string(cache_path)?;
2459 let cache: InstrumentCache = serde_json::from_str(&json)?;
2460
2461 if let Some(validity_days) = self.config.cache_validity_days {
2463 let cache_age = cache.cache_timestamp.duration_until(Timestamp::now());
2464 let max_age = jiff::SignedDuration::from_hours(24 * (validity_days as i64));
2465 if cache_age > max_age {
2466 tracing::debug!(
2467 "Cache is expired (age: {} days, max: {} days). Ignoring cache",
2468 cache_age.as_secs() / (24 * 60 * 60),
2469 validity_days
2470 );
2471 return Ok(false);
2472 }
2473 }
2474
2475 let mut loaded_count = 0;
2477
2478 for (instrument_id_str, instrument_json) in &cache.instruments {
2479 match InstrumentId::from_str(instrument_id_str) {
2480 Ok(instrument_id) => match serde_json::from_str::<InstrumentAny>(instrument_json) {
2481 Ok(instrument) => {
2482 self.instruments.insert(instrument_id, instrument);
2483
2484 if let Ok(value) =
2485 serde_json::from_str::<serde_json::Value>(instrument_json)
2486 && let Some(contract_json) = find_contract_json(&value)
2487 && let Ok(contract) = parse_contract_from_json(contract_json)
2488 {
2489 if contract.contract_id != 0 {
2490 self.contract_id_to_instrument_id
2491 .insert(contract.contract_id, instrument_id);
2492 }
2493
2494 self.contracts.insert(instrument_id, contract);
2495 }
2496
2497 loaded_count += 1;
2498 }
2499 Err(e) => {
2500 tracing::warn!(
2501 "Failed to deserialize instrument {}: {}",
2502 instrument_id_str,
2503 e
2504 );
2505 }
2506 },
2507 Err(e) => {
2508 tracing::warn!("Failed to parse instrument ID {}: {}", instrument_id_str, e);
2509 }
2510 }
2511 }
2512
2513 for (instrument_id_str, contract) in &cache.contracts {
2515 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2516 if contract.contract_id != 0 {
2517 self.contract_id_to_instrument_id
2518 .insert(contract.contract_id, instrument_id);
2519 }
2520
2521 self.contracts.insert(instrument_id, contract.clone());
2522 }
2523 }
2524
2525 for (instrument_id_str, details) in &cache.contract_details {
2526 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2527 if details.contract.contract_id != 0 {
2528 self.contract_id_to_instrument_id
2529 .insert(details.contract.contract_id, instrument_id);
2530 }
2531
2532 self.contracts
2533 .insert(instrument_id, details.contract.clone());
2534 self.contract_details.insert(instrument_id, details.clone());
2535 }
2536 }
2537
2538 for (contract_id, instrument_id_str) in &cache.contract_id_to_instrument_id {
2540 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2541 self.contract_id_to_instrument_id
2542 .insert(*contract_id, instrument_id);
2543 }
2544 }
2545
2546 for (instrument_id_str, magnifier) in &cache.price_magnifiers {
2548 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2549 self.price_magnifiers.insert(instrument_id, *magnifier);
2550 }
2551 }
2552
2553 tracing::debug!(
2554 "Loaded instrument cache from {} ({} instruments, created at {})",
2555 cache_path,
2556 loaded_count,
2557 cache.cache_timestamp
2558 );
2559 Ok(true)
2560 }
2561}
2562
2563#[cfg(test)]
2564mod tests {
2565 use std::{
2566 fs,
2567 sync::atomic::{AtomicBool, AtomicUsize, Ordering},
2568 };
2569
2570 use nautilus_core::{Params, UnixNanos};
2571 use nautilus_model::{
2572 identifiers::{Symbol, Venue},
2573 instruments::CurrencyPair,
2574 types::{Price, Quantity, currency::Currency},
2575 };
2576 use rstest::rstest;
2577 use tempfile::TempDir;
2578
2579 use super::*;
2580 use crate::common::contract_to_json_value;
2581
2582 struct TestStartupLoader {
2583 id_calls: AtomicUsize,
2584 contract_calls: AtomicUsize,
2585 fail_next_id: AtomicBool,
2586 resolve_ids: AtomicBool,
2587 resolve_contracts: AtomicBool,
2588 yield_on_load: bool,
2589 }
2590
2591 impl TestStartupLoader {
2592 fn new(resolve_ids: bool, resolve_contracts: bool) -> Self {
2593 Self {
2594 id_calls: AtomicUsize::new(0),
2595 contract_calls: AtomicUsize::new(0),
2596 fail_next_id: AtomicBool::new(false),
2597 resolve_ids: AtomicBool::new(resolve_ids),
2598 resolve_contracts: AtomicBool::new(resolve_contracts),
2599 yield_on_load: false,
2600 }
2601 }
2602 }
2603
2604 impl StartupInstrumentLoader for TestStartupLoader {
2605 async fn load_instrument_id(
2606 &self,
2607 instrument_id: InstrumentId,
2608 ) -> anyhow::Result<Option<InstrumentId>> {
2609 self.id_calls.fetch_add(1, Ordering::SeqCst);
2610
2611 if self.yield_on_load {
2612 tokio::task::yield_now().await;
2613 }
2614
2615 if self.fail_next_id.swap(false, Ordering::SeqCst) {
2616 anyhow::bail!("Socket disconnected");
2617 }
2618
2619 Ok(self
2620 .resolve_ids
2621 .load(Ordering::SeqCst)
2622 .then_some(instrument_id))
2623 }
2624
2625 async fn load_contract(
2626 &self,
2627 _configured: &ConfiguredContract,
2628 ) -> anyhow::Result<Vec<InstrumentId>> {
2629 self.contract_calls.fetch_add(1, Ordering::SeqCst);
2630
2631 if self.yield_on_load {
2632 tokio::task::yield_now().await;
2633 }
2634
2635 Ok(if self.resolve_contracts.load(Ordering::SeqCst) {
2636 vec![InstrumentId::new(
2637 Symbol::from("MSFT"),
2638 Venue::from("NASDAQ"),
2639 )]
2640 } else {
2641 Vec::new()
2642 })
2643 }
2644 }
2645
2646 fn create_test_provider_with_cache() -> (InteractiveBrokersInstrumentProvider, TempDir) {
2647 let temp_dir = TempDir::new().unwrap();
2648 let cache_path = temp_dir
2649 .path()
2650 .join("test_cache.json")
2651 .to_str()
2652 .unwrap()
2653 .to_string();
2654
2655 let config = InteractiveBrokersInstrumentProviderConfig::builder()
2656 .cache_path(cache_path)
2657 .cache_validity_days(7u32)
2658 .build();
2659
2660 let provider = InteractiveBrokersInstrumentProvider::new(config);
2661 (provider, temp_dir)
2662 }
2663
2664 fn opra_option_contract_details(mut contract: Contract) -> ibapi::contracts::ContractDetails {
2665 contract.contract_id = 12_345;
2666 contract.symbol = ibapi::contracts::Symbol::from("AAPL");
2667 contract.security_type = SecurityType::Option;
2668 contract.exchange = Exchange::from("SMART");
2669 contract.currency = ibapi::contracts::Currency::from("USD");
2670 contract.local_symbol = "AAPL 270115P00155000".to_string();
2671 contract.last_trade_date_or_contract_month = "20270115".to_string();
2672 contract.strike = 155.0;
2673 contract.right = Some(ibapi::contracts::OptionRight::Put);
2674 contract.multiplier = "100".to_string();
2675
2676 ibapi::contracts::ContractDetails {
2677 contract,
2678 min_tick: 0.01,
2679 under_symbol: "AAPL".to_string(),
2680 under_security_type: "STK".to_string(),
2681 valid_exchanges: vec!["SMART".to_string(), "CBOE".to_string()],
2682 ..Default::default()
2683 }
2684 }
2685
2686 fn create_test_instrument(instrument_id: InstrumentId) -> InstrumentAny {
2687 create_test_instrument_with_info(instrument_id, None)
2688 }
2689
2690 #[rstest]
2691 fn test_qualified_opra_details_preserve_canonical_instrument_identity() {
2692 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
2693 let requested_id = InstrumentId::from("AAPL 270115P00155000.OPRA");
2694 let request = provider
2695 .resolve_contract_for_instrument(requested_id)
2696 .unwrap();
2697
2698 assert_eq!(request.security_type, SecurityType::Option);
2699 assert_eq!(request.exchange.as_str(), "SMART");
2700 assert!(request.symbol.as_str().is_empty());
2701 assert_eq!(request.currency.as_str(), "USD");
2702 assert_eq!(request.local_symbol, "AAPL 270115P00155000");
2703 assert!(request.last_trade_date_or_contract_month.is_empty());
2704 assert!(request.right.is_none());
2705 assert_eq!(request.strike, 0.0);
2706
2707 let details = opra_option_contract_details(request);
2708 let loaded_id = provider
2709 .process_contract_detail(&details, Some(requested_id.venue), false)
2710 .unwrap()
2711 .unwrap();
2712
2713 assert_eq!(loaded_id, requested_id);
2714 assert_eq!(provider.count(), 1);
2715 assert_eq!(
2716 provider.get_instrument_id_by_contract_id(12_345),
2717 Some(requested_id)
2718 );
2719 assert_eq!(
2720 provider
2721 .resolve_instrument_id_for_contract(&details.contract)
2722 .unwrap(),
2723 requested_id
2724 );
2725
2726 let cached = provider.find(&requested_id).unwrap();
2727
2728 let InstrumentAny::OptionContract(option) = cached else {
2729 panic!("expected option contract");
2730 };
2731
2732 assert_eq!(option.id, requested_id);
2733 assert_eq!(option.id.venue.as_str(), "OPRA");
2734 assert!(
2735 provider
2736 .find(&InstrumentId::from("AAPL 270115P00155000.SMART"))
2737 .is_none()
2738 );
2739
2740 let cached_contract = provider
2741 .instrument_id_to_ib_contract(&requested_id)
2742 .unwrap();
2743 assert_eq!(cached_contract.contract_id, 12_345);
2744 assert_eq!(cached_contract.security_type, SecurityType::Option);
2745 assert_eq!(cached_contract.exchange.as_str(), "SMART");
2746
2747 let resolved_contract = provider
2748 .resolve_contract_for_instrument(requested_id)
2749 .unwrap();
2750 assert_eq!(resolved_contract, cached_contract);
2751
2752 let cached_details = provider
2753 .instrument_id_to_ib_contract_details(&requested_id)
2754 .unwrap();
2755 assert_eq!(cached_details.contract.contract_id, 12_345);
2756 assert_eq!(
2757 cached_details.valid_exchanges,
2758 vec!["SMART".to_string(), "CBOE".to_string()]
2759 );
2760 }
2761
2762 fn create_test_instrument_with_info(
2763 instrument_id: InstrumentId,
2764 info: Option<Params>,
2765 ) -> InstrumentAny {
2766 CurrencyPair::builder()
2767 .instrument_id(instrument_id)
2768 .raw_symbol(Symbol::from("EUR/USD"))
2769 .base_currency(Currency::from("EUR"))
2770 .quote_currency(Currency::from("USD"))
2771 .price_precision(4)
2772 .size_precision(0)
2773 .price_increment(Price::from("0.0001"))
2774 .size_increment(Quantity::from(1))
2775 .maybe_info(info)
2776 .ts_event(UnixNanos::default())
2777 .ts_init(UnixNanos::default())
2778 .build()
2779 .unwrap()
2780 .into()
2781 }
2782
2783 fn create_contract_info(contract: &Contract, price_magnifier: Option<i32>) -> Params {
2784 let mut info = Params::new();
2785 info.insert(String::from("contract"), contract_to_json_value(contract));
2786 if let Some(price_magnifier) = price_magnifier {
2787 info.insert(
2788 String::from("priceMagnifier"),
2789 serde_json::Value::from(price_magnifier),
2790 );
2791 }
2792
2793 info
2794 }
2795
2796 #[tokio::test]
2797 async fn test_initialize_loads_all_configured_inputs_once() {
2798 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2799
2800 let contract_spec = Contract {
2801 security_type: SecurityType::Stock,
2802 symbol: ibapi::contracts::Symbol::from("MSFT"),
2803 exchange: Exchange::from("NASDAQ"),
2804 ..Default::default()
2805 };
2806
2807 let config = InteractiveBrokersInstrumentProviderConfig {
2808 load_ids: [instrument_id].into_iter().collect(),
2809 load_contracts: vec![ConfiguredContract::from(contract_spec)],
2810 ..Default::default()
2811 };
2812
2813 let provider = InteractiveBrokersInstrumentProvider::new(config);
2814 let loader = TestStartupLoader::new(true, true);
2815
2816 let loaded_ids = provider.initialize_with_loader(&loader).await.unwrap();
2817 let second_result = provider.initialize_with_loader(&loader).await.unwrap();
2818
2819 assert_eq!(
2820 loaded_ids,
2821 vec![
2822 instrument_id,
2823 InstrumentId::new(Symbol::from("MSFT"), Venue::from("NASDAQ")),
2824 ]
2825 );
2826 assert!(second_result.is_empty());
2827 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
2828 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 1);
2829 assert!(*provider.startup_initialized.lock().await);
2830 }
2831
2832 #[tokio::test]
2833 async fn test_initialize_fails_closed_and_retries_unresolved_input() {
2834 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2835
2836 let contract_spec = Contract {
2837 security_type: SecurityType::Stock,
2838 symbol: ibapi::contracts::Symbol::from("MSFT"),
2839 exchange: Exchange::from("NASDAQ"),
2840 ..Default::default()
2841 };
2842
2843 let config = InteractiveBrokersInstrumentProviderConfig {
2844 load_ids: [instrument_id].into_iter().collect(),
2845 load_contracts: vec![ConfiguredContract::from(contract_spec)],
2846 ..Default::default()
2847 };
2848
2849 let provider = InteractiveBrokersInstrumentProvider::new(config);
2850 let loader = TestStartupLoader::new(true, false);
2851
2852 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
2853
2854 assert!(error.to_string().contains("contract at index 0"));
2855 assert!(!*provider.startup_initialized.lock().await);
2856
2857 loader.resolve_contracts.store(true, Ordering::SeqCst);
2858 provider.initialize_with_loader(&loader).await.unwrap();
2859
2860 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
2861 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 2);
2862 assert!(*provider.startup_initialized.lock().await);
2863 }
2864
2865 #[tokio::test]
2866 async fn test_initialize_preserves_load_error_and_allows_retry() {
2867 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2868
2869 let config = InteractiveBrokersInstrumentProviderConfig {
2870 load_ids: [instrument_id].into_iter().collect(),
2871 ..Default::default()
2872 };
2873
2874 let provider = InteractiveBrokersInstrumentProvider::new(config);
2875 let loader = TestStartupLoader::new(true, true);
2876 loader.fail_next_id.store(true, Ordering::SeqCst);
2877
2878 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
2879
2880 let error_chain = format!("{error:#}");
2881 assert!(error_chain.contains("Failed to load configured IB instrument ID AAPL.NASDAQ"));
2882 assert!(error_chain.contains("Socket disconnected"));
2883 assert!(!*provider.startup_initialized.lock().await);
2884
2885 provider.initialize_with_loader(&loader).await.unwrap();
2886
2887 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
2888 assert!(*provider.startup_initialized.lock().await);
2889 }
2890
2891 #[tokio::test]
2892 async fn test_initialize_serializes_concurrent_calls() {
2893 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2894
2895 let config = InteractiveBrokersInstrumentProviderConfig {
2896 load_ids: [instrument_id].into_iter().collect(),
2897 ..Default::default()
2898 };
2899
2900 let provider = InteractiveBrokersInstrumentProvider::new(config);
2901 let mut loader = TestStartupLoader::new(true, true);
2902 loader.yield_on_load = true;
2903
2904 let (first, second) = tokio::join!(
2905 provider.initialize_with_loader(&loader),
2906 provider.initialize_with_loader(&loader),
2907 );
2908
2909 assert_eq!(first.unwrap().len() + second.unwrap().len(), 1);
2910 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
2911 assert!(*provider.startup_initialized.lock().await);
2912 }
2913
2914 #[tokio::test]
2915 async fn test_save_cache() {
2916 let (provider, _temp_dir) = create_test_provider_with_cache();
2917 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2918
2919 let instrument_id1 = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
2921 let instrument_id2 = InstrumentId::new(Symbol::from("GBP/USD"), Venue::from("IDEALPRO"));
2922
2923 let instrument1 = create_test_instrument(instrument_id1);
2924 let instrument2 = create_test_instrument(instrument_id2);
2925
2926 provider.instruments.insert(instrument_id1, instrument1);
2927 provider.instruments.insert(instrument_id2, instrument2);
2928 provider
2929 .contract_id_to_instrument_id
2930 .insert(100, instrument_id1);
2931 provider
2932 .contract_id_to_instrument_id
2933 .insert(200, instrument_id2);
2934 provider.price_magnifiers.insert(instrument_id1, 1);
2935 provider.price_magnifiers.insert(instrument_id2, 1);
2936
2937 let result = provider.save_cache(&cache_path).await;
2939 assert!(result.is_ok(), "save_cache should succeed");
2940
2941 assert!(Path::new(&cache_path).exists(), "Cache file should exist");
2943
2944 let contents = fs::read_to_string(&cache_path).unwrap();
2946 assert!(
2947 contents.contains("EUR/USD"),
2948 "Cache should contain instrument data"
2949 );
2950 assert!(
2951 contents.contains("cache_timestamp"),
2952 "Cache should contain timestamp"
2953 );
2954 }
2955
2956 #[tokio::test]
2957 async fn test_load_cache_valid() {
2958 let (provider, _temp_dir) = create_test_provider_with_cache();
2959 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2960
2961 let instrument_id = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
2963 let instrument = create_test_instrument(instrument_id);
2964
2965 provider
2966 .instruments
2967 .insert(instrument_id, instrument.clone());
2968 provider
2969 .contract_id_to_instrument_id
2970 .insert(100, instrument_id);
2971 provider.price_magnifiers.insert(instrument_id, 1);
2972
2973 provider.save_cache(&cache_path).await.unwrap();
2974
2975 let new_config = InteractiveBrokersInstrumentProviderConfig::builder()
2977 .cache_path(cache_path.clone())
2978 .cache_validity_days(7u32)
2979 .build();
2980
2981 let new_provider = InteractiveBrokersInstrumentProvider::new(new_config);
2982
2983 let result = new_provider.load_cache(&cache_path).await;
2984 assert!(result.is_ok(), "load_cache should succeed");
2985 assert!(
2986 result.unwrap(),
2987 "load_cache should return true for valid cache"
2988 );
2989
2990 assert!(
2992 new_provider.find(&instrument_id).is_some(),
2993 "Instrument should be loaded from cache"
2994 );
2995 assert_eq!(new_provider.count(), 1, "Provider should have 1 instrument");
2996 }
2997
2998 #[tokio::test]
2999 async fn test_load_cache_reads_chrono_timestamp() {
3000 let provider = InteractiveBrokersInstrumentProvider::new(
3001 InteractiveBrokersInstrumentProviderConfig::builder()
3002 .cache_validity_days(7u32)
3003 .build(),
3004 );
3005 let cache_path = concat!(
3006 env!("CARGO_MANIFEST_DIR"),
3007 "/test_data/instrument_cache_chrono.json"
3008 );
3009
3010 let loaded = provider.load_cache(cache_path).await.unwrap();
3011
3012 assert!(loaded);
3013 assert_eq!(provider.count(), 0);
3014 }
3015
3016 #[tokio::test]
3017 async fn test_load_cache_restores_contract_details() {
3018 let (provider, _temp_dir) = create_test_provider_with_cache();
3019 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3020 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3021 let instrument = create_test_instrument(instrument_id);
3022
3023 let contract = Contract {
3024 contract_id: 265598,
3025 symbol: ibapi::contracts::Symbol::from("AAPL"),
3026 security_type: SecurityType::Stock,
3027 exchange: Exchange::from("SMART"),
3028 primary_exchange: Exchange::from("NASDAQ"),
3029 currency: ibapi::contracts::Currency::from("USD"),
3030 ..Default::default()
3031 };
3032
3033 let details = ibapi::contracts::ContractDetails {
3034 contract: contract.clone(),
3035 price_magnifier: 1,
3036 ..Default::default()
3037 };
3038
3039 provider.cache_instrument(
3040 instrument_id,
3041 instrument,
3042 Some(details),
3043 Some(contract),
3044 Some(1),
3045 false,
3046 );
3047 provider.save_cache(&cache_path).await.unwrap();
3048
3049 let new_provider = InteractiveBrokersInstrumentProvider::new(provider.config.clone());
3050
3051 assert!(new_provider.load_cache(&cache_path).await.unwrap());
3052 assert_eq!(
3053 new_provider
3054 .resolve_contract_for_instrument(instrument_id)
3055 .unwrap()
3056 .contract_id,
3057 265598
3058 );
3059 assert_eq!(
3060 new_provider
3061 .instrument_id_to_ib_contract_details(&instrument_id)
3062 .unwrap()
3063 .contract
3064 .contract_id,
3065 265598
3066 );
3067 }
3068
3069 #[rstest]
3070 fn test_filter_sec_types_uses_typed_ib_codes() {
3071 let config = InteractiveBrokersInstrumentProviderConfig {
3072 filter_sec_types: [IbSecurityType::Option].into_iter().collect(),
3073 ..Default::default()
3074 };
3075
3076 let provider = InteractiveBrokersInstrumentProvider::new(config);
3077
3078 assert!(provider.is_filtered_sec_type(&SecurityType::Option.to_string()));
3079 assert!(!provider.is_filtered_sec_type(&SecurityType::Stock.to_string()));
3080 }
3081
3082 #[rstest]
3083 fn test_add_cached_instruments_only_seeds_ib_contracts() {
3084 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
3085 let ib_instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3086 let non_ib_instrument_id =
3087 InstrumentId::new(Symbol::from("BTCUSDT"), Venue::from("BINANCE"));
3088
3089 let contract = Contract {
3090 contract_id: 265598,
3091 symbol: ibapi::contracts::Symbol::from("AAPL"),
3092 security_type: SecurityType::Stock,
3093 exchange: Exchange::from("SMART"),
3094 primary_exchange: Exchange::from("NASDAQ"),
3095 currency: ibapi::contracts::Currency::from("USD"),
3096 ..Default::default()
3097 };
3098
3099 let ib_instrument = create_test_instrument_with_info(
3100 ib_instrument_id,
3101 Some(create_contract_info(&contract, Some(100))),
3102 );
3103 let non_ib_instrument = create_test_instrument(non_ib_instrument_id);
3104
3105 let count = provider.add_cached_instruments([ib_instrument, non_ib_instrument]);
3106
3107 assert_eq!(count, 1);
3108 assert_eq!(provider.count(), 1);
3109 assert!(provider.find(&ib_instrument_id).is_some());
3110 assert!(provider.find(&non_ib_instrument_id).is_none());
3111 assert_eq!(
3112 provider
3113 .resolve_contract_for_instrument(ib_instrument_id)
3114 .unwrap()
3115 .contract_id,
3116 265598
3117 );
3118 assert_eq!(provider.get_price_magnifier(&ib_instrument_id), 100);
3119 }
3120
3121 #[tokio::test]
3122 async fn test_load_cache_missing_file() {
3123 let (provider, _temp_dir) = create_test_provider_with_cache();
3124 let cache_path = "/nonexistent/path/cache.json";
3125
3126 let result = provider.load_cache(cache_path).await;
3127 assert!(
3128 result.is_ok(),
3129 "load_cache should not error on missing file"
3130 );
3131 assert!(
3132 !result.unwrap(),
3133 "load_cache should return false for missing file"
3134 );
3135 }
3136
3137 #[tokio::test]
3138 async fn test_load_cache_expired() {
3139 let (provider, _temp_dir) = create_test_provider_with_cache();
3140 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3141
3142 let old_timestamp = Timestamp::now() - jiff::SignedDuration::from_hours(24 * (10));
3144
3145 let expired_cache = InstrumentCache {
3146 cache_timestamp: old_timestamp,
3147 contract_id_to_instrument_id: vec![],
3148 price_magnifiers: vec![],
3149 contracts: vec![],
3150 contract_details: vec![],
3151 instruments: vec![],
3152 };
3153
3154 let json = serde_json::to_string_pretty(&expired_cache).unwrap();
3155 fs::write(&cache_path, json).unwrap();
3156
3157 let result = provider.load_cache(&cache_path).await;
3159 assert!(
3160 result.is_ok(),
3161 "load_cache should not error on expired cache"
3162 );
3163 assert!(
3164 !result.unwrap(),
3165 "load_cache should return false for expired cache"
3166 );
3167 }
3168 #[rstest]
3169 fn restored_cache_seeds_provider_with_price_magnifier() {
3170 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
3171 let mut cache = Cache::default();
3172 assert_eq!(provider.seed_from_cache(&cache), 0);
3173 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
3174
3175 let contract = Contract {
3176 contract_id: 265598,
3177 symbol: ibapi::contracts::Symbol::from("AAPL"),
3178 security_type: SecurityType::Stock,
3179 exchange: Exchange::from("SMART"),
3180 currency: ibapi::contracts::Currency::from("USD"),
3181 ..Default::default()
3182 };
3183
3184 let instrument = create_test_instrument_with_info(
3185 instrument_id,
3186 Some(create_contract_info(&contract, Some(100))),
3187 );
3188 cache.add_instrument(instrument.clone()).unwrap();
3189 let seeded = provider.seed_from_cache(&cache);
3190 assert_eq!(seeded, 1);
3191 assert_eq!(provider.find(&instrument_id), Some(instrument));
3192 assert_eq!(provider.get_price_magnifier(&instrument_id), 100);
3193 assert_eq!(
3194 provider
3195 .resolve_contract_for_instrument(instrument_id)
3196 .unwrap(),
3197 contract
3198 );
3199 }
3200
3201 #[rstest]
3202 #[case(None)]
3203 #[case(Some(-1))]
3204 fn cached_instrument_without_valid_magnifier_requires_qualification(
3205 #[case] magnifier: Option<i32>,
3206 ) {
3207 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
3208 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
3209
3210 let contract = Contract {
3211 contract_id: 265598,
3212 ..Default::default()
3213 };
3214
3215 let instrument = create_test_instrument_with_info(
3216 instrument_id,
3217 Some(create_contract_info(&contract, magnifier)),
3218 );
3219 assert_eq!(provider.add_cached_instruments([instrument]), 0);
3220 assert_eq!(provider.find(&instrument_id), None);
3221 }
3222}