1use std::{collections::HashMap, fs, path::Path, str::FromStr, sync::Arc};
19
20use anyhow::Context;
21use chrono::{DateTime, Duration, Utc};
22use dashmap::DashMap;
23use ibapi::{
24 contracts::{ComboLegOpenClose, Contract, Exchange, LegAction, SecurityType, Symbol},
25 prelude::StreamExt,
26 subscriptions::SubscriptionItem,
27};
28use nautilus_model::{
29 identifiers::{InstrumentId, Venue},
30 instruments::{Instrument, InstrumentAny},
31};
32use serde::{Deserialize, Serialize};
33
34use crate::{
35 common::{
36 contracts::parse_contract_from_json,
37 enums::IbAction,
38 parse::{
39 create_spread_instrument_id, determine_venue_from_contract, exchange_to_mic_venue,
40 ib_contract_to_instrument_id_raw, ib_contract_to_instrument_id_simplified,
41 instrument_id_to_ib_contract, is_spread_instrument_id,
42 parse_spread_instrument_id_to_legs, possible_exchanges_for_venue,
43 },
44 },
45 config::{InteractiveBrokersInstrumentProviderConfig, SymbologyMethod},
46 providers::parse::{parse_ib_contract_to_instrument, parse_spread_instrument_any},
47};
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51struct InstrumentCache {
52 cache_timestamp: DateTime<Utc>,
54 contract_id_to_instrument_id: Vec<(i32, String)>,
56 price_magnifiers: Vec<(String, i32)>,
58 #[serde(default)]
60 contracts: Vec<(String, Contract)>,
61 #[serde(default)]
63 contract_details: Vec<(String, ibapi::contracts::ContractDetails)>,
64 instruments: Vec<(String, String)>, }
67
68#[cfg_attr(
73 feature = "python",
74 pyo3::pyclass(
75 module = "nautilus_trader.core.nautilus_pyo3.interactive_brokers",
76 unsendable,
77 from_py_object
78 )
79)]
80#[derive(Debug, Clone)]
81pub struct InteractiveBrokersInstrumentProvider {
82 config: InteractiveBrokersInstrumentProviderConfig,
84 contract_id_to_instrument_id: Arc<DashMap<i32, InstrumentId>>,
86 instruments: Arc<DashMap<InstrumentId, InstrumentAny>>,
88 contract_details: Arc<DashMap<InstrumentId, ibapi::contracts::ContractDetails>>,
90 contracts: Arc<DashMap<InstrumentId, Contract>>,
92 price_magnifiers: Arc<DashMap<InstrumentId, i32>>,
94}
95
96impl InteractiveBrokersInstrumentProvider {
97 pub fn new(config: InteractiveBrokersInstrumentProviderConfig) -> Self {
103 Self {
104 config,
105 contract_id_to_instrument_id: Arc::new(DashMap::new()),
106 instruments: Arc::new(DashMap::new()),
107 contract_details: Arc::new(DashMap::new()),
108 contracts: Arc::new(DashMap::new()),
109 price_magnifiers: Arc::new(DashMap::new()),
110 }
111 }
112
113 #[cfg(test)]
114 pub(crate) fn insert_test_instrument(
115 &self,
116 instrument: InstrumentAny,
117 contract_id: i32,
118 price_magnifier: i32,
119 ) {
120 let instrument_id = instrument.id();
121 self.instruments.insert(instrument_id, instrument);
122 self.contract_id_to_instrument_id
123 .insert(contract_id, instrument_id);
124 self.contracts.insert(
125 instrument_id,
126 Contract {
127 contract_id,
128 ..Default::default()
129 },
130 );
131 self.price_magnifiers.insert(instrument_id, price_magnifier);
132 }
133
134 #[cfg(test)]
135 pub(crate) fn insert_test_contract_id_mapping(
136 &self,
137 contract_id: i32,
138 instrument_id: InstrumentId,
139 ) {
140 self.contract_id_to_instrument_id
141 .insert(contract_id, instrument_id);
142 }
143
144 pub async fn initialize(&self) -> anyhow::Result<()> {
153 if let Some(ref cache_path) = self.config.cache_path {
154 match self.load_cache(cache_path).await {
155 Ok(cache_loaded) => {
156 if cache_loaded {
157 tracing::debug!(
158 "Initialized provider with {} instruments from cache",
159 self.count()
160 );
161 } else {
162 tracing::debug!(
163 "Cache file not found or expired, starting with empty cache"
164 );
165 }
166 }
167 Err(e) => {
168 tracing::warn!("Failed to load cache during initialization: {}", e);
169 }
170 }
171 }
172 Ok(())
173 }
174
175 pub async fn initialize_with_client(
176 &self,
177 client: &ibapi::Client,
178 ) -> anyhow::Result<Vec<InstrumentId>> {
179 self.initialize().await?;
180 self.load_all_async(client, None, None, false).await
181 }
182
183 pub fn add_cached_instruments<I>(&self, instruments: I) -> usize
188 where
189 I: IntoIterator<Item = InstrumentAny>,
190 {
191 let mut added = 0;
192
193 for instrument in instruments {
194 let instrument_id = instrument.id();
195 let Some(contract) = contract_from_instrument_info(&instrument) else {
196 continue;
197 };
198 let price_magnifier = price_magnifier_from_instrument_info(&instrument);
199
200 if self.cache_instrument(
201 instrument_id,
202 instrument,
203 None,
204 Some(contract),
205 price_magnifier,
206 false,
207 ) {
208 added += 1;
209 }
210 }
211 added
212 }
213
214 pub fn determine_venue(
227 &self,
228 contract: &Contract,
229 contract_details: Option<&ibapi::contracts::ContractDetails>,
230 ) -> Venue {
231 if matches!(contract.security_type, SecurityType::Stock) {
232 return Venue::from(self.resolve_stock_exchange_from_contract(contract).as_str());
233 }
234
235 let valid_exchanges = contract_details.map(|details| details.valid_exchanges.join(","));
236 let venue_str = determine_venue_from_contract(
237 contract,
238 &self.config.symbol_to_mic_venue,
239 self.config.convert_exchange_to_mic_venue,
240 valid_exchanges.as_deref(),
241 );
242 Venue::from(venue_str.as_str())
243 }
244
245 fn resolve_stock_exchange_from_contract(&self, contract: &Contract) -> String {
246 let cached_venue = self.resolve_cached_symbol_venue(contract);
247 if let Some(venue) = cached_venue.as_deref()
248 && Self::is_compatible_cached_stock_venue(venue, contract.primary_exchange.as_str())
249 {
250 return venue.to_string();
251 }
252
253 if !contract.primary_exchange.as_str().is_empty()
254 && contract.primary_exchange.as_str() != "SMART"
255 {
256 return if self.config.convert_exchange_to_mic_venue {
257 exchange_to_mic_venue(contract.primary_exchange.as_str())
258 .unwrap_or_else(|| contract.primary_exchange.as_str().to_string())
259 } else {
260 contract.primary_exchange.as_str().to_string()
261 };
262 }
263
264 if contract.exchange.as_str() == "SMART"
265 && let Some(venue) = cached_venue
266 {
267 return venue;
268 }
269
270 let exchange = contract.exchange.as_str();
271 if self.config.convert_exchange_to_mic_venue {
272 exchange_to_mic_venue(exchange).unwrap_or_else(|| exchange.to_string())
273 } else {
274 exchange.to_string()
275 }
276 }
277
278 fn is_compatible_cached_stock_venue(venue: &str, primary_exchange: &str) -> bool {
279 if primary_exchange.is_empty() || primary_exchange == "SMART" {
280 return true;
281 }
282
283 venue == primary_exchange
284 || exchange_to_mic_venue(primary_exchange).is_some_and(|mic| mic == venue)
285 }
286
287 fn resolve_cached_symbol_venue(&self, contract: &Contract) -> Option<String> {
288 self.instruments.iter().find_map(|entry| {
289 let instrument = entry.value();
290 let instrument_id = instrument.id();
291 (instrument_id.symbol.as_str() == contract.symbol.as_str())
292 .then(|| instrument_id.venue.to_string())
293 })
294 }
295
296 pub fn symbology_method(&self) -> crate::config::SymbologyMethod {
298 self.config.symbology_method
299 }
300
301 #[must_use]
311 pub fn find(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
312 self.instruments
313 .get(instrument_id)
314 .map(|entry| entry.value().clone())
315 }
316
317 #[must_use]
318 pub(crate) fn find_all(&self, instrument_ids: &[InstrumentId]) -> Vec<InstrumentAny> {
319 instrument_ids
320 .iter()
321 .filter_map(|instrument_id| self.find(instrument_id))
322 .collect()
323 }
324
325 #[must_use]
335 pub fn find_by_contract_id(&self, contract_id: i32) -> Option<InstrumentAny> {
336 self.contract_id_to_instrument_id
337 .get(&contract_id)
338 .and_then(|entry| self.find(entry.value()))
339 }
340
341 #[must_use]
351 pub fn get_instrument_id_by_contract_id(&self, contract_id: i32) -> Option<InstrumentId> {
352 self.contract_id_to_instrument_id
353 .get(&contract_id)
354 .map(|entry| *entry.value())
355 }
356
357 pub fn resolve_instrument_id_for_contract(
366 &self,
367 contract: &Contract,
368 ) -> anyhow::Result<InstrumentId> {
369 if contract.contract_id != 0
370 && let Some(instrument_id) = self.get_instrument_id_by_contract_id(contract.contract_id)
371 {
372 return Ok(instrument_id);
373 }
374
375 if contract.security_type == SecurityType::Spread {
376 return self.resolve_spread_instrument_id_for_contract(contract);
377 }
378
379 let venue = self.determine_venue(contract, None);
380
381 match self.config.symbology_method {
382 SymbologyMethod::Simplified => {
383 ib_contract_to_instrument_id_simplified(contract, Some(venue))
384 }
385 SymbologyMethod::Raw => ib_contract_to_instrument_id_raw(contract, Some(venue)),
386 }
387 }
388
389 fn resolve_spread_instrument_id_for_contract(
390 &self,
391 contract: &Contract,
392 ) -> anyhow::Result<InstrumentId> {
393 if contract.combo_legs.is_empty() {
394 anyhow::bail!("Cannot resolve BAG contract without combo legs or cached contract ID");
395 }
396
397 let mut leg_tuples = Vec::with_capacity(contract.combo_legs.len());
398
399 for combo_leg in &contract.combo_legs {
400 let leg_instrument_id = self
401 .get_instrument_id_by_contract_id(combo_leg.contract_id)
402 .with_context(|| {
403 format!(
404 "Cannot resolve BAG leg con_id {} to cached instrument ID",
405 combo_leg.contract_id
406 )
407 })?;
408 let ratio = IbAction::from_str(combo_leg.action.as_str())
409 .context("Invalid BAG combo leg action")?
410 .signed_multiplier()
411 * combo_leg.ratio;
412
413 leg_tuples.push((leg_instrument_id, ratio));
414 }
415
416 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
417 .context("Failed to create spread instrument ID from BAG combo legs")?;
418
419 if self.find(&spread_instrument_id).is_none() {
420 anyhow::bail!("Resolved BAG spread {spread_instrument_id} is not cached");
421 }
422
423 Ok(spread_instrument_id)
424 }
425
426 #[must_use]
436 pub fn is_filtered_sec_type(&self, sec_type: &str) -> bool {
437 self.config
438 .filter_sec_types
439 .iter()
440 .any(|filtered| filtered.eq_ignore_ascii_case(sec_type))
441 }
442
443 #[must_use]
449 pub fn get_all(&self) -> Vec<InstrumentAny> {
450 self.instruments
451 .iter()
452 .map(|entry| entry.value().clone())
453 .collect()
454 }
455
456 #[must_use]
462 pub fn count(&self) -> usize {
463 self.instruments.len()
464 }
465
466 #[must_use]
483 pub fn get_price_magnifier(&self, instrument_id: &InstrumentId) -> i32 {
484 if let Some(magnifier) = self.price_magnifiers.get(instrument_id) {
486 return normalize_price_magnifier(*magnifier.value());
487 }
488
489 if let Some(details) = self.contract_details.get(instrument_id) {
491 let magnifier = normalize_price_magnifier(details.value().price_magnifier);
492 self.price_magnifiers.insert(*instrument_id, magnifier);
494 return magnifier;
495 }
496
497 if self.instruments.contains_key(instrument_id) {
499 tracing::debug!(
500 "Price magnifier not found for instrument {} (has instrument but no contract details), using default 1",
501 instrument_id
502 );
503 } else {
504 tracing::trace!(
505 "Price magnifier not found for instrument {} (instrument not loaded), using default 1",
506 instrument_id
507 );
508 }
509
510 1
512 }
513
514 pub async fn get_instrument(
532 &self,
533 client: &ibapi::Client,
534 contract: &Contract,
535 ) -> anyhow::Result<Option<InstrumentAny>> {
536 log::debug!(
537 "IB get_instrument request sec_type={:?} con_id={} symbol={} local_symbol={} exchange={} expiry={}",
538 contract.security_type,
539 contract.contract_id,
540 contract.symbol.as_str(),
541 contract.local_symbol.as_str(),
542 contract.exchange.as_str(),
543 contract.last_trade_date_or_contract_month.as_str()
544 );
545 let sec_type_str = security_type_code(&contract.security_type);
547 if self.is_filtered_sec_type(&sec_type_str) {
548 tracing::warn!(
549 "Skipping filtered security type {} for contract",
550 sec_type_str
551 );
552 return Ok(None);
553 }
554
555 let contract_id = contract.contract_id;
556
557 if let Some(cached_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id) {
559 log::debug!(
560 "IB get_instrument cache hit for contract_id={} -> {}",
561 contract_id,
562 cached_instrument_id.value()
563 );
564
565 if let Some(instrument) = self.find(cached_instrument_id.value()) {
566 return Ok(Some(instrument));
567 }
568 }
569
570 if contract.security_type == SecurityType::Spread && !contract.combo_legs.is_empty() {
572 self.fetch_bag_contract(client, contract).await?;
574
575 if let Some(spread_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id)
577 {
578 return Ok(self.find(spread_instrument_id.value()));
579 }
580
581 if let Ok(spread_instrument_id) =
582 self.resolve_spread_instrument_id_for_contract(contract)
583 {
584 return Ok(self.find(&spread_instrument_id));
585 }
586 }
587
588 let details_vec = client
590 .contract_details(contract)
591 .await
592 .context("Failed to fetch contract details from IB")?;
593
594 log::debug!(
595 "IB get_instrument received {} contract details for sec_type={:?} symbol={} local_symbol={}",
596 details_vec.len(),
597 contract.security_type,
598 contract.symbol.as_str(),
599 contract.local_symbol.as_str()
600 );
601
602 if details_vec.is_empty() {
603 tracing::warn!("No contract details returned for contract {}", contract_id);
604 return Ok(None);
605 }
606
607 let loaded_ids = self.process_contract_details(details_vec, None, false);
608
609 if contract_id != 0
610 && let Some(instrument) = self.find_by_contract_id(contract_id)
611 {
612 return Ok(Some(instrument));
613 }
614
615 Ok(loaded_ids
616 .first()
617 .and_then(|instrument_id| self.find(instrument_id)))
618 }
619
620 pub(crate) async fn load_contract_spec(
621 &self,
622 client: &ibapi::Client,
623 contract: &Contract,
624 spec: Option<&serde_json::Value>,
625 ) -> anyhow::Result<Vec<InstrumentId>> {
626 let mut loaded_ids = Vec::new();
627 let build_futures_chain = json_bool(spec, "build_futures_chain")
628 || self.config.build_futures_chain.unwrap_or(false);
629 let build_options_chain = json_bool(spec, "build_options_chain")
630 || self.config.build_options_chain.unwrap_or(false);
631 let min_expiry_days = json_u32(spec, "min_expiry_days").or(self.config.min_expiry_days);
632 let max_expiry_days = json_u32(spec, "max_expiry_days").or(self.config.max_expiry_days);
633 let options_chain_exchange = json_string(spec, "options_chain_exchange")
634 .or_else(|| json_string(spec, "optionsChainExchange"));
635 let chain_contract = if contract.security_type == SecurityType::ContinuousFuture
636 && (build_futures_chain || build_options_chain)
637 {
638 match client.contract_details(contract).await {
639 Ok(details_vec) => details_vec
640 .into_iter()
641 .next()
642 .map(|details| {
643 tracing::debug!(
644 "Qualified continuous future contract {}.{} as local_symbol={} trading_class={} con_id={}",
645 contract.symbol.as_str(),
646 contract.exchange.as_str(),
647 details.contract.local_symbol.as_str(),
648 details.contract.trading_class.as_str(),
649 details.contract.contract_id,
650 );
651 details.contract
652 })
653 .unwrap_or_else(|| contract.clone()),
654 Err(e) => {
655 tracing::warn!(
656 "Failed to qualify continuous future contract {:?}: {}",
657 contract,
658 e
659 );
660 contract.clone()
661 }
662 }
663 } else {
664 contract.clone()
665 };
666 let chain_trading_class = (!chain_contract.trading_class.is_empty())
667 .then_some(chain_contract.trading_class.as_str());
668
669 if build_futures_chain {
670 let loaded = self
671 .fetch_futures_chain(
672 client,
673 chain_contract.symbol.as_str(),
674 chain_contract.exchange.as_str(),
675 chain_contract.currency.as_str(),
676 chain_trading_class,
677 contract.security_type == SecurityType::ContinuousFuture,
678 min_expiry_days,
679 max_expiry_days,
680 )
681 .await?;
682 tracing::debug!(
683 "Loaded {} futures instruments for chain request {}.{}",
684 loaded,
685 chain_contract.symbol.as_str(),
686 chain_contract.exchange.as_str(),
687 );
688 loaded_ids.extend(self.cached_contract_ids_for(
689 chain_contract.symbol.as_str(),
690 chain_contract.exchange.as_str(),
691 &[SecurityType::Future],
692 ));
693 }
694
695 if build_options_chain {
696 let expiry_min = expiry_bound_from_days(min_expiry_days);
697 let expiry_max = expiry_bound_from_days(max_expiry_days);
698 let mut underlyings = Vec::new();
699
700 if contract.security_type == SecurityType::ContinuousFuture {
701 if !build_futures_chain {
702 self.fetch_futures_chain(
703 client,
704 chain_contract.symbol.as_str(),
705 chain_contract.exchange.as_str(),
706 chain_contract.currency.as_str(),
707 chain_trading_class,
708 true,
709 min_expiry_days,
710 max_expiry_days,
711 )
712 .await?;
713 }
714
715 underlyings.extend(
716 self.cached_contracts_for(
717 contract.symbol.as_str(),
718 chain_contract.exchange.as_str(),
719 &[SecurityType::Future],
720 )
721 .into_iter()
722 .map(|(_, contract)| contract),
723 );
724 } else if let Some(instrument) = self.get_instrument(client, contract).await? {
725 let instrument_id = instrument.id();
726 loaded_ids.push(instrument_id);
727 if let Some(underlying) = self.instrument_id_to_ib_contract(&instrument_id) {
728 underlyings.push(underlying);
729 }
730 }
731
732 for underlying in underlyings {
733 let loaded = self
734 .fetch_option_chain_by_range(
735 client,
736 &underlying,
737 expiry_min.as_deref(),
738 expiry_max.as_deref(),
739 options_chain_exchange.as_deref(),
740 )
741 .await?;
742 tracing::debug!(
743 "Loaded {} option instruments for chain request {}.{}",
744 loaded,
745 underlying.symbol.as_str(),
746 underlying.exchange.as_str(),
747 );
748 }
749
750 loaded_ids.extend(
751 self.cached_contract_ids_for(
752 contract.symbol.as_str(),
753 options_chain_exchange
754 .as_deref()
755 .unwrap_or_else(|| contract.exchange.as_str()),
756 &[SecurityType::Option, SecurityType::FuturesOption],
757 ),
758 );
759 }
760
761 if !build_futures_chain
762 && !build_options_chain
763 && let Some(instrument) = self.get_instrument(client, contract).await?
764 {
765 loaded_ids.push(instrument.id());
766 }
767
768 loaded_ids.sort_unstable();
769 loaded_ids.dedup();
770 Ok(loaded_ids)
771 }
772
773 fn cached_contract_ids_for(
774 &self,
775 symbol: &str,
776 exchange: &str,
777 security_types: &[SecurityType],
778 ) -> Vec<InstrumentId> {
779 self.cached_contracts_for(symbol, exchange, security_types)
780 .into_iter()
781 .map(|(instrument_id, _)| instrument_id)
782 .collect()
783 }
784
785 fn cached_contracts_for(
786 &self,
787 symbol: &str,
788 exchange: &str,
789 security_types: &[SecurityType],
790 ) -> Vec<(InstrumentId, Contract)> {
791 self.contracts
792 .iter()
793 .filter_map(|entry| {
794 let instrument_id = *entry.key();
795 let contract = entry.value();
796 let exchange_matches =
797 exchange.is_empty() || contract.exchange.as_str() == exchange;
798 if contract.symbol.as_str() == symbol
799 && exchange_matches
800 && security_types.contains(&contract.security_type)
801 {
802 Some((instrument_id, contract.clone()))
803 } else {
804 None
805 }
806 })
807 .collect()
808 }
809
810 #[must_use]
822 pub fn instrument_id_to_ib_contract_details(
823 &self,
824 instrument_id: &InstrumentId,
825 ) -> Option<ibapi::contracts::ContractDetails> {
826 self.contract_details
827 .get(instrument_id)
828 .map(|entry| entry.value().clone())
829 }
830
831 #[must_use]
832 pub fn instrument_id_to_ib_contract(&self, instrument_id: &InstrumentId) -> Option<Contract> {
833 self.contracts
834 .get(instrument_id)
835 .map(|entry| entry.value().clone())
836 }
837
838 pub fn resolve_contract_for_instrument(
839 &self,
840 instrument_id: InstrumentId,
841 ) -> anyhow::Result<Contract> {
842 let cached_contract = self.instrument_id_to_ib_contract(&instrument_id);
843 if let Some(contract) = cached_contract.as_ref()
844 && (contract.contract_id != 0 || is_spread_instrument_id(&instrument_id))
845 {
846 return Ok(contract.clone());
847 }
848
849 if let Some(details) = self.instrument_id_to_ib_contract_details(&instrument_id) {
850 return Ok(details.contract);
851 }
852
853 if let Some(contract) = cached_contract {
854 return Ok(contract);
855 }
856
857 instrument_id_to_ib_contract(instrument_id, None)
858 }
859
860 pub async fn resolve_contract_for_instrument_async(
861 &self,
862 client: &ibapi::Client,
863 instrument_id: InstrumentId,
864 ) -> anyhow::Result<Contract> {
865 if let Ok(contract) = self.resolve_contract_for_instrument(instrument_id)
866 && (contract.contract_id != 0 || self.contract_details.contains_key(&instrument_id))
867 {
868 return Ok(contract);
869 }
870
871 if is_spread_instrument_id(&instrument_id) {
872 self.fetch_spread_instrument(client, instrument_id, false, None)
873 .await?;
874 } else {
875 self.fetch_contract_details(client, instrument_id, false, None)
876 .await?;
877 }
878
879 self.resolve_contract_for_instrument(instrument_id)
880 }
881
882 pub async fn load_async(
896 &self,
897 client: &ibapi::Client,
898 instrument_id: InstrumentId,
899 filters: Option<HashMap<String, String>>,
900 ) -> anyhow::Result<()> {
901 let filters: Option<HashMap<String, String>> = filters;
902 let force_instrument_update = filters
903 .as_ref()
904 .and_then(|f| f.get("force_instrument_update"))
905 .map(|v| v == "true")
906 .unwrap_or(false);
907
908 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
909 .await
910 }
911
912 pub async fn load_with_return_async(
930 &self,
931 client: &ibapi::Client,
932 instrument_id: InstrumentId,
933 filters: Option<HashMap<String, String>>,
934 ) -> anyhow::Result<Option<InstrumentId>> {
935 let filters: Option<HashMap<String, String>> = filters;
936 let force_instrument_update = filters
937 .as_ref()
938 .and_then(|f| f.get("force_instrument_update"))
939 .map(|v| v == "true")
940 .unwrap_or(false);
941
942 if is_spread_instrument_id(&instrument_id) {
943 self.fetch_spread_instrument(client, instrument_id, force_instrument_update, filters)
944 .await?;
945 } else {
946 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
947 .await?;
948 }
949
950 if self.instruments.contains_key(&instrument_id) {
951 Ok(Some(instrument_id))
952 } else {
953 Ok(None)
954 }
955 }
956
957 pub async fn load_contract_with_return_async(
958 &self,
959 client: &ibapi::Client,
960 contract: &Contract,
961 spec: Option<&serde_json::Value>,
962 ) -> anyhow::Result<Vec<InstrumentId>> {
963 self.load_contract_spec(client, contract, spec).await
964 }
965
966 pub async fn load_ids_async(
980 &self,
981 client: &ibapi::Client,
982 instrument_ids: Vec<InstrumentId>,
983 filters: Option<HashMap<String, String>>,
984 ) -> anyhow::Result<()> {
985 let filters: Option<HashMap<String, String>> = filters;
986 let force_instrument_update = filters
987 .as_ref()
988 .and_then(|f| f.get("force_instrument_update"))
989 .map(|v| v == "true")
990 .unwrap_or(false);
991
992 for instrument_id in instrument_ids {
993 let load_result = if is_spread_instrument_id(&instrument_id) {
994 self.fetch_spread_instrument(
995 client,
996 instrument_id,
997 force_instrument_update,
998 filters.clone(),
999 )
1000 .await
1001 .map(|_| ())
1002 } else {
1003 self.fetch_contract_details(
1004 client,
1005 instrument_id,
1006 force_instrument_update,
1007 filters.clone(),
1008 )
1009 .await
1010 };
1011
1012 if let Err(e) = load_result {
1013 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1014 }
1015 }
1016 Ok(())
1017 }
1018
1019 pub async fn load_ids_with_return_async(
1037 &self,
1038 client: &ibapi::Client,
1039 instrument_ids: Vec<InstrumentId>,
1040 filters: Option<HashMap<String, String>>,
1041 ) -> anyhow::Result<Vec<InstrumentId>> {
1042 let mut loaded_ids = Vec::new();
1043
1044 for instrument_id in instrument_ids {
1045 match self
1046 .load_with_return_async(client, instrument_id, filters.clone())
1047 .await
1048 {
1049 Ok(Some(loaded_id)) => loaded_ids.push(loaded_id),
1050 Ok(None) => {}
1051 Err(e) => {
1052 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1053 }
1054 }
1055 }
1056
1057 Ok(loaded_ids)
1058 }
1059
1060 fn create_bag_contract_from_legs(
1061 &self,
1062 leg_contract_details: &[(ibapi::contracts::ContractDetails, i32)],
1063 instrument_id: Option<InstrumentId>,
1064 bag_contract: Option<&Contract>,
1065 ) -> anyhow::Result<Contract> {
1066 if let Some(bag_contract) = bag_contract {
1067 return Ok(bag_contract.clone());
1068 }
1069
1070 let (first_details, _) = leg_contract_details
1071 .first()
1072 .ok_or_else(|| anyhow::anyhow!("Cannot create BAG contract without leg details"))?;
1073
1074 let combo_legs = leg_contract_details
1075 .iter()
1076 .map(|(details, ratio)| ibapi::contracts::ComboLeg {
1077 contract_id: details.contract.contract_id,
1078 ratio: ratio.abs(),
1079 action: if *ratio > 0 {
1080 LegAction::Buy
1081 } else {
1082 LegAction::Sell
1083 },
1084 exchange: details.contract.exchange.to_string(),
1085 open_close: ComboLegOpenClose::Same,
1086 short_sale_slot: 0,
1087 designated_location: String::new(),
1088 exempt_code: -1,
1089 })
1090 .collect();
1091
1092 Ok(Contract {
1093 contract_id: 0,
1094 symbol: first_details.contract.symbol.clone(),
1095 security_type: SecurityType::Spread,
1096 exchange: Exchange::from("SMART"),
1097 currency: first_details.contract.currency.clone(),
1098 local_symbol: instrument_id.map_or_else(String::new, |id| id.symbol.to_string()),
1099 combo_legs_description: instrument_id
1100 .map(|id| format!("Spread: {}", id.symbol))
1101 .unwrap_or_else(|| "Spread".to_string()),
1102 combo_legs,
1103 ..Default::default()
1104 })
1105 }
1106
1107 pub async fn fetch_spread_instrument(
1127 &self,
1128 client: &ibapi::Client,
1129 spread_instrument_id: InstrumentId,
1130 force_instrument_update: bool,
1131 filters: Option<HashMap<String, String>>,
1132 ) -> anyhow::Result<bool> {
1133 if !force_instrument_update && self.instruments.contains_key(&spread_instrument_id) {
1135 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
1136 return Ok(true);
1137 }
1138
1139 let leg_tuples = parse_spread_instrument_id_to_legs(&spread_instrument_id)
1141 .context("Failed to parse spread instrument ID to leg tuples")?;
1142
1143 if leg_tuples.is_empty() {
1144 tracing::error!("Spread instrument {} has no legs", spread_instrument_id);
1145 return Ok(false);
1146 }
1147
1148 tracing::debug!(
1149 "Loading spread instrument {} with {} legs",
1150 spread_instrument_id,
1151 leg_tuples.len()
1152 );
1153
1154 let mut leg_contract_details = Vec::new();
1156
1157 for (leg_instrument_id, ratio) in &leg_tuples {
1158 tracing::debug!(
1159 "Loading leg instrument: {} (ratio: {})",
1160 leg_instrument_id,
1161 ratio
1162 );
1163
1164 self.fetch_contract_details(
1166 client,
1167 *leg_instrument_id,
1168 force_instrument_update,
1169 filters.clone(),
1170 )
1171 .await
1172 .with_context(|| format!("Failed to load leg instrument: {}", leg_instrument_id))?;
1173
1174 let leg_details = self
1176 .contract_details
1177 .get(leg_instrument_id)
1178 .map(|entry| entry.value().clone())
1179 .ok_or_else(|| {
1180 anyhow::anyhow!(
1181 "Leg instrument {} not found in contract details after loading",
1182 leg_instrument_id
1183 )
1184 })?;
1185
1186 leg_contract_details.push((leg_details, *ratio));
1187 }
1188
1189 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1191 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
1192 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
1193
1194 let bag_contract = self.create_bag_contract_from_legs(
1195 &leg_contract_details,
1196 Some(spread_instrument_id),
1197 None,
1198 )?;
1199 let spread_instrument = parse_spread_instrument_any(
1200 spread_instrument_id,
1201 &leg_details_refs,
1202 Some(&bag_contract),
1203 Some(timestamp),
1204 )
1205 .context("Failed to parse spread instrument")?;
1206
1207 self.instruments
1209 .insert(spread_instrument_id, spread_instrument);
1210 self.contracts.insert(spread_instrument_id, bag_contract);
1211
1212 if let Some((first_details, _)) = leg_contract_details.first() {
1213 self.price_magnifiers
1214 .insert(spread_instrument_id, first_details.price_magnifier);
1215 }
1216
1217 tracing::debug!(
1218 "Successfully loaded spread instrument {}",
1219 spread_instrument_id
1220 );
1221 Ok(true)
1222 }
1223
1224 pub async fn load_all_async(
1241 &self,
1242 client: &ibapi::Client,
1243 instrument_ids: Option<Vec<InstrumentId>>,
1244 contracts: Option<Vec<Contract>>,
1245 force_instrument_update: bool,
1246 ) -> anyhow::Result<Vec<InstrumentId>> {
1247 let mut loaded_ids = Vec::new();
1248
1249 let ids_to_load =
1251 instrument_ids.unwrap_or_else(|| self.config.load_ids.iter().cloned().collect());
1252
1253 if !ids_to_load.is_empty() {
1254 let mut filters = std::collections::HashMap::new();
1255
1256 if force_instrument_update {
1257 filters.insert("force_instrument_update".to_string(), "true".to_string());
1258 }
1259 let filters = if filters.is_empty() {
1260 None
1261 } else {
1262 Some(filters)
1263 };
1264
1265 let ids_result = self
1266 .load_ids_with_return_async(client, ids_to_load, filters)
1267 .await
1268 .context("Failed to load instruments from IDs")?;
1269 loaded_ids.extend(ids_result);
1270 }
1271
1272 if let Some(contracts_to_load) = contracts {
1274 for contract in contracts_to_load {
1275 match self.load_contract_spec(client, &contract, None).await {
1276 Ok(mut instrument_ids) => {
1277 loaded_ids.append(&mut instrument_ids);
1278 }
1279 Err(e) => {
1280 tracing::warn!(
1281 "Error loading instrument from contract {:?}: {}",
1282 contract,
1283 e
1284 );
1285 }
1286 }
1287 }
1288 } else {
1289 for contract_json in &self.config.load_contracts {
1290 match crate::common::contracts::parse_contract_from_json(contract_json)
1291 .context("Failed to parse contract from config JSON")
1292 {
1293 Ok(contract) => match self
1294 .load_contract_spec(client, &contract, Some(contract_json))
1295 .await
1296 {
1297 Ok(mut instrument_ids) => {
1298 loaded_ids.append(&mut instrument_ids);
1299 }
1300 Err(e) => {
1301 tracing::warn!(
1302 "Error loading instrument from contract {:?}: {}",
1303 contract,
1304 e
1305 );
1306 }
1307 },
1308 Err(e) => {
1309 tracing::warn!(
1310 "Error parsing load contract spec {:?}: {}",
1311 contract_json,
1312 e
1313 );
1314 }
1315 }
1316 }
1317 }
1318
1319 if loaded_ids.is_empty() {
1320 tracing::debug!("load_all_async called but no instruments were loaded");
1321 } else {
1322 tracing::debug!("load_all_async loaded {} instruments", loaded_ids.len());
1323 }
1324
1325 Ok(loaded_ids)
1326 }
1327}
1328
1329fn normalize_price_magnifier(price_magnifier: i32) -> i32 {
1330 if price_magnifier > 0 {
1331 price_magnifier
1332 } else {
1333 1
1334 }
1335}
1336
1337fn security_type_code(security_type: &SecurityType) -> String {
1338 security_type.to_string()
1339}
1340
1341fn json_bool(spec: Option<&serde_json::Value>, key: &str) -> bool {
1342 spec.and_then(|value| value.get(key))
1343 .and_then(serde_json::Value::as_bool)
1344 .unwrap_or(false)
1345}
1346
1347fn json_u32(spec: Option<&serde_json::Value>, key: &str) -> Option<u32> {
1348 spec.and_then(|value| value.get(key))
1349 .and_then(serde_json::Value::as_u64)
1350 .and_then(|value| u32::try_from(value).ok())
1351}
1352
1353fn json_string(spec: Option<&serde_json::Value>, key: &str) -> Option<String> {
1354 spec.and_then(|value| value.get(key))
1355 .and_then(serde_json::Value::as_str)
1356 .filter(|value| !value.is_empty())
1357 .map(ToString::to_string)
1358}
1359
1360fn contract_from_instrument_info(instrument: &InstrumentAny) -> Option<Contract> {
1361 let value = serde_json::to_value(instrument).ok()?;
1362 let contract_json = find_contract_json(&value)?;
1363 parse_contract_from_json(contract_json).ok()
1364}
1365
1366fn price_magnifier_from_instrument_info(instrument: &InstrumentAny) -> Option<i32> {
1367 let value = serde_json::to_value(instrument).ok()?;
1368 let price_magnifier = find_price_magnifier_json(&value)?;
1369 parse_i32_json(price_magnifier)
1370}
1371
1372fn find_contract_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1373 if let Some(contract_json) = value.get("info").and_then(|info| info.get("contract")) {
1374 return Some(contract_json);
1375 }
1376
1377 value.as_object()?.values().find_map(find_contract_json)
1378}
1379
1380fn find_price_magnifier_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1381 if let Some(info) = value.get("info")
1382 && let Some(price_magnifier) = info
1383 .get("priceMagnifier")
1384 .or_else(|| info.get("price_magnifier"))
1385 {
1386 return Some(price_magnifier);
1387 }
1388
1389 value
1390 .as_object()?
1391 .values()
1392 .find_map(find_price_magnifier_json)
1393}
1394
1395fn parse_i32_json(value: &serde_json::Value) -> Option<i32> {
1396 if let Some(value) = value.as_i64() {
1397 return i32::try_from(value).ok();
1398 }
1399
1400 if let Some(value) = value.as_u64() {
1401 return i32::try_from(value).ok();
1402 }
1403
1404 value.as_str()?.parse::<i32>().ok()
1405}
1406
1407fn expiry_bound_from_days(days: Option<u32>) -> Option<String> {
1408 days.map(|days| {
1409 (Utc::now().date_naive() + Duration::days(i64::from(days)))
1410 .format("%Y%m%d")
1411 .to_string()
1412 })
1413}
1414
1415impl InteractiveBrokersInstrumentProvider {
1416 pub async fn fetch_contract_details(
1427 &self,
1428 client: &ibapi::Client,
1429 instrument_id: InstrumentId,
1430 force_instrument_update: bool,
1431 filters: Option<HashMap<String, String>>,
1432 ) -> anyhow::Result<()> {
1433 if !force_instrument_update {
1434 if self.instruments.contains_key(&instrument_id)
1435 && (self.contract_details.contains_key(&instrument_id)
1436 || self.contracts.contains_key(&instrument_id))
1437 {
1438 tracing::debug!(
1439 "Instrument {} already cached, skipping fetch",
1440 instrument_id
1441 );
1442 return Ok(());
1443 }
1444 }
1445 let exchange = filters
1447 .as_ref()
1448 .and_then(|f| f.get("exchange"))
1449 .map(|s| s.as_str());
1450
1451 let exchanges_to_try: Vec<String> = if let Some(exchange) = exchange {
1452 vec![exchange.to_string()]
1453 } else {
1454 possible_exchanges_for_venue(instrument_id.venue.as_str())
1455 };
1456
1457 let mut details_vec = Vec::new();
1458 let mut last_error = None;
1459
1460 for candidate_exchange in exchanges_to_try {
1461 let contract = instrument_id_to_ib_contract(instrument_id, Some(candidate_exchange.as_str()))
1462 .with_context(|| format!("Failed to convert instrument_id {} to IB contract. Check that the instrument ID format is correct and the venue/symbol are valid.", instrument_id))?;
1463
1464 match client.contract_details(&contract).await {
1465 Ok(result) if !result.is_empty() => {
1466 details_vec = result;
1467 break;
1468 }
1469 Ok(_) => {}
1470 Err(e) => {
1471 last_error = Some((candidate_exchange.clone(), e.to_string()));
1472 }
1473 }
1474 }
1475
1476 if details_vec.is_empty() {
1477 if let Some((candidate_exchange, error)) = last_error {
1478 tracing::warn!(
1479 "Failed to fetch contract details for {} on {}: {}",
1480 instrument_id,
1481 candidate_exchange,
1482 error
1483 );
1484 } else {
1485 tracing::warn!(
1486 "No contract details returned for {} - instrument may not exist in IB or contract specification is incomplete",
1487 instrument_id
1488 );
1489 }
1490 return Ok(());
1491 }
1492
1493 let loaded_ids = self.process_contract_details(
1494 details_vec,
1495 Some(instrument_id.venue),
1496 force_instrument_update,
1497 );
1498
1499 if loaded_ids.is_empty() {
1500 tracing::warn!("No contract details were processed for {}", instrument_id);
1501 } else {
1502 tracing::debug!(
1503 "Successfully loaded {} instrument(s) for {}",
1504 loaded_ids.len(),
1505 instrument_id
1506 );
1507 }
1508 Ok(())
1509 }
1510
1511 fn process_contract_details(
1512 &self,
1513 details_vec: Vec<ibapi::contracts::ContractDetails>,
1514 venue: Option<Venue>,
1515 force_instrument_update: bool,
1516 ) -> Vec<InstrumentId> {
1517 let mut processed_ids = Vec::new();
1518
1519 for details in details_vec {
1520 match self.process_contract_detail(&details, venue, force_instrument_update) {
1521 Ok(Some(instrument_id)) => processed_ids.push(instrument_id),
1522 Ok(None) => {}
1523 Err(e) => {
1524 tracing::warn!(
1525 "Failed to process IB contract details con_id={} sec_type={}: {}",
1526 details.contract.contract_id,
1527 security_type_code(&details.contract.security_type),
1528 e
1529 );
1530 }
1531 }
1532 }
1533
1534 processed_ids
1535 }
1536
1537 fn process_contract_detail(
1538 &self,
1539 details: &ibapi::contracts::ContractDetails,
1540 venue: Option<Venue>,
1541 force_instrument_update: bool,
1542 ) -> anyhow::Result<Option<InstrumentId>> {
1543 let sec_type = security_type_code(&details.contract.security_type);
1544 if self.is_filtered_sec_type(&sec_type) {
1545 tracing::warn!(
1546 "Skipping filtered security type {} for contract {:?}",
1547 sec_type,
1548 details.contract
1549 );
1550 return Ok(None);
1551 }
1552
1553 let resolved_venue =
1554 venue.unwrap_or_else(|| self.determine_venue(&details.contract, Some(details)));
1555 let instrument_id = self
1556 .instrument_id_from_contract(&details.contract, resolved_venue)
1557 .context("Failed to convert IB contract to instrument ID")?;
1558 let instrument = match parse_ib_contract_to_instrument(details, instrument_id) {
1559 Ok(instrument) => instrument,
1560 Err(e) => {
1561 tracing::warn!(
1562 "Failed to parse IB contract details for {}: {}",
1563 instrument_id,
1564 e
1565 );
1566 return Ok(None);
1567 }
1568 };
1569
1570 if !self.passes_filter_callable(&instrument)? {
1571 return Ok(None);
1572 }
1573
1574 self.cache_instrument(
1575 instrument_id,
1576 instrument,
1577 Some(details.clone()),
1578 None,
1579 None,
1580 force_instrument_update,
1581 );
1582
1583 Ok(Some(instrument_id))
1584 }
1585
1586 fn instrument_id_from_contract(
1587 &self,
1588 contract: &Contract,
1589 venue: Venue,
1590 ) -> anyhow::Result<InstrumentId> {
1591 match self.config.symbology_method {
1592 SymbologyMethod::Simplified => {
1593 ib_contract_to_instrument_id_simplified(contract, Some(venue))
1594 }
1595 SymbologyMethod::Raw => ib_contract_to_instrument_id_raw(contract, Some(venue)),
1596 }
1597 }
1598
1599 fn cache_instrument(
1600 &self,
1601 instrument_id: InstrumentId,
1602 instrument: InstrumentAny,
1603 details: Option<ibapi::contracts::ContractDetails>,
1604 contract: Option<Contract>,
1605 price_magnifier: Option<i32>,
1606 force_instrument_update: bool,
1607 ) -> bool {
1608 let should_update =
1609 force_instrument_update || !self.instruments.contains_key(&instrument_id);
1610
1611 if should_update {
1612 self.instruments.insert(instrument_id, instrument);
1613 }
1614
1615 if let Some(details) = details {
1616 let contract_id = details.contract.contract_id;
1617 self.contracts
1618 .insert(instrument_id, details.contract.clone());
1619 self.contract_details.insert(instrument_id, details.clone());
1620
1621 if contract_id != 0 {
1622 self.contract_id_to_instrument_id
1623 .insert(contract_id, instrument_id);
1624 }
1625 self.price_magnifiers.insert(
1626 instrument_id,
1627 normalize_price_magnifier(details.price_magnifier),
1628 );
1629 } else if let Some(contract) = contract {
1630 if contract.contract_id != 0 {
1631 self.contract_id_to_instrument_id
1632 .insert(contract.contract_id, instrument_id);
1633 }
1634 self.contracts.insert(instrument_id, contract);
1635 }
1636
1637 if let Some(price_magnifier) = price_magnifier {
1638 self.price_magnifiers
1639 .insert(instrument_id, normalize_price_magnifier(price_magnifier));
1640 }
1641
1642 should_update
1643 }
1644
1645 fn passes_filter_callable(&self, instrument: &InstrumentAny) -> anyhow::Result<bool> {
1646 let Some(filter_callable) = self.config.filter_callable.as_deref() else {
1647 return Ok(true);
1648 };
1649
1650 #[cfg(feature = "python")]
1651 {
1652 use nautilus_model::python::instruments::instrument_any_to_pyobject;
1653 use pyo3::{prelude::*, types::PyModule};
1654
1655 Python::attach(|py| {
1656 let (module_name, callable_name) =
1657 filter_callable.rsplit_once('.').ok_or_else(|| {
1658 anyhow::anyhow!(
1659 "Invalid filter_callable path {filter_callable:?}; expected module.callable"
1660 )
1661 })?;
1662 let callable = PyModule::import(py, module_name)
1663 .map_err(|e| anyhow::anyhow!("Failed to import {module_name}: {e}"))?
1664 .getattr(callable_name)
1665 .map_err(|e| anyhow::anyhow!("Failed to resolve {filter_callable}: {e}"))?;
1666 let py_instrument = instrument_any_to_pyobject(py, instrument.clone())
1667 .map_err(|e| anyhow::anyhow!("Failed to convert instrument to Python: {e}"))?;
1668 callable
1669 .call1((py_instrument,))
1670 .and_then(|result| result.extract::<bool>())
1671 .map_err(|e| anyhow::anyhow!("filter_callable {filter_callable} failed: {e}"))
1672 })
1673 }
1674
1675 #[cfg(not(feature = "python"))]
1676 {
1677 let _ = instrument;
1678 anyhow::bail!(
1679 "filter_callable {filter_callable:?} requires the Interactive Brokers adapter to be built with the python feature"
1680 );
1681 }
1682 }
1683
1684 pub async fn batch_load(
1702 &self,
1703 client: &ibapi::Client,
1704 instrument_ids: Vec<InstrumentId>,
1705 filters: Option<&[String]>,
1706 ) -> anyhow::Result<Vec<InstrumentId>> {
1707 let mut loaded_ids = Vec::new();
1708
1709 let filtered_ids: Vec<InstrumentId> = if let Some(filter_list) = filters {
1711 instrument_ids
1717 .into_iter()
1718 .filter(|instrument_id| {
1719 for filter in filter_list {
1721 if instrument_id
1723 .symbol
1724 .as_str()
1725 .to_lowercase()
1726 .contains(&filter.to_lowercase())
1727 {
1728 return true;
1729 }
1730
1731 if instrument_id.venue.as_str() == filter {
1733 return true;
1734 }
1735
1736 if let Some(contract_details) = self.contract_details.get(instrument_id) {
1738 let sec_type_str =
1739 security_type_code(&contract_details.contract.security_type);
1740
1741 if sec_type_str.to_uppercase().contains(&filter.to_uppercase()) {
1742 return true;
1743 }
1744 }
1745 }
1746 false
1747 })
1748 .collect()
1749 } else {
1750 instrument_ids
1751 };
1752
1753 let filtered_count = filtered_ids.len();
1755 for instrument_id in filtered_ids {
1756 match self
1757 .fetch_contract_details(client, instrument_id, false, None)
1758 .await
1759 {
1760 Ok(()) => loaded_ids.push(instrument_id),
1761 Err(e) => {
1762 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1763 }
1764 }
1765 }
1766
1767 tracing::debug!(
1768 "Batch loaded {} instruments ({} after filtering)",
1769 loaded_ids.len(),
1770 filtered_count
1771 );
1772
1773 if !loaded_ids.is_empty()
1775 && let Some(ref cache_path) = self.config.cache_path
1776 && let Err(e) = self.save_cache(cache_path).await
1777 {
1778 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
1779 }
1780
1781 Ok(loaded_ids)
1782 }
1783
1784 pub async fn fetch_option_chain_by_range(
1805 &self,
1806 client: &ibapi::Client,
1807 underlying: &Contract,
1808 expiry_min: Option<&str>,
1809 expiry_max: Option<&str>,
1810 option_chain_exchange: Option<&str>,
1811 ) -> anyhow::Result<usize> {
1812 let exchange = option_chain_exchange.unwrap_or_else(|| underlying.exchange.as_str());
1813 tracing::debug!(
1814 "Building option chain for {}.{} (sec_type={:?}, contract_id={}, expiry_min={:?}, expiry_max={:?}, config_min_days={:?}, config_max_days={:?})",
1815 underlying.symbol.as_str(),
1816 exchange,
1817 underlying.security_type,
1818 underlying.contract_id,
1819 expiry_min,
1820 expiry_max,
1821 self.config.min_expiry_days,
1822 self.config.max_expiry_days,
1823 );
1824
1825 let symbol = underlying.symbol.as_str();
1827 let mut option_chain_stream = client
1828 .option_chain(
1829 symbol,
1830 exchange,
1831 underlying.security_type.clone(),
1832 underlying.contract_id,
1833 )
1834 .await
1835 .context("Failed to request option chain from IB")?;
1836
1837 let mut total_loaded = 0;
1838
1839 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1841
1842 let mut all_expirations = Vec::new();
1844
1845 while let Some(result) = option_chain_stream.next().await {
1846 match result {
1847 Ok(SubscriptionItem::Data(chain)) => {
1848 tracing::debug!(
1849 "Received option chain metadata exchange={} trading_class={} expirations={} strikes={}",
1850 chain.exchange,
1851 chain.trading_class,
1852 chain.expirations.len(),
1853 chain.strikes.len(),
1854 );
1855
1856 for expiration in &chain.expirations {
1857 let date_filter_pass = match (expiry_min, expiry_max) {
1859 (Some(min), Some(max)) => {
1860 expiration.as_str() >= min && expiration.as_str() <= max
1861 }
1862 (Some(min), None) => expiration.as_str() >= min,
1863 (None, Some(max)) => expiration.as_str() <= max,
1864 (None, None) => true,
1865 };
1866
1867 let days_filter_pass = {
1869 let expiry_ns =
1870 crate::providers::parse::expiry_timestring_to_unix_nanos(
1871 expiration.as_str(),
1872 None,
1873 )
1874 .unwrap_or(now);
1875 let days_until_expiry =
1876 (expiry_ns.as_u64().saturating_sub(now.as_u64()))
1877 / (24 * 60 * 60 * 1_000_000_000);
1878
1879 let min_days_ok = self
1880 .config
1881 .min_expiry_days
1882 .is_none_or(|min| days_until_expiry >= min as u64);
1883 let max_days_ok = self
1884 .config
1885 .max_expiry_days
1886 .is_none_or(|max| days_until_expiry <= max as u64);
1887
1888 min_days_ok && max_days_ok
1889 };
1890
1891 if date_filter_pass
1892 && days_filter_pass
1893 && !all_expirations.contains(expiration)
1894 {
1895 all_expirations.push(expiration.clone());
1896 }
1897 }
1898 }
1899 Ok(SubscriptionItem::Notice(notice)) => {
1900 tracing::debug!("Received option chain notice: {notice:?}");
1901 }
1902 Err(e) => {
1903 tracing::warn!("Error receiving option chain metadata: {e}");
1904 }
1905 }
1906 }
1907
1908 all_expirations.sort_unstable();
1909
1910 tracing::debug!(
1911 "Filtered {} option expirations for {}.{}",
1912 all_expirations.len(),
1913 underlying.symbol.as_str(),
1914 exchange,
1915 );
1916
1917 for expiration in all_expirations {
1919 tracing::debug!(
1920 "Requesting option contract details for {}.{} expiry {}",
1921 underlying.symbol.as_str(),
1922 exchange,
1923 expiration,
1924 );
1925
1926 let option_contract = Contract {
1927 contract_id: 0,
1928 symbol: underlying.symbol.clone(),
1929 security_type: if underlying.security_type == SecurityType::Future {
1930 SecurityType::FuturesOption
1931 } else {
1932 SecurityType::Option
1933 },
1934 last_trade_date_or_contract_month: expiration.clone(),
1935 strike: f64::MAX,
1936 right: None,
1937 multiplier: String::new(),
1938 exchange: Exchange::from(exchange),
1939 currency: underlying.currency.clone(),
1940 local_symbol: String::new(),
1941 primary_exchange: Exchange::from(""),
1942 trading_class: String::new(),
1943 include_expired: false,
1944 security_id_type: None,
1945 security_id: String::new(),
1946 combo_legs_description: String::new(),
1947 combo_legs: Vec::new(),
1948 delta_neutral_contract: None,
1949 issuer_id: String::new(),
1950 description: String::new(),
1951 last_trade_date: None,
1952 };
1953
1954 match client.contract_details(&option_contract).await {
1955 Ok(details_vec) => {
1956 tracing::debug!(
1957 "Received {} raw option contract details for {}.{} expiry {}",
1958 details_vec.len(),
1959 underlying.symbol.as_str(),
1960 exchange,
1961 expiration,
1962 );
1963
1964 for details in details_vec {
1965 if details.under_contract_id != underlying.contract_id {
1967 continue;
1968 }
1969
1970 let contract_id = details.contract.contract_id;
1971
1972 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
1973 continue;
1974 }
1975
1976 match self.process_contract_detail(&details, None, false) {
1977 Ok(Some(_instrument_id)) => {
1978 total_loaded += 1;
1979 }
1980 Ok(None) => {}
1981 Err(e) => {
1982 tracing::warn!("Failed to parse option instrument: {}", e);
1983 }
1984 }
1985 }
1986 }
1987 Err(e) => {
1988 tracing::warn!(
1989 "Failed to fetch contract details for expiration {}: {}",
1990 expiration,
1991 e
1992 );
1993 }
1994 }
1995 }
1996
1997 tracing::debug!(
1998 "Successfully loaded {} option instruments from chain for {}.{}",
1999 total_loaded,
2000 underlying.symbol.as_str(),
2001 exchange,
2002 );
2003
2004 if total_loaded > 0
2006 && let Some(ref cache_path) = self.config.cache_path
2007 && let Err(e) = self.save_cache(cache_path).await
2008 {
2009 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2010 }
2011
2012 Ok(total_loaded)
2013 }
2014
2015 pub async fn fetch_futures_chain(
2035 &self,
2036 client: &ibapi::Client,
2037 symbol: &str,
2038 exchange: &str,
2039 currency: &str,
2040 trading_class: Option<&str>,
2041 include_expired: bool,
2042 min_expiry_days: Option<u32>,
2043 max_expiry_days: Option<u32>,
2044 ) -> anyhow::Result<usize> {
2045 tracing::debug!(
2046 "Building futures chain for {}.{} (currency={}, trading_class={:?}, include_expired={}, min_days={:?}, max_days={:?}, config_min_days={:?}, config_max_days={:?})",
2047 symbol,
2048 exchange,
2049 currency,
2050 trading_class,
2051 include_expired,
2052 min_expiry_days,
2053 max_expiry_days,
2054 self.config.min_expiry_days,
2055 self.config.max_expiry_days,
2056 );
2057
2058 let futures_contract = Contract {
2060 contract_id: 0, symbol: Symbol::from(symbol.to_string()),
2062 security_type: SecurityType::Future,
2063 last_trade_date_or_contract_month: String::new(),
2064 strike: f64::MAX,
2065 right: None,
2066 multiplier: String::new(),
2067 exchange: Exchange::from(exchange.to_string()),
2068 currency: ibapi::contracts::Currency::from(currency.to_string()),
2069 local_symbol: String::new(),
2070 primary_exchange: Exchange::from(""),
2071 trading_class: trading_class.unwrap_or_default().to_string(),
2072 include_expired,
2073 security_id_type: None,
2074 security_id: String::new(),
2075 combo_legs_description: String::new(),
2076 combo_legs: Vec::new(),
2077 delta_neutral_contract: None,
2078 issuer_id: String::new(),
2079 description: String::new(),
2080 last_trade_date: None,
2081 };
2082
2083 let details_vec = client
2085 .contract_details(&futures_contract)
2086 .await
2087 .context("Failed to fetch futures chain from IB")?;
2088
2089 tracing::debug!(
2090 "Received {} raw futures contract details for {}.{}",
2091 details_vec.len(),
2092 symbol,
2093 exchange,
2094 );
2095
2096 let mut total_loaded = 0;
2097 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2098
2099 for details in details_vec {
2100 let contract_id = details.contract.contract_id;
2101
2102 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2104 continue;
2105 }
2106
2107 let sec_type_str = security_type_code(&details.contract.security_type);
2109 if self.is_filtered_sec_type(&sec_type_str) {
2110 continue;
2111 }
2112
2113 if !details
2115 .contract
2116 .last_trade_date_or_contract_month
2117 .is_empty()
2118 && let Ok(expiry_ns) = crate::providers::parse::expiry_timestring_to_unix_nanos(
2119 &details.contract.last_trade_date_or_contract_month,
2120 Some(&details),
2121 )
2122 {
2123 let days_until_expiry = (expiry_ns.as_u64().saturating_sub(now.as_u64()))
2124 / (24 * 60 * 60 * 1_000_000_000);
2125
2126 let min_days_ok = min_expiry_days
2127 .or(self.config.min_expiry_days)
2128 .is_none_or(|min| days_until_expiry >= min as u64);
2129 let max_days_ok = max_expiry_days
2130 .or(self.config.max_expiry_days)
2131 .is_none_or(|max| days_until_expiry <= max as u64);
2132
2133 if !min_days_ok || !max_days_ok {
2134 continue;
2135 }
2136 }
2137
2138 match self.process_contract_detail(&details, None, false) {
2139 Ok(Some(_instrument_id)) => {
2140 total_loaded += 1;
2141 }
2142 Ok(None) => {}
2143 Err(e) => {
2144 tracing::warn!("Failed to parse futures instrument: {}", e);
2145 }
2146 }
2147 }
2148
2149 tracing::debug!(
2150 "Successfully loaded {} futures instruments from chain",
2151 total_loaded
2152 );
2153
2154 if total_loaded > 0
2156 && let Some(ref cache_path) = self.config.cache_path
2157 && let Err(e) = self.save_cache(cache_path).await
2158 {
2159 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2160 }
2161
2162 Ok(total_loaded)
2163 }
2164
2165 pub async fn fetch_bag_contract(
2189 &self,
2190 client: &ibapi::Client,
2191 bag_contract: &Contract,
2192 ) -> anyhow::Result<usize> {
2193 if bag_contract.security_type != SecurityType::Spread || bag_contract.combo_legs.is_empty()
2195 {
2196 anyhow::bail!(
2197 "Invalid BAG contract: must have security_type=Spread and non-empty combo_legs"
2198 );
2199 }
2200
2201 tracing::debug!(
2202 "Loading BAG contract with {} legs",
2203 bag_contract.combo_legs.len()
2204 );
2205
2206 let mut leg_contract_details = Vec::new();
2208 let mut leg_tuples = Vec::new();
2209
2210 for combo_leg in &bag_contract.combo_legs {
2211 let leg_contract = Contract {
2213 contract_id: combo_leg.contract_id, symbol: bag_contract.symbol.clone(), security_type: SecurityType::Option, last_trade_date_or_contract_month: String::new(),
2217 strike: 0.0,
2218 right: None,
2219 multiplier: String::new(),
2220 exchange: Exchange::from(combo_leg.exchange.as_str()),
2221 currency: bag_contract.currency.clone(), local_symbol: String::new(),
2223 primary_exchange: Exchange::default(),
2224 trading_class: String::new(),
2225 include_expired: false,
2226 security_id_type: None,
2227 security_id: String::new(),
2228 combo_legs_description: String::new(),
2229 combo_legs: Vec::new(),
2230 delta_neutral_contract: None,
2231 issuer_id: String::new(),
2232 description: String::new(),
2233 last_trade_date: None,
2234 };
2235
2236 let leg_details_vec =
2238 client
2239 .contract_details(&leg_contract)
2240 .await
2241 .with_context(|| {
2242 format!(
2243 "Failed to fetch contract details for leg conId {}",
2244 combo_leg.contract_id
2245 )
2246 })?;
2247
2248 if leg_details_vec.is_empty() {
2249 tracing::warn!(
2250 "No contract details returned for leg conId {}",
2251 combo_leg.contract_id
2252 );
2253 continue;
2254 }
2255
2256 let leg_details = &leg_details_vec[0];
2257 let leg_contract_id = leg_details.contract.contract_id;
2258
2259 let leg_instrument_id =
2261 if let Some(cached_id) = self.contract_id_to_instrument_id.get(&leg_contract_id) {
2262 *cached_id.value()
2263 } else {
2264 let leg_venue = self.determine_venue(&leg_details.contract, Some(leg_details));
2266 let leg_instrument_id = match self.config.symbology_method {
2267 crate::config::SymbologyMethod::Simplified => {
2268 crate::common::parse::ib_contract_to_instrument_id_simplified(
2269 &leg_details.contract,
2270 Some(leg_venue),
2271 )
2272 }
2273 crate::config::SymbologyMethod::Raw => {
2274 crate::common::parse::ib_contract_to_instrument_id_raw(
2275 &leg_details.contract,
2276 Some(leg_venue),
2277 )
2278 }
2279 }
2280 .context("Failed to convert leg contract to instrument ID")?;
2281
2282 let leg_instrument =
2284 parse_ib_contract_to_instrument(leg_details, leg_instrument_id)
2285 .context("Failed to parse leg instrument")?;
2286
2287 self.instruments.insert(leg_instrument_id, leg_instrument);
2288 self.contract_details
2289 .insert(leg_instrument_id, leg_details.clone());
2290 self.contracts
2291 .insert(leg_instrument_id, leg_details.contract.clone());
2292 self.contract_id_to_instrument_id
2293 .insert(leg_contract_id, leg_instrument_id);
2294 self.price_magnifiers
2295 .insert(leg_instrument_id, leg_details.price_magnifier);
2296
2297 leg_instrument_id
2298 };
2299
2300 let ratio = IbAction::from_str(combo_leg.action.as_str())
2302 .context("Invalid combo leg action")?
2303 .signed_multiplier()
2304 * combo_leg.ratio;
2305
2306 let leg_details_clone = self
2308 .contract_details
2309 .get(&leg_instrument_id)
2310 .map(|entry| entry.value().clone())
2311 .ok_or_else(|| {
2312 anyhow::anyhow!(
2313 "Contract details not found for leg {} after loading",
2314 leg_instrument_id
2315 )
2316 })?;
2317
2318 leg_contract_details.push((leg_details_clone, ratio));
2319 leg_tuples.push((leg_instrument_id, ratio));
2320 }
2321
2322 if leg_tuples.is_empty() {
2323 anyhow::bail!("No valid legs loaded for BAG contract");
2324 }
2325
2326 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
2328 .context("Failed to create spread instrument ID from leg tuples")?;
2329
2330 let bag_details_vec = client
2332 .contract_details(bag_contract)
2333 .await
2334 .context("Failed to fetch BAG contract details from IB")?;
2335
2336 if bag_details_vec.is_empty() {
2337 tracing::warn!("No contract details returned for BAG contract");
2338
2339 if bag_contract.contract_id != 0 && self.instruments.contains_key(&spread_instrument_id)
2340 {
2341 self.contract_id_to_instrument_id
2342 .insert(bag_contract.contract_id, spread_instrument_id);
2343 }
2344 return Ok(0);
2345 }
2346
2347 let bag_details = &bag_details_vec[0];
2348 let bag_contract_id = bag_details.contract.contract_id;
2349
2350 if bag_contract_id != 0 {
2351 self.contract_id_to_instrument_id
2352 .insert(bag_contract_id, spread_instrument_id);
2353 }
2354
2355 if self.instruments.contains_key(&spread_instrument_id) {
2357 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
2358 self.contract_details
2359 .insert(spread_instrument_id, bag_details.clone());
2360 self.contracts
2361 .insert(spread_instrument_id, bag_details.contract.clone());
2362 self.price_magnifiers
2363 .insert(spread_instrument_id, bag_details.price_magnifier);
2364 return Ok(0);
2365 }
2366
2367 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2369
2370 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
2372 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
2373
2374 let spread_instrument = parse_spread_instrument_any(
2375 spread_instrument_id,
2376 &leg_details_refs,
2377 Some(&bag_details.contract),
2378 Some(timestamp),
2379 )
2380 .context("Failed to parse spread instrument")?;
2381
2382 self.instruments
2384 .insert(spread_instrument_id, spread_instrument);
2385 self.contract_details
2386 .insert(spread_instrument_id, bag_details.clone());
2387 self.contracts
2388 .insert(spread_instrument_id, bag_details.contract.clone());
2389 self.price_magnifiers
2390 .insert(spread_instrument_id, bag_details.price_magnifier);
2391
2392 tracing::debug!(
2393 "Successfully loaded spread instrument {} with {} legs",
2394 spread_instrument_id,
2395 leg_tuples.len()
2396 );
2397
2398 if let Some(ref cache_path) = self.config.cache_path
2400 && let Err(e) = self.save_cache(cache_path).await
2401 {
2402 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2403 }
2404
2405 Ok(1)
2406 }
2407
2408 pub async fn save_cache(&self, cache_path: &str) -> anyhow::Result<()> {
2418 let cache = InstrumentCache {
2419 cache_timestamp: Utc::now(),
2420 contract_id_to_instrument_id: self
2421 .contract_id_to_instrument_id
2422 .iter()
2423 .map(|entry| (*entry.key(), entry.value().to_string()))
2424 .collect(),
2425 price_magnifiers: self
2426 .price_magnifiers
2427 .iter()
2428 .map(|entry| (entry.key().to_string(), *entry.value()))
2429 .collect(),
2430 contracts: self
2431 .contracts
2432 .iter()
2433 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2434 .collect(),
2435 contract_details: self
2436 .contract_details
2437 .iter()
2438 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2439 .collect(),
2440 instruments: self
2441 .instruments
2442 .iter()
2443 .map(|entry| {
2444 let instrument_id = entry.key().to_string();
2445 let json =
2446 serde_json::to_string(entry.value()).unwrap_or_else(|_| String::new());
2447 (instrument_id, json)
2448 })
2449 .collect(),
2450 };
2451
2452 if let Some(parent) = Path::new(cache_path).parent() {
2454 fs::create_dir_all(parent)?;
2455 }
2456
2457 let json = serde_json::to_string_pretty(&cache)?;
2459 fs::write(cache_path, json)?;
2460 tracing::debug!(
2461 "Saved instrument cache to {} ({} instruments)",
2462 cache_path,
2463 cache.instruments.len()
2464 );
2465 Ok(())
2466 }
2467
2468 pub async fn load_cache(&self, cache_path: &str) -> anyhow::Result<bool> {
2482 if !Path::new(cache_path).exists() {
2484 tracing::debug!("Cache file does not exist: {}", cache_path);
2485 return Ok(false);
2486 }
2487
2488 let json = fs::read_to_string(cache_path)?;
2490 let cache: InstrumentCache = serde_json::from_str(&json)?;
2491
2492 if let Some(validity_days) = self.config.cache_validity_days {
2494 let cache_age = Utc::now() - cache.cache_timestamp;
2495 let max_age = chrono::Duration::days(validity_days as i64);
2496 if cache_age > max_age {
2497 tracing::debug!(
2498 "Cache is expired (age: {} days, max: {} days). Ignoring cache",
2499 cache_age.num_days(),
2500 validity_days
2501 );
2502 return Ok(false);
2503 }
2504 }
2505
2506 let mut loaded_count = 0;
2508
2509 for (instrument_id_str, instrument_json) in &cache.instruments {
2510 match InstrumentId::from_str(instrument_id_str) {
2511 Ok(instrument_id) => match serde_json::from_str::<InstrumentAny>(instrument_json) {
2512 Ok(instrument) => {
2513 self.instruments.insert(instrument_id, instrument);
2514
2515 if let Ok(value) =
2516 serde_json::from_str::<serde_json::Value>(instrument_json)
2517 && let Some(contract_json) = find_contract_json(&value)
2518 && let Ok(contract) = parse_contract_from_json(contract_json)
2519 {
2520 if contract.contract_id != 0 {
2521 self.contract_id_to_instrument_id
2522 .insert(contract.contract_id, instrument_id);
2523 }
2524 self.contracts.insert(instrument_id, contract);
2525 }
2526 loaded_count += 1;
2527 }
2528 Err(e) => {
2529 tracing::warn!(
2530 "Failed to deserialize instrument {}: {}",
2531 instrument_id_str,
2532 e
2533 );
2534 }
2535 },
2536 Err(e) => {
2537 tracing::warn!("Failed to parse instrument ID {}: {}", instrument_id_str, e);
2538 }
2539 }
2540 }
2541
2542 for (instrument_id_str, contract) in &cache.contracts {
2544 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2545 if contract.contract_id != 0 {
2546 self.contract_id_to_instrument_id
2547 .insert(contract.contract_id, instrument_id);
2548 }
2549 self.contracts.insert(instrument_id, contract.clone());
2550 }
2551 }
2552
2553 for (instrument_id_str, details) in &cache.contract_details {
2554 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2555 if details.contract.contract_id != 0 {
2556 self.contract_id_to_instrument_id
2557 .insert(details.contract.contract_id, instrument_id);
2558 }
2559 self.contracts
2560 .insert(instrument_id, details.contract.clone());
2561 self.contract_details.insert(instrument_id, details.clone());
2562 }
2563 }
2564
2565 for (contract_id, instrument_id_str) in &cache.contract_id_to_instrument_id {
2567 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2568 self.contract_id_to_instrument_id
2569 .insert(*contract_id, instrument_id);
2570 }
2571 }
2572
2573 for (instrument_id_str, magnifier) in &cache.price_magnifiers {
2575 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2576 self.price_magnifiers.insert(instrument_id, *magnifier);
2577 }
2578 }
2579
2580 tracing::debug!(
2581 "Loaded instrument cache from {} ({} instruments, created at {})",
2582 cache_path,
2583 loaded_count,
2584 cache.cache_timestamp
2585 );
2586 Ok(true)
2587 }
2588}
2589
2590#[cfg(test)]
2591mod tests {
2592 use std::fs;
2593
2594 use nautilus_core::{Params, UnixNanos};
2595 use nautilus_model::{
2596 identifiers::{Symbol, Venue},
2597 instruments::CurrencyPair,
2598 types::{Price, Quantity, currency::Currency},
2599 };
2600 use rstest::rstest;
2601 use tempfile::TempDir;
2602
2603 use super::*;
2604 use crate::common::contract_to_json_value;
2605
2606 fn create_test_provider_with_cache() -> (InteractiveBrokersInstrumentProvider, TempDir) {
2607 let temp_dir = TempDir::new().unwrap();
2608 let cache_path = temp_dir
2609 .path()
2610 .join("test_cache.json")
2611 .to_str()
2612 .unwrap()
2613 .to_string();
2614
2615 let config = InteractiveBrokersInstrumentProviderConfig::builder()
2616 .cache_path(cache_path)
2617 .cache_validity_days(7u32)
2618 .build();
2619
2620 let provider = InteractiveBrokersInstrumentProvider::new(config);
2621 (provider, temp_dir)
2622 }
2623
2624 fn create_test_instrument(instrument_id: InstrumentId) -> InstrumentAny {
2625 create_test_instrument_with_info(instrument_id, None)
2626 }
2627
2628 fn create_test_instrument_with_info(
2629 instrument_id: InstrumentId,
2630 info: Option<Params>,
2631 ) -> InstrumentAny {
2632 CurrencyPair::new(
2633 instrument_id,
2634 Symbol::from("EUR/USD"),
2635 Currency::from("EUR"),
2636 Currency::from("USD"),
2637 4,
2638 0,
2639 Price::from("0.0001"),
2640 Quantity::from(1),
2641 None,
2642 None,
2643 None,
2644 None,
2645 None,
2646 None,
2647 None,
2648 None,
2649 None,
2650 None,
2651 None,
2652 None,
2653 None,
2654 info,
2655 UnixNanos::default(),
2656 UnixNanos::default(),
2657 )
2658 .into()
2659 }
2660
2661 fn create_contract_info(contract: &Contract, price_magnifier: Option<i32>) -> Params {
2662 let mut info = Params::new();
2663 info.insert(String::from("contract"), contract_to_json_value(contract));
2664 if let Some(price_magnifier) = price_magnifier {
2665 info.insert(
2666 String::from("priceMagnifier"),
2667 serde_json::Value::from(price_magnifier),
2668 );
2669 }
2670 info
2671 }
2672
2673 #[tokio::test]
2674 async fn test_save_cache() {
2675 let (provider, _temp_dir) = create_test_provider_with_cache();
2676 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2677
2678 let instrument_id1 = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
2680 let instrument_id2 = InstrumentId::new(Symbol::from("GBP/USD"), Venue::from("IDEALPRO"));
2681
2682 let instrument1 = create_test_instrument(instrument_id1);
2683 let instrument2 = create_test_instrument(instrument_id2);
2684
2685 provider.instruments.insert(instrument_id1, instrument1);
2686 provider.instruments.insert(instrument_id2, instrument2);
2687 provider
2688 .contract_id_to_instrument_id
2689 .insert(100, instrument_id1);
2690 provider
2691 .contract_id_to_instrument_id
2692 .insert(200, instrument_id2);
2693 provider.price_magnifiers.insert(instrument_id1, 1);
2694 provider.price_magnifiers.insert(instrument_id2, 1);
2695
2696 let result = provider.save_cache(&cache_path).await;
2698 assert!(result.is_ok(), "save_cache should succeed");
2699
2700 assert!(Path::new(&cache_path).exists(), "Cache file should exist");
2702
2703 let contents = fs::read_to_string(&cache_path).unwrap();
2705 assert!(
2706 contents.contains("EUR/USD"),
2707 "Cache should contain instrument data"
2708 );
2709 assert!(
2710 contents.contains("cache_timestamp"),
2711 "Cache should contain timestamp"
2712 );
2713 }
2714
2715 #[tokio::test]
2716 async fn test_load_cache_valid() {
2717 let (provider, _temp_dir) = create_test_provider_with_cache();
2718 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2719
2720 let instrument_id = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
2722 let instrument = create_test_instrument(instrument_id);
2723
2724 provider
2725 .instruments
2726 .insert(instrument_id, instrument.clone());
2727 provider
2728 .contract_id_to_instrument_id
2729 .insert(100, instrument_id);
2730 provider.price_magnifiers.insert(instrument_id, 1);
2731
2732 provider.save_cache(&cache_path).await.unwrap();
2733
2734 let new_config = InteractiveBrokersInstrumentProviderConfig::builder()
2736 .cache_path(cache_path.clone())
2737 .cache_validity_days(7u32)
2738 .build();
2739
2740 let new_provider = InteractiveBrokersInstrumentProvider::new(new_config);
2741
2742 let result = new_provider.load_cache(&cache_path).await;
2743 assert!(result.is_ok(), "load_cache should succeed");
2744 assert!(
2745 result.unwrap(),
2746 "load_cache should return true for valid cache"
2747 );
2748
2749 assert!(
2751 new_provider.find(&instrument_id).is_some(),
2752 "Instrument should be loaded from cache"
2753 );
2754 assert_eq!(new_provider.count(), 1, "Provider should have 1 instrument");
2755 }
2756
2757 #[tokio::test]
2758 async fn test_load_cache_restores_contract_details() {
2759 let (provider, _temp_dir) = create_test_provider_with_cache();
2760 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2761 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
2762 let instrument = create_test_instrument(instrument_id);
2763 let contract = Contract {
2764 contract_id: 265598,
2765 symbol: ibapi::contracts::Symbol::from("AAPL"),
2766 security_type: SecurityType::Stock,
2767 exchange: Exchange::from("SMART"),
2768 primary_exchange: Exchange::from("NASDAQ"),
2769 currency: ibapi::contracts::Currency::from("USD"),
2770 ..Default::default()
2771 };
2772 let details = ibapi::contracts::ContractDetails {
2773 contract: contract.clone(),
2774 price_magnifier: 1,
2775 ..Default::default()
2776 };
2777
2778 provider.cache_instrument(
2779 instrument_id,
2780 instrument,
2781 Some(details),
2782 Some(contract),
2783 Some(1),
2784 false,
2785 );
2786 provider.save_cache(&cache_path).await.unwrap();
2787
2788 let new_provider = InteractiveBrokersInstrumentProvider::new(provider.config.clone());
2789
2790 assert!(new_provider.load_cache(&cache_path).await.unwrap());
2791 assert_eq!(
2792 new_provider
2793 .resolve_contract_for_instrument(instrument_id)
2794 .unwrap()
2795 .contract_id,
2796 265598
2797 );
2798 assert_eq!(
2799 new_provider
2800 .instrument_id_to_ib_contract_details(&instrument_id)
2801 .unwrap()
2802 .contract
2803 .contract_id,
2804 265598
2805 );
2806 }
2807
2808 #[rstest]
2809 fn test_filter_sec_types_uses_ib_codes_case_insensitive() {
2810 let config = InteractiveBrokersInstrumentProviderConfig {
2811 filter_sec_types: [String::from("opt")].into_iter().collect(),
2812 ..Default::default()
2813 };
2814 let provider = InteractiveBrokersInstrumentProvider::new(config);
2815
2816 assert!(provider.is_filtered_sec_type(&security_type_code(&SecurityType::Option)));
2817 assert!(!provider.is_filtered_sec_type(&security_type_code(&SecurityType::Stock)));
2818 }
2819
2820 #[rstest]
2821 fn test_add_cached_instruments_only_seeds_ib_contracts() {
2822 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
2823 let ib_instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
2824 let non_ib_instrument_id =
2825 InstrumentId::new(Symbol::from("BTCUSDT"), Venue::from("BINANCE"));
2826 let contract = Contract {
2827 contract_id: 265598,
2828 symbol: ibapi::contracts::Symbol::from("AAPL"),
2829 security_type: SecurityType::Stock,
2830 exchange: Exchange::from("SMART"),
2831 primary_exchange: Exchange::from("NASDAQ"),
2832 currency: ibapi::contracts::Currency::from("USD"),
2833 ..Default::default()
2834 };
2835 let ib_instrument = create_test_instrument_with_info(
2836 ib_instrument_id,
2837 Some(create_contract_info(&contract, Some(100))),
2838 );
2839 let non_ib_instrument = create_test_instrument(non_ib_instrument_id);
2840
2841 let count = provider.add_cached_instruments([ib_instrument, non_ib_instrument]);
2842
2843 assert_eq!(count, 1);
2844 assert_eq!(provider.count(), 1);
2845 assert!(provider.find(&ib_instrument_id).is_some());
2846 assert!(provider.find(&non_ib_instrument_id).is_none());
2847 assert_eq!(
2848 provider
2849 .resolve_contract_for_instrument(ib_instrument_id)
2850 .unwrap()
2851 .contract_id,
2852 265598
2853 );
2854 assert_eq!(provider.get_price_magnifier(&ib_instrument_id), 100);
2855 }
2856
2857 #[tokio::test]
2858 async fn test_load_cache_missing_file() {
2859 let (provider, _temp_dir) = create_test_provider_with_cache();
2860 let cache_path = "/nonexistent/path/cache.json";
2861
2862 let result = provider.load_cache(cache_path).await;
2863 assert!(
2864 result.is_ok(),
2865 "load_cache should not error on missing file"
2866 );
2867 assert!(
2868 !result.unwrap(),
2869 "load_cache should return false for missing file"
2870 );
2871 }
2872
2873 #[tokio::test]
2874 async fn test_load_cache_expired() {
2875 let (provider, _temp_dir) = create_test_provider_with_cache();
2876 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
2877
2878 let old_timestamp = Utc::now() - chrono::Duration::days(10);
2880 let expired_cache = InstrumentCache {
2881 cache_timestamp: old_timestamp,
2882 contract_id_to_instrument_id: vec![],
2883 price_magnifiers: vec![],
2884 contracts: vec![],
2885 contract_details: vec![],
2886 instruments: vec![],
2887 };
2888
2889 let json = serde_json::to_string_pretty(&expired_cache).unwrap();
2890 fs::write(&cache_path, json).unwrap();
2891
2892 let result = provider.load_cache(&cache_path).await;
2894 assert!(
2895 result.is_ok(),
2896 "load_cache should not error on expired cache"
2897 );
2898 assert!(
2899 !result.unwrap(),
2900 "load_cache should return false for expired cache"
2901 );
2902 }
2903}