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_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: Timestamp,
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.adapters.interactive_brokers",
76 unsendable,
77 from_py_object
78 )
79)]
80#[cfg_attr(
81 feature = "python",
82 pyo3_stub_gen::derive::gen_stub_pyclass(
83 module = "nautilus_trader.adapters.interactive_brokers"
84 )
85)]
86#[derive(Debug, Clone)]
87pub struct InteractiveBrokersInstrumentProvider {
88 config: InteractiveBrokersInstrumentProviderConfig,
90 contract_id_to_instrument_id: Arc<DashMap<i32, InstrumentId>>,
92 instruments: Arc<DashMap<InstrumentId, InstrumentAny>>,
94 contract_details: Arc<DashMap<InstrumentId, ibapi::contracts::ContractDetails>>,
96 contracts: Arc<DashMap<InstrumentId, Contract>>,
98 price_magnifiers: Arc<DashMap<InstrumentId, i32>>,
100 startup_initialized: Arc<tokio::sync::Mutex<bool>>,
102}
103
104trait StartupInstrumentLoader {
105 async fn load_instrument_id(
106 &self,
107 instrument_id: InstrumentId,
108 ) -> anyhow::Result<Option<InstrumentId>>;
109
110 async fn load_contract(
111 &self,
112 contract_spec: &serde_json::Value,
113 ) -> anyhow::Result<Vec<InstrumentId>>;
114}
115
116struct IbStartupInstrumentLoader<'a> {
117 provider: &'a InteractiveBrokersInstrumentProvider,
118 client: &'a ibapi::Client,
119}
120
121impl StartupInstrumentLoader for IbStartupInstrumentLoader<'_> {
122 async fn load_instrument_id(
123 &self,
124 instrument_id: InstrumentId,
125 ) -> anyhow::Result<Option<InstrumentId>> {
126 self.provider
127 .load_with_return_async(self.client, instrument_id, None)
128 .await
129 }
130
131 async fn load_contract(
132 &self,
133 contract_spec: &serde_json::Value,
134 ) -> anyhow::Result<Vec<InstrumentId>> {
135 let contract = parse_contract_from_json(contract_spec)
136 .context("Failed to parse configured IB contract")?;
137 self.provider
138 .load_contract_spec(self.client, &contract, Some(contract_spec))
139 .await
140 }
141}
142
143impl InteractiveBrokersInstrumentProvider {
144 pub fn new(config: InteractiveBrokersInstrumentProviderConfig) -> Self {
150 Self {
151 config,
152 contract_id_to_instrument_id: Arc::new(DashMap::new()),
153 instruments: Arc::new(DashMap::new()),
154 contract_details: Arc::new(DashMap::new()),
155 contracts: Arc::new(DashMap::new()),
156 price_magnifiers: Arc::new(DashMap::new()),
157 startup_initialized: Arc::new(tokio::sync::Mutex::new(false)),
158 }
159 }
160
161 #[cfg(test)]
162 pub(crate) fn insert_test_instrument(
163 &self,
164 instrument: InstrumentAny,
165 contract_id: i32,
166 price_magnifier: i32,
167 ) {
168 let instrument_id = instrument.id();
169 self.instruments.insert(instrument_id, instrument);
170 self.contract_id_to_instrument_id
171 .insert(contract_id, instrument_id);
172 self.contracts.insert(
173 instrument_id,
174 Contract {
175 contract_id,
176 ..Default::default()
177 },
178 );
179 self.price_magnifiers.insert(instrument_id, price_magnifier);
180 }
181
182 #[cfg(test)]
183 pub(crate) fn insert_test_contract_id_mapping(
184 &self,
185 contract_id: i32,
186 instrument_id: InstrumentId,
187 ) {
188 self.contract_id_to_instrument_id
189 .insert(contract_id, instrument_id);
190 }
191
192 pub async fn initialize(&self) -> anyhow::Result<()> {
201 if let Some(ref cache_path) = self.config.cache_path {
202 match self.load_cache(cache_path).await {
203 Ok(cache_loaded) => {
204 if cache_loaded {
205 tracing::debug!(
206 "Initialized provider with {} instruments from cache",
207 self.count()
208 );
209 } else {
210 tracing::debug!(
211 "Cache file not found or expired, starting with empty cache"
212 );
213 }
214 }
215 Err(e) => {
216 tracing::warn!("Failed to load cache during initialization: {}", e);
217 }
218 }
219 }
220 Ok(())
221 }
222
223 pub async fn initialize_with_client(
231 &self,
232 client: &ibapi::Client,
233 ) -> anyhow::Result<Vec<InstrumentId>> {
234 let loader = IbStartupInstrumentLoader {
235 provider: self,
236 client,
237 };
238 self.initialize_with_loader(&loader).await
239 }
240
241 async fn initialize_with_loader<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
242 where
243 L: StartupInstrumentLoader + Sync,
244 {
245 let mut initialized = self.startup_initialized.lock().await;
246 if *initialized {
247 return Ok(Vec::new());
248 }
249
250 self.initialize().await?;
251 let loaded_ids = self.load_configured_instruments(loader).await?;
252 *initialized = true;
253 Ok(loaded_ids)
254 }
255
256 async fn load_configured_instruments<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
257 where
258 L: StartupInstrumentLoader + Sync,
259 {
260 let mut loaded_ids = Vec::new();
261 let mut unresolved = Vec::new();
262 let mut configured_ids: Vec<_> = self.config.load_ids.iter().copied().collect();
263 configured_ids.sort_unstable();
264
265 for instrument_id in configured_ids {
266 match loader
267 .load_instrument_id(instrument_id)
268 .await
269 .with_context(|| {
270 format!("Failed to load configured IB instrument ID {instrument_id}")
271 })? {
272 Some(loaded_id) => loaded_ids.push(loaded_id),
273 None => unresolved.push(format!("instrument ID {instrument_id}")),
274 }
275 }
276
277 for (index, contract_spec) in self.config.load_contracts.iter().enumerate() {
278 let mut contract_ids =
279 loader.load_contract(contract_spec).await.with_context(|| {
280 format!(
281 "Failed to load configured IB contract at index {index}: {contract_spec}"
282 )
283 })?;
284
285 if contract_ids.is_empty() {
286 unresolved.push(format!("contract at index {index}: {contract_spec}"));
287 } else {
288 loaded_ids.append(&mut contract_ids);
289 }
290 }
291
292 if !unresolved.is_empty() {
293 anyhow::bail!(
294 "Unable to resolve configured Interactive Brokers instruments: {}",
295 unresolved.join(", ")
296 );
297 }
298
299 loaded_ids.sort_unstable();
300 loaded_ids.dedup();
301 Ok(loaded_ids)
302 }
303
304 pub fn add_cached_instruments<I>(&self, instruments: I) -> usize
309 where
310 I: IntoIterator<Item = InstrumentAny>,
311 {
312 let mut added = 0;
313
314 for instrument in instruments {
315 let instrument_id = instrument.id();
316 let Some(contract) = contract_from_instrument_info(&instrument) else {
317 continue;
318 };
319 let price_magnifier = price_magnifier_from_instrument_info(&instrument);
320
321 if self.cache_instrument(
322 instrument_id,
323 instrument,
324 None,
325 Some(contract),
326 price_magnifier,
327 false,
328 ) {
329 added += 1;
330 }
331 }
332 added
333 }
334
335 pub fn determine_venue(
348 &self,
349 contract: &Contract,
350 contract_details: Option<&ibapi::contracts::ContractDetails>,
351 ) -> Venue {
352 if matches!(contract.security_type, SecurityType::Stock) {
353 return Venue::from(self.resolve_stock_exchange_from_contract(contract).as_str());
354 }
355
356 let valid_exchanges = contract_details.map(|details| details.valid_exchanges.join(","));
357 let venue_str = determine_venue_from_contract(
358 contract,
359 &self.config.symbol_to_mic_venue,
360 self.config.convert_exchange_to_mic_venue,
361 valid_exchanges.as_deref(),
362 );
363 Venue::from(venue_str.as_str())
364 }
365
366 fn resolve_stock_exchange_from_contract(&self, contract: &Contract) -> String {
367 let cached_venue = self.resolve_cached_symbol_venue(contract);
368 if let Some(venue) = cached_venue.as_deref()
369 && Self::is_compatible_cached_stock_venue(venue, contract.primary_exchange.as_str())
370 {
371 return venue.to_string();
372 }
373
374 if !contract.primary_exchange.as_str().is_empty()
375 && contract.primary_exchange.as_str() != "SMART"
376 {
377 return if self.config.convert_exchange_to_mic_venue {
378 exchange_to_mic_venue(contract.primary_exchange.as_str())
379 .unwrap_or_else(|| contract.primary_exchange.as_str().to_string())
380 } else {
381 contract.primary_exchange.as_str().to_string()
382 };
383 }
384
385 if contract.exchange.as_str() == "SMART"
386 && let Some(venue) = cached_venue
387 {
388 return venue;
389 }
390
391 let exchange = contract.exchange.as_str();
392 if self.config.convert_exchange_to_mic_venue {
393 exchange_to_mic_venue(exchange).unwrap_or_else(|| exchange.to_string())
394 } else {
395 exchange.to_string()
396 }
397 }
398
399 fn is_compatible_cached_stock_venue(venue: &str, primary_exchange: &str) -> bool {
400 if primary_exchange.is_empty() || primary_exchange == "SMART" {
401 return true;
402 }
403
404 venue == primary_exchange
405 || exchange_to_mic_venue(primary_exchange).is_some_and(|mic| mic == venue)
406 }
407
408 fn resolve_cached_symbol_venue(&self, contract: &Contract) -> Option<String> {
409 self.instruments.iter().find_map(|entry| {
410 let instrument = entry.value();
411 let instrument_id = instrument.id();
412 (instrument_id.symbol.as_str() == contract.symbol.as_str())
413 .then(|| instrument_id.venue.to_string())
414 })
415 }
416
417 pub fn symbology_method(&self) -> crate::config::SymbologyMethod {
419 self.config.symbology_method
420 }
421
422 #[must_use]
432 pub fn find(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
433 self.instruments
434 .get(instrument_id)
435 .map(|entry| entry.value().clone())
436 }
437
438 #[must_use]
439 pub(crate) fn find_all(&self, instrument_ids: &[InstrumentId]) -> Vec<InstrumentAny> {
440 instrument_ids
441 .iter()
442 .filter_map(|instrument_id| self.find(instrument_id))
443 .collect()
444 }
445
446 #[must_use]
456 pub fn find_by_contract_id(&self, contract_id: i32) -> Option<InstrumentAny> {
457 self.contract_id_to_instrument_id
458 .get(&contract_id)
459 .and_then(|entry| self.find(entry.value()))
460 }
461
462 #[must_use]
472 pub fn get_instrument_id_by_contract_id(&self, contract_id: i32) -> Option<InstrumentId> {
473 self.contract_id_to_instrument_id
474 .get(&contract_id)
475 .map(|entry| *entry.value())
476 }
477
478 pub fn resolve_instrument_id_for_contract(
487 &self,
488 contract: &Contract,
489 ) -> anyhow::Result<InstrumentId> {
490 if contract.contract_id != 0
491 && let Some(instrument_id) = self.get_instrument_id_by_contract_id(contract.contract_id)
492 {
493 return Ok(instrument_id);
494 }
495
496 if contract.security_type == SecurityType::Spread {
497 return self.resolve_spread_instrument_id_for_contract(contract);
498 }
499
500 let venue = self.determine_venue(contract, None);
501
502 match self.config.symbology_method {
503 SymbologyMethod::Simplified => {
504 ib_contract_to_instrument_id_simplified(contract, Some(venue))
505 }
506 SymbologyMethod::Raw => ib_contract_to_instrument_id_raw(contract, Some(venue)),
507 }
508 }
509
510 fn resolve_spread_instrument_id_for_contract(
511 &self,
512 contract: &Contract,
513 ) -> anyhow::Result<InstrumentId> {
514 if contract.combo_legs.is_empty() {
515 anyhow::bail!("Cannot resolve BAG contract without combo legs or cached contract ID");
516 }
517
518 let mut leg_tuples = Vec::with_capacity(contract.combo_legs.len());
519
520 for combo_leg in &contract.combo_legs {
521 let leg_instrument_id = self
522 .get_instrument_id_by_contract_id(combo_leg.contract_id)
523 .with_context(|| {
524 format!(
525 "Cannot resolve BAG leg con_id {} to cached instrument ID",
526 combo_leg.contract_id
527 )
528 })?;
529 let ratio = IbAction::from_str(combo_leg.action.as_str())
530 .context("Invalid BAG combo leg action")?
531 .signed_multiplier()
532 * combo_leg.ratio;
533
534 leg_tuples.push((leg_instrument_id, ratio));
535 }
536
537 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
538 .context("Failed to create spread instrument ID from BAG combo legs")?;
539
540 if self.find(&spread_instrument_id).is_none() {
541 anyhow::bail!("Resolved BAG spread {spread_instrument_id} is not cached");
542 }
543
544 Ok(spread_instrument_id)
545 }
546
547 #[must_use]
557 pub fn is_filtered_sec_type(&self, sec_type: &str) -> bool {
558 self.config
559 .filter_sec_types
560 .iter()
561 .any(|filtered| filtered.eq_ignore_ascii_case(sec_type))
562 }
563
564 #[must_use]
570 pub fn get_all(&self) -> Vec<InstrumentAny> {
571 self.instruments
572 .iter()
573 .map(|entry| entry.value().clone())
574 .collect()
575 }
576
577 #[must_use]
583 pub fn count(&self) -> usize {
584 self.instruments.len()
585 }
586
587 #[must_use]
604 pub fn get_price_magnifier(&self, instrument_id: &InstrumentId) -> i32 {
605 if let Some(magnifier) = self.price_magnifiers.get(instrument_id) {
607 return normalize_price_magnifier(*magnifier.value());
608 }
609
610 if let Some(details) = self.contract_details.get(instrument_id) {
612 let magnifier = normalize_price_magnifier(details.value().price_magnifier);
613 self.price_magnifiers.insert(*instrument_id, magnifier);
615 return magnifier;
616 }
617
618 if self.instruments.contains_key(instrument_id) {
620 tracing::debug!(
621 "Price magnifier not found for instrument {} (has instrument but no contract details), using default 1",
622 instrument_id
623 );
624 } else {
625 tracing::trace!(
626 "Price magnifier not found for instrument {} (instrument not loaded), using default 1",
627 instrument_id
628 );
629 }
630
631 1
633 }
634
635 pub async fn get_instrument(
653 &self,
654 client: &ibapi::Client,
655 contract: &Contract,
656 ) -> anyhow::Result<Option<InstrumentAny>> {
657 log::debug!(
658 "IB get_instrument request sec_type={:?} con_id={} symbol={} local_symbol={} exchange={} expiry={}",
659 contract.security_type,
660 contract.contract_id,
661 contract.symbol.as_str(),
662 contract.local_symbol.as_str(),
663 contract.exchange.as_str(),
664 contract.last_trade_date_or_contract_month.as_str()
665 );
666 let sec_type_str = security_type_code(&contract.security_type);
668 if self.is_filtered_sec_type(&sec_type_str) {
669 tracing::warn!(
670 "Skipping filtered security type {} for contract",
671 sec_type_str
672 );
673 return Ok(None);
674 }
675
676 let contract_id = contract.contract_id;
677
678 if let Some(cached_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id) {
680 log::debug!(
681 "IB get_instrument cache hit for contract_id={} -> {}",
682 contract_id,
683 cached_instrument_id.value()
684 );
685
686 if let Some(instrument) = self.find(cached_instrument_id.value()) {
687 return Ok(Some(instrument));
688 }
689 }
690
691 if contract.security_type == SecurityType::Spread && !contract.combo_legs.is_empty() {
693 self.fetch_bag_contract(client, contract).await?;
695
696 if let Some(spread_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id)
698 {
699 return Ok(self.find(spread_instrument_id.value()));
700 }
701
702 if let Ok(spread_instrument_id) =
703 self.resolve_spread_instrument_id_for_contract(contract)
704 {
705 return Ok(self.find(&spread_instrument_id));
706 }
707 }
708
709 let details_vec = client
711 .contract_details(contract)
712 .await
713 .context("Failed to fetch contract details from IB")?;
714
715 log::debug!(
716 "IB get_instrument received {} contract details for sec_type={:?} symbol={} local_symbol={}",
717 details_vec.len(),
718 contract.security_type,
719 contract.symbol.as_str(),
720 contract.local_symbol.as_str()
721 );
722
723 if details_vec.is_empty() {
724 tracing::warn!("No contract details returned for contract {}", contract_id);
725 return Ok(None);
726 }
727
728 let loaded_ids = self.process_contract_details(details_vec, None, false);
729
730 if contract_id != 0
731 && let Some(instrument) = self.find_by_contract_id(contract_id)
732 {
733 return Ok(Some(instrument));
734 }
735
736 Ok(loaded_ids
737 .first()
738 .and_then(|instrument_id| self.find(instrument_id)))
739 }
740
741 pub(crate) async fn load_contract_spec(
742 &self,
743 client: &ibapi::Client,
744 contract: &Contract,
745 spec: Option<&serde_json::Value>,
746 ) -> anyhow::Result<Vec<InstrumentId>> {
747 let mut loaded_ids = Vec::new();
748 let build_futures_chain = json_bool(spec, "build_futures_chain")
749 || self.config.build_futures_chain.unwrap_or(false);
750 let build_options_chain = json_bool(spec, "build_options_chain")
751 || self.config.build_options_chain.unwrap_or(false);
752 let min_expiry_days = json_u32(spec, "min_expiry_days").or(self.config.min_expiry_days);
753 let max_expiry_days = json_u32(spec, "max_expiry_days").or(self.config.max_expiry_days);
754 let options_chain_exchange = json_string(spec, "options_chain_exchange")
755 .or_else(|| json_string(spec, "optionsChainExchange"));
756 let chain_contract = if contract.security_type == SecurityType::ContinuousFuture
757 && (build_futures_chain || build_options_chain)
758 {
759 match client.contract_details(contract).await {
760 Ok(details_vec) => details_vec
761 .into_iter()
762 .next()
763 .map(|details| {
764 tracing::debug!(
765 "Qualified continuous future contract {}.{} as local_symbol={} trading_class={} con_id={}",
766 contract.symbol.as_str(),
767 contract.exchange.as_str(),
768 details.contract.local_symbol.as_str(),
769 details.contract.trading_class.as_str(),
770 details.contract.contract_id,
771 );
772 details.contract
773 })
774 .unwrap_or_else(|| contract.clone()),
775 Err(e) if e.is_connection_lost() => {
776 return Err(e).context("Failed to qualify continuous future contract");
777 }
778 Err(e) => {
779 tracing::warn!(
780 "Failed to qualify continuous future contract {:?}: {}",
781 contract,
782 e
783 );
784 contract.clone()
785 }
786 }
787 } else {
788 contract.clone()
789 };
790 let chain_trading_class = (!chain_contract.trading_class.is_empty())
791 .then_some(chain_contract.trading_class.as_str());
792
793 if build_futures_chain {
794 let loaded = self
795 .fetch_futures_chain(
796 client,
797 chain_contract.symbol.as_str(),
798 chain_contract.exchange.as_str(),
799 chain_contract.currency.as_str(),
800 chain_trading_class,
801 contract.security_type == SecurityType::ContinuousFuture,
802 min_expiry_days,
803 max_expiry_days,
804 )
805 .await?;
806 tracing::debug!(
807 "Loaded {} futures instruments for chain request {}.{}",
808 loaded,
809 chain_contract.symbol.as_str(),
810 chain_contract.exchange.as_str(),
811 );
812 loaded_ids.extend(self.cached_contract_ids_for(
813 chain_contract.symbol.as_str(),
814 chain_contract.exchange.as_str(),
815 &[SecurityType::Future],
816 ));
817 }
818
819 if build_options_chain {
820 let expiry_min = expiry_bound_from_days(min_expiry_days);
821 let expiry_max = expiry_bound_from_days(max_expiry_days);
822 let mut underlyings = Vec::new();
823
824 if contract.security_type == SecurityType::ContinuousFuture {
825 if !build_futures_chain {
826 self.fetch_futures_chain(
827 client,
828 chain_contract.symbol.as_str(),
829 chain_contract.exchange.as_str(),
830 chain_contract.currency.as_str(),
831 chain_trading_class,
832 true,
833 min_expiry_days,
834 max_expiry_days,
835 )
836 .await?;
837 }
838
839 underlyings.extend(
840 self.cached_contracts_for(
841 contract.symbol.as_str(),
842 chain_contract.exchange.as_str(),
843 &[SecurityType::Future],
844 )
845 .into_iter()
846 .map(|(_, contract)| contract),
847 );
848 } else if let Some(instrument) = self.get_instrument(client, contract).await? {
849 let instrument_id = instrument.id();
850 loaded_ids.push(instrument_id);
851 if let Some(underlying) = self.instrument_id_to_ib_contract(&instrument_id) {
852 underlyings.push(underlying);
853 }
854 }
855
856 for underlying in underlyings {
857 let loaded = self
858 .fetch_option_chain_by_range(
859 client,
860 &underlying,
861 expiry_min.as_deref(),
862 expiry_max.as_deref(),
863 options_chain_exchange.as_deref(),
864 )
865 .await?;
866 tracing::debug!(
867 "Loaded {} option instruments for chain request {}.{}",
868 loaded,
869 underlying.symbol.as_str(),
870 underlying.exchange.as_str(),
871 );
872 }
873
874 loaded_ids.extend(
875 self.cached_contract_ids_for(
876 contract.symbol.as_str(),
877 options_chain_exchange
878 .as_deref()
879 .unwrap_or_else(|| contract.exchange.as_str()),
880 &[SecurityType::Option, SecurityType::FuturesOption],
881 ),
882 );
883 }
884
885 if !build_futures_chain
886 && !build_options_chain
887 && let Some(instrument) = self.get_instrument(client, contract).await?
888 {
889 loaded_ids.push(instrument.id());
890 }
891
892 loaded_ids.sort_unstable();
893 loaded_ids.dedup();
894 Ok(loaded_ids)
895 }
896
897 fn cached_contract_ids_for(
898 &self,
899 symbol: &str,
900 exchange: &str,
901 security_types: &[SecurityType],
902 ) -> Vec<InstrumentId> {
903 self.cached_contracts_for(symbol, exchange, security_types)
904 .into_iter()
905 .map(|(instrument_id, _)| instrument_id)
906 .collect()
907 }
908
909 fn cached_contracts_for(
910 &self,
911 symbol: &str,
912 exchange: &str,
913 security_types: &[SecurityType],
914 ) -> Vec<(InstrumentId, Contract)> {
915 self.contracts
916 .iter()
917 .filter_map(|entry| {
918 let instrument_id = *entry.key();
919 let contract = entry.value();
920 let exchange_matches =
921 exchange.is_empty() || contract.exchange.as_str() == exchange;
922 if contract.symbol.as_str() == symbol
923 && exchange_matches
924 && security_types.contains(&contract.security_type)
925 {
926 Some((instrument_id, contract.clone()))
927 } else {
928 None
929 }
930 })
931 .collect()
932 }
933
934 #[must_use]
946 pub fn instrument_id_to_ib_contract_details(
947 &self,
948 instrument_id: &InstrumentId,
949 ) -> Option<ibapi::contracts::ContractDetails> {
950 self.contract_details
951 .get(instrument_id)
952 .map(|entry| entry.value().clone())
953 }
954
955 #[must_use]
956 pub fn instrument_id_to_ib_contract(&self, instrument_id: &InstrumentId) -> Option<Contract> {
957 self.contracts
958 .get(instrument_id)
959 .map(|entry| entry.value().clone())
960 }
961
962 pub fn resolve_contract_for_instrument(
963 &self,
964 instrument_id: InstrumentId,
965 ) -> anyhow::Result<Contract> {
966 let cached_contract = self.instrument_id_to_ib_contract(&instrument_id);
967 if let Some(contract) = cached_contract.as_ref()
968 && (contract.contract_id != 0 || is_spread_instrument_id(&instrument_id))
969 {
970 return Ok(contract.clone());
971 }
972
973 if let Some(details) = self.instrument_id_to_ib_contract_details(&instrument_id) {
974 return Ok(details.contract);
975 }
976
977 if let Some(contract) = cached_contract {
978 return Ok(contract);
979 }
980
981 instrument_id_to_ib_contract(instrument_id, None)
982 }
983
984 pub async fn resolve_contract_for_instrument_async(
985 &self,
986 client: &ibapi::Client,
987 instrument_id: InstrumentId,
988 ) -> anyhow::Result<Contract> {
989 if let Ok(contract) = self.resolve_contract_for_instrument(instrument_id)
990 && (contract.contract_id != 0 || self.contract_details.contains_key(&instrument_id))
991 {
992 return Ok(contract);
993 }
994
995 if is_spread_instrument_id(&instrument_id) {
996 self.fetch_spread_instrument(client, instrument_id, false, None)
997 .await?;
998 } else {
999 self.fetch_contract_details(client, instrument_id, false, None)
1000 .await?;
1001 }
1002
1003 self.resolve_contract_for_instrument(instrument_id)
1004 }
1005
1006 pub async fn load_async(
1020 &self,
1021 client: &ibapi::Client,
1022 instrument_id: InstrumentId,
1023 filters: Option<HashMap<String, String>>,
1024 ) -> anyhow::Result<()> {
1025 let filters: Option<HashMap<String, String>> = filters;
1026 let force_instrument_update = filters
1027 .as_ref()
1028 .and_then(|f| f.get("force_instrument_update"))
1029 .map(|v| v == "true")
1030 .unwrap_or(false);
1031
1032 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
1033 .await
1034 }
1035
1036 pub async fn load_with_return_async(
1054 &self,
1055 client: &ibapi::Client,
1056 instrument_id: InstrumentId,
1057 filters: Option<HashMap<String, String>>,
1058 ) -> anyhow::Result<Option<InstrumentId>> {
1059 let filters: Option<HashMap<String, String>> = filters;
1060 let force_instrument_update = filters
1061 .as_ref()
1062 .and_then(|f| f.get("force_instrument_update"))
1063 .map(|v| v == "true")
1064 .unwrap_or(false);
1065
1066 if is_spread_instrument_id(&instrument_id) {
1067 self.fetch_spread_instrument(client, instrument_id, force_instrument_update, filters)
1068 .await?;
1069 } else {
1070 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
1071 .await?;
1072 }
1073
1074 if self.instruments.contains_key(&instrument_id) {
1075 Ok(Some(instrument_id))
1076 } else {
1077 Ok(None)
1078 }
1079 }
1080
1081 pub async fn load_contract_with_return_async(
1082 &self,
1083 client: &ibapi::Client,
1084 contract: &Contract,
1085 spec: Option<&serde_json::Value>,
1086 ) -> anyhow::Result<Vec<InstrumentId>> {
1087 self.load_contract_spec(client, contract, spec).await
1088 }
1089
1090 pub async fn load_ids_async(
1104 &self,
1105 client: &ibapi::Client,
1106 instrument_ids: Vec<InstrumentId>,
1107 filters: Option<HashMap<String, String>>,
1108 ) -> anyhow::Result<()> {
1109 let filters: Option<HashMap<String, String>> = filters;
1110 let force_instrument_update = filters
1111 .as_ref()
1112 .and_then(|f| f.get("force_instrument_update"))
1113 .map(|v| v == "true")
1114 .unwrap_or(false);
1115
1116 for instrument_id in instrument_ids {
1117 let load_result = if is_spread_instrument_id(&instrument_id) {
1118 self.fetch_spread_instrument(
1119 client,
1120 instrument_id,
1121 force_instrument_update,
1122 filters.clone(),
1123 )
1124 .await
1125 .map(|_| ())
1126 } else {
1127 self.fetch_contract_details(
1128 client,
1129 instrument_id,
1130 force_instrument_update,
1131 filters.clone(),
1132 )
1133 .await
1134 };
1135
1136 if let Err(e) = load_result {
1137 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1138 }
1139 }
1140 Ok(())
1141 }
1142
1143 pub async fn load_ids_with_return_async(
1161 &self,
1162 client: &ibapi::Client,
1163 instrument_ids: Vec<InstrumentId>,
1164 filters: Option<HashMap<String, String>>,
1165 ) -> anyhow::Result<Vec<InstrumentId>> {
1166 let mut loaded_ids = Vec::new();
1167
1168 for instrument_id in instrument_ids {
1169 match self
1170 .load_with_return_async(client, instrument_id, filters.clone())
1171 .await
1172 {
1173 Ok(Some(loaded_id)) => loaded_ids.push(loaded_id),
1174 Ok(None) => {}
1175 Err(e) => {
1176 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1177 }
1178 }
1179 }
1180
1181 Ok(loaded_ids)
1182 }
1183
1184 fn create_bag_contract_from_legs(
1185 &self,
1186 leg_contract_details: &[(ibapi::contracts::ContractDetails, i32)],
1187 instrument_id: Option<InstrumentId>,
1188 bag_contract: Option<&Contract>,
1189 ) -> anyhow::Result<Contract> {
1190 if let Some(bag_contract) = bag_contract {
1191 return Ok(bag_contract.clone());
1192 }
1193
1194 let (first_details, _) = leg_contract_details
1195 .first()
1196 .ok_or_else(|| anyhow::anyhow!("Cannot create BAG contract without leg details"))?;
1197
1198 let combo_legs = leg_contract_details
1199 .iter()
1200 .map(|(details, ratio)| ibapi::contracts::ComboLeg {
1201 contract_id: details.contract.contract_id,
1202 ratio: ratio.abs(),
1203 action: if *ratio > 0 {
1204 LegAction::Buy
1205 } else {
1206 LegAction::Sell
1207 },
1208 exchange: details.contract.exchange.to_string(),
1209 open_close: ComboLegOpenClose::Same,
1210 short_sale_slot: 0,
1211 designated_location: String::new(),
1212 exempt_code: -1,
1213 })
1214 .collect();
1215
1216 Ok(Contract {
1217 contract_id: 0,
1218 symbol: first_details.contract.symbol.clone(),
1219 security_type: SecurityType::Spread,
1220 exchange: Exchange::from("SMART"),
1221 currency: first_details.contract.currency.clone(),
1222 local_symbol: instrument_id.map_or_else(String::new, |id| id.symbol.to_string()),
1223 combo_legs_description: instrument_id
1224 .map(|id| format!("Spread: {}", id.symbol))
1225 .unwrap_or_else(|| "Spread".to_string()),
1226 combo_legs,
1227 ..Default::default()
1228 })
1229 }
1230
1231 pub async fn fetch_spread_instrument(
1251 &self,
1252 client: &ibapi::Client,
1253 spread_instrument_id: InstrumentId,
1254 force_instrument_update: bool,
1255 filters: Option<HashMap<String, String>>,
1256 ) -> anyhow::Result<bool> {
1257 if !force_instrument_update && self.instruments.contains_key(&spread_instrument_id) {
1259 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
1260 return Ok(true);
1261 }
1262
1263 let leg_tuples = parse_spread_instrument_id_to_legs(&spread_instrument_id)
1265 .context("Failed to parse spread instrument ID to leg tuples")?;
1266
1267 if leg_tuples.is_empty() {
1268 tracing::error!("Spread instrument {} has no legs", spread_instrument_id);
1269 return Ok(false);
1270 }
1271
1272 tracing::debug!(
1273 "Loading spread instrument {} with {} legs",
1274 spread_instrument_id,
1275 leg_tuples.len()
1276 );
1277
1278 let mut leg_contract_details = Vec::new();
1280
1281 for (leg_instrument_id, ratio) in &leg_tuples {
1282 tracing::debug!(
1283 "Loading leg instrument: {} (ratio: {})",
1284 leg_instrument_id,
1285 ratio
1286 );
1287
1288 self.fetch_contract_details(
1290 client,
1291 *leg_instrument_id,
1292 force_instrument_update,
1293 filters.clone(),
1294 )
1295 .await
1296 .with_context(|| format!("Failed to load leg instrument: {}", leg_instrument_id))?;
1297
1298 let leg_details = self
1300 .contract_details
1301 .get(leg_instrument_id)
1302 .map(|entry| entry.value().clone())
1303 .ok_or_else(|| {
1304 anyhow::anyhow!(
1305 "Leg instrument {} not found in contract details after loading",
1306 leg_instrument_id
1307 )
1308 })?;
1309
1310 leg_contract_details.push((leg_details, *ratio));
1311 }
1312
1313 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1315 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
1316 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
1317
1318 let bag_contract = self.create_bag_contract_from_legs(
1319 &leg_contract_details,
1320 Some(spread_instrument_id),
1321 None,
1322 )?;
1323 let spread_instrument = parse_spread_instrument_any(
1324 spread_instrument_id,
1325 &leg_details_refs,
1326 Some(&bag_contract),
1327 Some(timestamp),
1328 )
1329 .context("Failed to parse spread instrument")?;
1330
1331 self.instruments
1333 .insert(spread_instrument_id, spread_instrument);
1334 self.contracts.insert(spread_instrument_id, bag_contract);
1335
1336 if let Some((first_details, _)) = leg_contract_details.first() {
1337 self.price_magnifiers
1338 .insert(spread_instrument_id, first_details.price_magnifier);
1339 }
1340
1341 tracing::debug!(
1342 "Successfully loaded spread instrument {}",
1343 spread_instrument_id
1344 );
1345 Ok(true)
1346 }
1347
1348 pub async fn load_all_async(
1365 &self,
1366 client: &ibapi::Client,
1367 instrument_ids: Option<Vec<InstrumentId>>,
1368 contracts: Option<Vec<Contract>>,
1369 force_instrument_update: bool,
1370 ) -> anyhow::Result<Vec<InstrumentId>> {
1371 let mut loaded_ids = Vec::new();
1372
1373 let ids_to_load =
1375 instrument_ids.unwrap_or_else(|| self.config.load_ids.iter().cloned().collect());
1376
1377 if !ids_to_load.is_empty() {
1378 let mut filters = std::collections::HashMap::new();
1379
1380 if force_instrument_update {
1381 filters.insert("force_instrument_update".to_string(), "true".to_string());
1382 }
1383 let filters = if filters.is_empty() {
1384 None
1385 } else {
1386 Some(filters)
1387 };
1388
1389 let ids_result = self
1390 .load_ids_with_return_async(client, ids_to_load, filters)
1391 .await
1392 .context("Failed to load instruments from IDs")?;
1393 loaded_ids.extend(ids_result);
1394 }
1395
1396 if let Some(contracts_to_load) = contracts {
1398 for contract in contracts_to_load {
1399 match self.load_contract_spec(client, &contract, None).await {
1400 Ok(mut instrument_ids) => {
1401 loaded_ids.append(&mut instrument_ids);
1402 }
1403 Err(e) => {
1404 tracing::warn!(
1405 "Error loading instrument from contract {:?}: {}",
1406 contract,
1407 e
1408 );
1409 }
1410 }
1411 }
1412 } else {
1413 for contract_json in &self.config.load_contracts {
1414 match crate::common::contracts::parse_contract_from_json(contract_json)
1415 .context("Failed to parse contract from config JSON")
1416 {
1417 Ok(contract) => match self
1418 .load_contract_spec(client, &contract, Some(contract_json))
1419 .await
1420 {
1421 Ok(mut instrument_ids) => {
1422 loaded_ids.append(&mut instrument_ids);
1423 }
1424 Err(e) => {
1425 tracing::warn!(
1426 "Error loading instrument from contract {:?}: {}",
1427 contract,
1428 e
1429 );
1430 }
1431 },
1432 Err(e) => {
1433 tracing::warn!(
1434 "Error parsing load contract spec {:?}: {}",
1435 contract_json,
1436 e
1437 );
1438 }
1439 }
1440 }
1441 }
1442
1443 if loaded_ids.is_empty() {
1444 tracing::debug!("load_all_async called but no instruments were loaded");
1445 } else {
1446 tracing::debug!("load_all_async loaded {} instruments", loaded_ids.len());
1447 }
1448
1449 Ok(loaded_ids)
1450 }
1451}
1452
1453fn normalize_price_magnifier(price_magnifier: i32) -> i32 {
1454 if price_magnifier > 0 {
1455 price_magnifier
1456 } else {
1457 1
1458 }
1459}
1460
1461fn security_type_code(security_type: &SecurityType) -> String {
1462 security_type.to_string()
1463}
1464
1465fn json_bool(spec: Option<&serde_json::Value>, key: &str) -> bool {
1466 spec.and_then(|value| value.get(key))
1467 .and_then(serde_json::Value::as_bool)
1468 .unwrap_or(false)
1469}
1470
1471fn json_u32(spec: Option<&serde_json::Value>, key: &str) -> Option<u32> {
1472 spec.and_then(|value| value.get(key))
1473 .and_then(serde_json::Value::as_u64)
1474 .and_then(|value| u32::try_from(value).ok())
1475}
1476
1477fn json_string(spec: Option<&serde_json::Value>, key: &str) -> Option<String> {
1478 spec.and_then(|value| value.get(key))
1479 .and_then(serde_json::Value::as_str)
1480 .filter(|value| !value.is_empty())
1481 .map(ToString::to_string)
1482}
1483
1484fn contract_from_instrument_info(instrument: &InstrumentAny) -> Option<Contract> {
1485 let value = serde_json::to_value(instrument).ok()?;
1486 let contract_json = find_contract_json(&value)?;
1487 parse_contract_from_json(contract_json).ok()
1488}
1489
1490fn price_magnifier_from_instrument_info(instrument: &InstrumentAny) -> Option<i32> {
1491 let value = serde_json::to_value(instrument).ok()?;
1492 let price_magnifier = find_price_magnifier_json(&value)?;
1493 parse_i32_json(price_magnifier)
1494}
1495
1496fn find_contract_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1497 if let Some(contract_json) = value.get("info").and_then(|info| info.get("contract")) {
1498 return Some(contract_json);
1499 }
1500
1501 value.as_object()?.values().find_map(find_contract_json)
1502}
1503
1504fn find_price_magnifier_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1505 if let Some(info) = value.get("info")
1506 && let Some(price_magnifier) = info
1507 .get("priceMagnifier")
1508 .or_else(|| info.get("price_magnifier"))
1509 {
1510 return Some(price_magnifier);
1511 }
1512
1513 value
1514 .as_object()?
1515 .values()
1516 .find_map(find_price_magnifier_json)
1517}
1518
1519fn parse_i32_json(value: &serde_json::Value) -> Option<i32> {
1520 if let Some(value) = value.as_i64() {
1521 return i32::try_from(value).ok();
1522 }
1523
1524 if let Some(value) = value.as_u64() {
1525 return i32::try_from(value).ok();
1526 }
1527
1528 value.as_str()?.parse::<i32>().ok()
1529}
1530
1531fn expiry_bound_from_days(days: Option<u32>) -> Option<String> {
1532 days.map(|days| {
1533 Offset::UTC
1534 .to_datetime(Timestamp::now())
1535 .date()
1536 .checked_add(Span::new().days(i64::from(days)))
1537 .expect("expiry bound date in range")
1538 .strftime("%Y%m%d")
1539 .to_string()
1540 })
1541}
1542
1543impl InteractiveBrokersInstrumentProvider {
1544 pub async fn fetch_contract_details(
1555 &self,
1556 client: &ibapi::Client,
1557 instrument_id: InstrumentId,
1558 force_instrument_update: bool,
1559 filters: Option<HashMap<String, String>>,
1560 ) -> anyhow::Result<()> {
1561 if !force_instrument_update {
1562 if self.instruments.contains_key(&instrument_id)
1563 && (self.contract_details.contains_key(&instrument_id)
1564 || self.contracts.contains_key(&instrument_id))
1565 {
1566 tracing::debug!(
1567 "Instrument {} already cached, skipping fetch",
1568 instrument_id
1569 );
1570 return Ok(());
1571 }
1572 }
1573 let exchange = filters
1575 .as_ref()
1576 .and_then(|f| f.get("exchange"))
1577 .map(|s| s.as_str());
1578
1579 let exchanges_to_try: Vec<String> = if let Some(exchange) = exchange {
1580 vec![exchange.to_string()]
1581 } else {
1582 possible_exchanges_for_venue(instrument_id.venue.as_str())
1583 };
1584
1585 let mut details_vec = Vec::new();
1586 let mut last_error = None;
1587
1588 for candidate_exchange in exchanges_to_try {
1589 let contract = instrument_id_to_ib_contract(instrument_id, Some(candidate_exchange.as_str()))
1590 .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))?;
1591
1592 match client.contract_details(&contract).await {
1593 Ok(result) if !result.is_empty() => {
1594 details_vec = result;
1595 break;
1596 }
1597 Ok(_) => {}
1598 Err(e) => {
1599 last_error = Some((candidate_exchange.clone(), e));
1600 }
1601 }
1602 }
1603
1604 if details_vec.is_empty() {
1605 if let Some((candidate_exchange, e)) = last_error {
1606 return Err(e).with_context(|| {
1607 format!(
1608 "Failed to fetch contract details for {instrument_id} on {candidate_exchange}"
1609 )
1610 });
1611 } else {
1612 tracing::warn!(
1613 "No contract details returned for {} - instrument may not exist in IB or contract specification is incomplete",
1614 instrument_id
1615 );
1616 }
1617 return Ok(());
1618 }
1619
1620 let loaded_ids = self.process_contract_details(
1621 details_vec,
1622 Some(instrument_id.venue),
1623 force_instrument_update,
1624 );
1625
1626 if loaded_ids.is_empty() {
1627 tracing::warn!("No contract details were processed for {}", instrument_id);
1628 } else {
1629 tracing::debug!(
1630 "Successfully loaded {} instrument(s) for {}",
1631 loaded_ids.len(),
1632 instrument_id
1633 );
1634 }
1635 Ok(())
1636 }
1637
1638 fn process_contract_details(
1639 &self,
1640 details_vec: Vec<ibapi::contracts::ContractDetails>,
1641 venue: Option<Venue>,
1642 force_instrument_update: bool,
1643 ) -> Vec<InstrumentId> {
1644 let mut processed_ids = Vec::new();
1645
1646 for details in details_vec {
1647 match self.process_contract_detail(&details, venue, force_instrument_update) {
1648 Ok(Some(instrument_id)) => processed_ids.push(instrument_id),
1649 Ok(None) => {}
1650 Err(e) => {
1651 tracing::warn!(
1652 "Failed to process IB contract details con_id={} sec_type={}: {}",
1653 details.contract.contract_id,
1654 security_type_code(&details.contract.security_type),
1655 e
1656 );
1657 }
1658 }
1659 }
1660
1661 processed_ids
1662 }
1663
1664 fn process_contract_detail(
1665 &self,
1666 details: &ibapi::contracts::ContractDetails,
1667 venue: Option<Venue>,
1668 force_instrument_update: bool,
1669 ) -> anyhow::Result<Option<InstrumentId>> {
1670 let sec_type = security_type_code(&details.contract.security_type);
1671 if self.is_filtered_sec_type(&sec_type) {
1672 tracing::warn!(
1673 "Skipping filtered security type {} for contract {:?}",
1674 sec_type,
1675 details.contract
1676 );
1677 return Ok(None);
1678 }
1679
1680 let resolved_venue =
1681 venue.unwrap_or_else(|| self.determine_venue(&details.contract, Some(details)));
1682 let instrument_id = self
1683 .instrument_id_from_contract(&details.contract, resolved_venue)
1684 .context("Failed to convert IB contract to instrument ID")?;
1685 let instrument = match parse_ib_contract_to_instrument(details, instrument_id) {
1686 Ok(instrument) => instrument,
1687 Err(e) => {
1688 tracing::warn!(
1689 "Failed to parse IB contract details for {}: {}",
1690 instrument_id,
1691 e
1692 );
1693 return Ok(None);
1694 }
1695 };
1696
1697 if !self.passes_filter_callable(&instrument)? {
1698 return Ok(None);
1699 }
1700
1701 self.cache_instrument(
1702 instrument_id,
1703 instrument,
1704 Some(details.clone()),
1705 None,
1706 None,
1707 force_instrument_update,
1708 );
1709
1710 Ok(Some(instrument_id))
1711 }
1712
1713 fn instrument_id_from_contract(
1714 &self,
1715 contract: &Contract,
1716 venue: Venue,
1717 ) -> anyhow::Result<InstrumentId> {
1718 match self.config.symbology_method {
1719 SymbologyMethod::Simplified => {
1720 ib_contract_to_instrument_id_simplified(contract, Some(venue))
1721 }
1722 SymbologyMethod::Raw => ib_contract_to_instrument_id_raw(contract, Some(venue)),
1723 }
1724 }
1725
1726 fn cache_instrument(
1727 &self,
1728 instrument_id: InstrumentId,
1729 instrument: InstrumentAny,
1730 details: Option<ibapi::contracts::ContractDetails>,
1731 contract: Option<Contract>,
1732 price_magnifier: Option<i32>,
1733 force_instrument_update: bool,
1734 ) -> bool {
1735 let should_update =
1736 force_instrument_update || !self.instruments.contains_key(&instrument_id);
1737
1738 if should_update {
1739 self.instruments.insert(instrument_id, instrument);
1740 }
1741
1742 if let Some(details) = details {
1743 let contract_id = details.contract.contract_id;
1744 self.contracts
1745 .insert(instrument_id, details.contract.clone());
1746 self.contract_details.insert(instrument_id, details.clone());
1747
1748 if contract_id != 0 {
1749 self.contract_id_to_instrument_id
1750 .insert(contract_id, instrument_id);
1751 }
1752 self.price_magnifiers.insert(
1753 instrument_id,
1754 normalize_price_magnifier(details.price_magnifier),
1755 );
1756 } else if let Some(contract) = contract {
1757 if contract.contract_id != 0 {
1758 self.contract_id_to_instrument_id
1759 .insert(contract.contract_id, instrument_id);
1760 }
1761 self.contracts.insert(instrument_id, contract);
1762 }
1763
1764 if let Some(price_magnifier) = price_magnifier {
1765 self.price_magnifiers
1766 .insert(instrument_id, normalize_price_magnifier(price_magnifier));
1767 }
1768
1769 should_update
1770 }
1771
1772 fn passes_filter_callable(&self, instrument: &InstrumentAny) -> anyhow::Result<bool> {
1773 let Some(filter_callable) = self.config.filter_callable.as_deref() else {
1774 return Ok(true);
1775 };
1776
1777 #[cfg(feature = "python")]
1778 {
1779 use nautilus_model::python::instruments::instrument_any_to_pyobject;
1780 use pyo3::{prelude::*, types::PyModule};
1781
1782 Python::attach(|py| {
1783 let (module_name, callable_name) =
1784 filter_callable.rsplit_once('.').ok_or_else(|| {
1785 anyhow::anyhow!(
1786 "Invalid filter_callable path {filter_callable:?}; expected module.callable"
1787 )
1788 })?;
1789 let callable = PyModule::import(py, module_name)
1790 .map_err(|e| anyhow::anyhow!("Failed to import {module_name}: {e}"))?
1791 .getattr(callable_name)
1792 .map_err(|e| anyhow::anyhow!("Failed to resolve {filter_callable}: {e}"))?;
1793 let py_instrument = instrument_any_to_pyobject(py, instrument.clone())
1794 .map_err(|e| anyhow::anyhow!("Failed to convert instrument to Python: {e}"))?;
1795 callable
1796 .call1((py_instrument,))
1797 .and_then(|result| result.extract::<bool>())
1798 .map_err(|e| anyhow::anyhow!("filter_callable {filter_callable} failed: {e}"))
1799 })
1800 }
1801
1802 #[cfg(not(feature = "python"))]
1803 {
1804 let _ = instrument;
1805 anyhow::bail!(
1806 "filter_callable {filter_callable:?} requires the Interactive Brokers adapter to be built with the python feature"
1807 );
1808 }
1809 }
1810
1811 pub async fn batch_load(
1829 &self,
1830 client: &ibapi::Client,
1831 instrument_ids: Vec<InstrumentId>,
1832 filters: Option<&[String]>,
1833 ) -> anyhow::Result<Vec<InstrumentId>> {
1834 let mut loaded_ids = Vec::new();
1835
1836 let filtered_ids: Vec<InstrumentId> = if let Some(filter_list) = filters {
1838 instrument_ids
1844 .into_iter()
1845 .filter(|instrument_id| {
1846 for filter in filter_list {
1848 if instrument_id
1850 .symbol
1851 .as_str()
1852 .to_lowercase()
1853 .contains(&filter.to_lowercase())
1854 {
1855 return true;
1856 }
1857
1858 if instrument_id.venue.as_str() == filter {
1860 return true;
1861 }
1862
1863 if let Some(contract_details) = self.contract_details.get(instrument_id) {
1865 let sec_type_str =
1866 security_type_code(&contract_details.contract.security_type);
1867
1868 if sec_type_str.to_uppercase().contains(&filter.to_uppercase()) {
1869 return true;
1870 }
1871 }
1872 }
1873 false
1874 })
1875 .collect()
1876 } else {
1877 instrument_ids
1878 };
1879
1880 let filtered_count = filtered_ids.len();
1882 for instrument_id in filtered_ids {
1883 match self
1884 .fetch_contract_details(client, instrument_id, false, None)
1885 .await
1886 {
1887 Ok(()) => loaded_ids.push(instrument_id),
1888 Err(e) => {
1889 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1890 }
1891 }
1892 }
1893
1894 tracing::debug!(
1895 "Batch loaded {} instruments ({} after filtering)",
1896 loaded_ids.len(),
1897 filtered_count
1898 );
1899
1900 if !loaded_ids.is_empty()
1902 && let Some(ref cache_path) = self.config.cache_path
1903 && let Err(e) = self.save_cache(cache_path).await
1904 {
1905 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
1906 }
1907
1908 Ok(loaded_ids)
1909 }
1910
1911 pub async fn fetch_option_chain_by_range(
1932 &self,
1933 client: &ibapi::Client,
1934 underlying: &Contract,
1935 expiry_min: Option<&str>,
1936 expiry_max: Option<&str>,
1937 option_chain_exchange: Option<&str>,
1938 ) -> anyhow::Result<usize> {
1939 let exchange = option_chain_exchange.unwrap_or_else(|| underlying.exchange.as_str());
1940 tracing::debug!(
1941 "Building option chain for {}.{} (sec_type={:?}, contract_id={}, expiry_min={:?}, expiry_max={:?}, config_min_days={:?}, config_max_days={:?})",
1942 underlying.symbol.as_str(),
1943 exchange,
1944 underlying.security_type,
1945 underlying.contract_id,
1946 expiry_min,
1947 expiry_max,
1948 self.config.min_expiry_days,
1949 self.config.max_expiry_days,
1950 );
1951
1952 let symbol = underlying.symbol.as_str();
1954 let mut option_chain_stream = client
1955 .option_chain(
1956 symbol,
1957 exchange,
1958 underlying.security_type.clone(),
1959 underlying.contract_id,
1960 )
1961 .await
1962 .context("Failed to request option chain from IB")?;
1963
1964 let mut total_loaded = 0;
1965
1966 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1968
1969 let mut all_expirations = Vec::new();
1971
1972 while let Some(result) = option_chain_stream.next().await {
1973 match result {
1974 Ok(SubscriptionItem::Data(chain)) => {
1975 tracing::debug!(
1976 "Received option chain metadata exchange={} trading_class={} expirations={} strikes={}",
1977 chain.exchange,
1978 chain.trading_class,
1979 chain.expirations.len(),
1980 chain.strikes.len(),
1981 );
1982
1983 for expiration in &chain.expirations {
1984 let date_filter_pass = match (expiry_min, expiry_max) {
1986 (Some(min), Some(max)) => {
1987 expiration.as_str() >= min && expiration.as_str() <= max
1988 }
1989 (Some(min), None) => expiration.as_str() >= min,
1990 (None, Some(max)) => expiration.as_str() <= max,
1991 (None, None) => true,
1992 };
1993
1994 let days_filter_pass = {
1996 let expiry_ns =
1997 crate::providers::parse::expiry_timestring_to_unix_nanos(
1998 expiration.as_str(),
1999 None,
2000 )
2001 .unwrap_or(now);
2002 let days_until_expiry =
2003 (expiry_ns.as_u64().saturating_sub(now.as_u64()))
2004 / (24 * 60 * 60 * 1_000_000_000);
2005
2006 let min_days_ok = self
2007 .config
2008 .min_expiry_days
2009 .is_none_or(|min| days_until_expiry >= min as u64);
2010 let max_days_ok = self
2011 .config
2012 .max_expiry_days
2013 .is_none_or(|max| days_until_expiry <= max as u64);
2014
2015 min_days_ok && max_days_ok
2016 };
2017
2018 if date_filter_pass
2019 && days_filter_pass
2020 && !all_expirations.contains(expiration)
2021 {
2022 all_expirations.push(expiration.clone());
2023 }
2024 }
2025 }
2026 Ok(SubscriptionItem::Notice(notice)) => {
2027 tracing::debug!("Received option chain notice: {notice:?}");
2028 }
2029 Err(e) => {
2030 tracing::warn!("Error receiving option chain metadata: {e}");
2031 }
2032 }
2033 }
2034
2035 all_expirations.sort_unstable();
2036
2037 tracing::debug!(
2038 "Filtered {} option expirations for {}.{}",
2039 all_expirations.len(),
2040 underlying.symbol.as_str(),
2041 exchange,
2042 );
2043
2044 for expiration in all_expirations {
2046 tracing::debug!(
2047 "Requesting option contract details for {}.{} expiry {}",
2048 underlying.symbol.as_str(),
2049 exchange,
2050 expiration,
2051 );
2052
2053 let option_contract = Contract {
2054 contract_id: 0,
2055 symbol: underlying.symbol.clone(),
2056 security_type: if underlying.security_type == SecurityType::Future {
2057 SecurityType::FuturesOption
2058 } else {
2059 SecurityType::Option
2060 },
2061 last_trade_date_or_contract_month: expiration.clone(),
2062 strike: f64::MAX,
2063 right: None,
2064 multiplier: String::new(),
2065 exchange: Exchange::from(exchange),
2066 currency: underlying.currency.clone(),
2067 local_symbol: String::new(),
2068 primary_exchange: Exchange::from(""),
2069 trading_class: String::new(),
2070 include_expired: false,
2071 security_id_type: None,
2072 security_id: String::new(),
2073 combo_legs_description: String::new(),
2074 combo_legs: Vec::new(),
2075 delta_neutral_contract: None,
2076 issuer_id: String::new(),
2077 description: String::new(),
2078 last_trade_date: None,
2079 };
2080
2081 match client.contract_details(&option_contract).await {
2082 Ok(details_vec) => {
2083 tracing::debug!(
2084 "Received {} raw option contract details for {}.{} expiry {}",
2085 details_vec.len(),
2086 underlying.symbol.as_str(),
2087 exchange,
2088 expiration,
2089 );
2090
2091 for details in details_vec {
2092 if details.under_contract_id != underlying.contract_id {
2094 continue;
2095 }
2096
2097 let contract_id = details.contract.contract_id;
2098
2099 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2100 continue;
2101 }
2102
2103 match self.process_contract_detail(&details, None, false) {
2104 Ok(Some(_instrument_id)) => {
2105 total_loaded += 1;
2106 }
2107 Ok(None) => {}
2108 Err(e) => {
2109 tracing::warn!("Failed to parse option instrument: {}", e);
2110 }
2111 }
2112 }
2113 }
2114 Err(e) => {
2115 tracing::warn!(
2116 "Failed to fetch contract details for expiration {}: {}",
2117 expiration,
2118 e
2119 );
2120 }
2121 }
2122 }
2123
2124 tracing::debug!(
2125 "Successfully loaded {} option instruments from chain for {}.{}",
2126 total_loaded,
2127 underlying.symbol.as_str(),
2128 exchange,
2129 );
2130
2131 if total_loaded > 0
2133 && let Some(ref cache_path) = self.config.cache_path
2134 && let Err(e) = self.save_cache(cache_path).await
2135 {
2136 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2137 }
2138
2139 Ok(total_loaded)
2140 }
2141
2142 pub async fn fetch_futures_chain(
2162 &self,
2163 client: &ibapi::Client,
2164 symbol: &str,
2165 exchange: &str,
2166 currency: &str,
2167 trading_class: Option<&str>,
2168 include_expired: bool,
2169 min_expiry_days: Option<u32>,
2170 max_expiry_days: Option<u32>,
2171 ) -> anyhow::Result<usize> {
2172 tracing::debug!(
2173 "Building futures chain for {}.{} (currency={}, trading_class={:?}, include_expired={}, min_days={:?}, max_days={:?}, config_min_days={:?}, config_max_days={:?})",
2174 symbol,
2175 exchange,
2176 currency,
2177 trading_class,
2178 include_expired,
2179 min_expiry_days,
2180 max_expiry_days,
2181 self.config.min_expiry_days,
2182 self.config.max_expiry_days,
2183 );
2184
2185 let futures_contract = Contract {
2187 contract_id: 0, symbol: Symbol::from(symbol.to_string()),
2189 security_type: SecurityType::Future,
2190 last_trade_date_or_contract_month: String::new(),
2191 strike: f64::MAX,
2192 right: None,
2193 multiplier: String::new(),
2194 exchange: Exchange::from(exchange.to_string()),
2195 currency: ibapi::contracts::Currency::from(currency.to_string()),
2196 local_symbol: String::new(),
2197 primary_exchange: Exchange::from(""),
2198 trading_class: trading_class.unwrap_or_default().to_string(),
2199 include_expired,
2200 security_id_type: None,
2201 security_id: String::new(),
2202 combo_legs_description: String::new(),
2203 combo_legs: Vec::new(),
2204 delta_neutral_contract: None,
2205 issuer_id: String::new(),
2206 description: String::new(),
2207 last_trade_date: None,
2208 };
2209
2210 let details_vec = client
2212 .contract_details(&futures_contract)
2213 .await
2214 .context("Failed to fetch futures chain from IB")?;
2215
2216 tracing::debug!(
2217 "Received {} raw futures contract details for {}.{}",
2218 details_vec.len(),
2219 symbol,
2220 exchange,
2221 );
2222
2223 let mut total_loaded = 0;
2224 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2225
2226 for details in details_vec {
2227 let contract_id = details.contract.contract_id;
2228
2229 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2231 continue;
2232 }
2233
2234 let sec_type_str = security_type_code(&details.contract.security_type);
2236 if self.is_filtered_sec_type(&sec_type_str) {
2237 continue;
2238 }
2239
2240 if !details
2242 .contract
2243 .last_trade_date_or_contract_month
2244 .is_empty()
2245 && let Ok(expiry_ns) = crate::providers::parse::expiry_timestring_to_unix_nanos(
2246 &details.contract.last_trade_date_or_contract_month,
2247 Some(&details),
2248 )
2249 {
2250 let days_until_expiry = (expiry_ns.as_u64().saturating_sub(now.as_u64()))
2251 / (24 * 60 * 60 * 1_000_000_000);
2252
2253 let min_days_ok = min_expiry_days
2254 .or(self.config.min_expiry_days)
2255 .is_none_or(|min| days_until_expiry >= min as u64);
2256 let max_days_ok = max_expiry_days
2257 .or(self.config.max_expiry_days)
2258 .is_none_or(|max| days_until_expiry <= max as u64);
2259
2260 if !min_days_ok || !max_days_ok {
2261 continue;
2262 }
2263 }
2264
2265 match self.process_contract_detail(&details, None, false) {
2266 Ok(Some(_instrument_id)) => {
2267 total_loaded += 1;
2268 }
2269 Ok(None) => {}
2270 Err(e) => {
2271 tracing::warn!("Failed to parse futures instrument: {}", e);
2272 }
2273 }
2274 }
2275
2276 tracing::debug!(
2277 "Successfully loaded {} futures instruments from chain",
2278 total_loaded
2279 );
2280
2281 if total_loaded > 0
2283 && let Some(ref cache_path) = self.config.cache_path
2284 && let Err(e) = self.save_cache(cache_path).await
2285 {
2286 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2287 }
2288
2289 Ok(total_loaded)
2290 }
2291
2292 pub async fn fetch_bag_contract(
2316 &self,
2317 client: &ibapi::Client,
2318 bag_contract: &Contract,
2319 ) -> anyhow::Result<usize> {
2320 if bag_contract.security_type != SecurityType::Spread || bag_contract.combo_legs.is_empty()
2322 {
2323 anyhow::bail!(
2324 "Invalid BAG contract: must have security_type=Spread and non-empty combo_legs"
2325 );
2326 }
2327
2328 tracing::debug!(
2329 "Loading BAG contract with {} legs",
2330 bag_contract.combo_legs.len()
2331 );
2332
2333 let mut leg_contract_details = Vec::new();
2335 let mut leg_tuples = Vec::new();
2336
2337 for combo_leg in &bag_contract.combo_legs {
2338 let leg_contract = Contract {
2340 contract_id: combo_leg.contract_id, symbol: bag_contract.symbol.clone(), security_type: SecurityType::Option, last_trade_date_or_contract_month: String::new(),
2344 strike: 0.0,
2345 right: None,
2346 multiplier: String::new(),
2347 exchange: Exchange::from(combo_leg.exchange.as_str()),
2348 currency: bag_contract.currency.clone(), local_symbol: String::new(),
2350 primary_exchange: Exchange::default(),
2351 trading_class: String::new(),
2352 include_expired: false,
2353 security_id_type: None,
2354 security_id: String::new(),
2355 combo_legs_description: String::new(),
2356 combo_legs: Vec::new(),
2357 delta_neutral_contract: None,
2358 issuer_id: String::new(),
2359 description: String::new(),
2360 last_trade_date: None,
2361 };
2362
2363 let leg_details_vec =
2365 client
2366 .contract_details(&leg_contract)
2367 .await
2368 .with_context(|| {
2369 format!(
2370 "Failed to fetch contract details for leg conId {}",
2371 combo_leg.contract_id
2372 )
2373 })?;
2374
2375 if leg_details_vec.is_empty() {
2376 tracing::warn!(
2377 "No contract details returned for leg conId {}",
2378 combo_leg.contract_id
2379 );
2380 continue;
2381 }
2382
2383 let leg_details = &leg_details_vec[0];
2384 let leg_contract_id = leg_details.contract.contract_id;
2385
2386 let leg_instrument_id =
2388 if let Some(cached_id) = self.contract_id_to_instrument_id.get(&leg_contract_id) {
2389 *cached_id.value()
2390 } else {
2391 let leg_venue = self.determine_venue(&leg_details.contract, Some(leg_details));
2393 let leg_instrument_id = match self.config.symbology_method {
2394 crate::config::SymbologyMethod::Simplified => {
2395 crate::common::parse::ib_contract_to_instrument_id_simplified(
2396 &leg_details.contract,
2397 Some(leg_venue),
2398 )
2399 }
2400 crate::config::SymbologyMethod::Raw => {
2401 crate::common::parse::ib_contract_to_instrument_id_raw(
2402 &leg_details.contract,
2403 Some(leg_venue),
2404 )
2405 }
2406 }
2407 .context("Failed to convert leg contract to instrument ID")?;
2408
2409 let leg_instrument =
2411 parse_ib_contract_to_instrument(leg_details, leg_instrument_id)
2412 .context("Failed to parse leg instrument")?;
2413
2414 self.instruments.insert(leg_instrument_id, leg_instrument);
2415 self.contract_details
2416 .insert(leg_instrument_id, leg_details.clone());
2417 self.contracts
2418 .insert(leg_instrument_id, leg_details.contract.clone());
2419 self.contract_id_to_instrument_id
2420 .insert(leg_contract_id, leg_instrument_id);
2421 self.price_magnifiers
2422 .insert(leg_instrument_id, leg_details.price_magnifier);
2423
2424 leg_instrument_id
2425 };
2426
2427 let ratio = IbAction::from_str(combo_leg.action.as_str())
2429 .context("Invalid combo leg action")?
2430 .signed_multiplier()
2431 * combo_leg.ratio;
2432
2433 let leg_details_clone = self
2435 .contract_details
2436 .get(&leg_instrument_id)
2437 .map(|entry| entry.value().clone())
2438 .ok_or_else(|| {
2439 anyhow::anyhow!(
2440 "Contract details not found for leg {} after loading",
2441 leg_instrument_id
2442 )
2443 })?;
2444
2445 leg_contract_details.push((leg_details_clone, ratio));
2446 leg_tuples.push((leg_instrument_id, ratio));
2447 }
2448
2449 if leg_tuples.is_empty() {
2450 anyhow::bail!("No valid legs loaded for BAG contract");
2451 }
2452
2453 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
2455 .context("Failed to create spread instrument ID from leg tuples")?;
2456
2457 let bag_details_vec = client
2459 .contract_details(bag_contract)
2460 .await
2461 .context("Failed to fetch BAG contract details from IB")?;
2462
2463 if bag_details_vec.is_empty() {
2464 tracing::warn!("No contract details returned for BAG contract");
2465
2466 if bag_contract.contract_id != 0 && self.instruments.contains_key(&spread_instrument_id)
2467 {
2468 self.contract_id_to_instrument_id
2469 .insert(bag_contract.contract_id, spread_instrument_id);
2470 }
2471 return Ok(0);
2472 }
2473
2474 let bag_details = &bag_details_vec[0];
2475 let bag_contract_id = bag_details.contract.contract_id;
2476
2477 if bag_contract_id != 0 {
2478 self.contract_id_to_instrument_id
2479 .insert(bag_contract_id, spread_instrument_id);
2480 }
2481
2482 if self.instruments.contains_key(&spread_instrument_id) {
2484 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
2485 self.contract_details
2486 .insert(spread_instrument_id, bag_details.clone());
2487 self.contracts
2488 .insert(spread_instrument_id, bag_details.contract.clone());
2489 self.price_magnifiers
2490 .insert(spread_instrument_id, bag_details.price_magnifier);
2491 return Ok(0);
2492 }
2493
2494 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2496
2497 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
2499 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
2500
2501 let spread_instrument = parse_spread_instrument_any(
2502 spread_instrument_id,
2503 &leg_details_refs,
2504 Some(&bag_details.contract),
2505 Some(timestamp),
2506 )
2507 .context("Failed to parse spread instrument")?;
2508
2509 self.instruments
2511 .insert(spread_instrument_id, spread_instrument);
2512 self.contract_details
2513 .insert(spread_instrument_id, bag_details.clone());
2514 self.contracts
2515 .insert(spread_instrument_id, bag_details.contract.clone());
2516 self.price_magnifiers
2517 .insert(spread_instrument_id, bag_details.price_magnifier);
2518
2519 tracing::debug!(
2520 "Successfully loaded spread instrument {} with {} legs",
2521 spread_instrument_id,
2522 leg_tuples.len()
2523 );
2524
2525 if let Some(ref cache_path) = self.config.cache_path
2527 && let Err(e) = self.save_cache(cache_path).await
2528 {
2529 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2530 }
2531
2532 Ok(1)
2533 }
2534
2535 pub async fn save_cache(&self, cache_path: &str) -> anyhow::Result<()> {
2545 let cache = InstrumentCache {
2546 cache_timestamp: Timestamp::now(),
2547 contract_id_to_instrument_id: self
2548 .contract_id_to_instrument_id
2549 .iter()
2550 .map(|entry| (*entry.key(), entry.value().to_string()))
2551 .collect(),
2552 price_magnifiers: self
2553 .price_magnifiers
2554 .iter()
2555 .map(|entry| (entry.key().to_string(), *entry.value()))
2556 .collect(),
2557 contracts: self
2558 .contracts
2559 .iter()
2560 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2561 .collect(),
2562 contract_details: self
2563 .contract_details
2564 .iter()
2565 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2566 .collect(),
2567 instruments: self
2568 .instruments
2569 .iter()
2570 .map(|entry| {
2571 let instrument_id = entry.key().to_string();
2572 let json =
2573 serde_json::to_string(entry.value()).unwrap_or_else(|_| String::new());
2574 (instrument_id, json)
2575 })
2576 .collect(),
2577 };
2578
2579 if let Some(parent) = Path::new(cache_path).parent() {
2581 fs::create_dir_all(parent)?;
2582 }
2583
2584 let json = serde_json::to_string_pretty(&cache)?;
2586 fs::write(cache_path, json)?;
2587 tracing::debug!(
2588 "Saved instrument cache to {} ({} instruments)",
2589 cache_path,
2590 cache.instruments.len()
2591 );
2592 Ok(())
2593 }
2594
2595 pub async fn load_cache(&self, cache_path: &str) -> anyhow::Result<bool> {
2609 if !Path::new(cache_path).exists() {
2611 tracing::debug!("Cache file does not exist: {}", cache_path);
2612 return Ok(false);
2613 }
2614
2615 let json = fs::read_to_string(cache_path)?;
2617 let cache: InstrumentCache = serde_json::from_str(&json)?;
2618
2619 if let Some(validity_days) = self.config.cache_validity_days {
2621 let cache_age = cache.cache_timestamp.duration_until(Timestamp::now());
2622 let max_age = jiff::SignedDuration::from_hours(24 * (validity_days as i64));
2623 if cache_age > max_age {
2624 tracing::debug!(
2625 "Cache is expired (age: {} days, max: {} days). Ignoring cache",
2626 cache_age.as_secs() / (24 * 60 * 60),
2627 validity_days
2628 );
2629 return Ok(false);
2630 }
2631 }
2632
2633 let mut loaded_count = 0;
2635
2636 for (instrument_id_str, instrument_json) in &cache.instruments {
2637 match InstrumentId::from_str(instrument_id_str) {
2638 Ok(instrument_id) => match serde_json::from_str::<InstrumentAny>(instrument_json) {
2639 Ok(instrument) => {
2640 self.instruments.insert(instrument_id, instrument);
2641
2642 if let Ok(value) =
2643 serde_json::from_str::<serde_json::Value>(instrument_json)
2644 && let Some(contract_json) = find_contract_json(&value)
2645 && let Ok(contract) = parse_contract_from_json(contract_json)
2646 {
2647 if contract.contract_id != 0 {
2648 self.contract_id_to_instrument_id
2649 .insert(contract.contract_id, instrument_id);
2650 }
2651 self.contracts.insert(instrument_id, contract);
2652 }
2653 loaded_count += 1;
2654 }
2655 Err(e) => {
2656 tracing::warn!(
2657 "Failed to deserialize instrument {}: {}",
2658 instrument_id_str,
2659 e
2660 );
2661 }
2662 },
2663 Err(e) => {
2664 tracing::warn!("Failed to parse instrument ID {}: {}", instrument_id_str, e);
2665 }
2666 }
2667 }
2668
2669 for (instrument_id_str, contract) in &cache.contracts {
2671 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2672 if contract.contract_id != 0 {
2673 self.contract_id_to_instrument_id
2674 .insert(contract.contract_id, instrument_id);
2675 }
2676 self.contracts.insert(instrument_id, contract.clone());
2677 }
2678 }
2679
2680 for (instrument_id_str, details) in &cache.contract_details {
2681 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2682 if details.contract.contract_id != 0 {
2683 self.contract_id_to_instrument_id
2684 .insert(details.contract.contract_id, instrument_id);
2685 }
2686 self.contracts
2687 .insert(instrument_id, details.contract.clone());
2688 self.contract_details.insert(instrument_id, details.clone());
2689 }
2690 }
2691
2692 for (contract_id, instrument_id_str) in &cache.contract_id_to_instrument_id {
2694 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2695 self.contract_id_to_instrument_id
2696 .insert(*contract_id, instrument_id);
2697 }
2698 }
2699
2700 for (instrument_id_str, magnifier) in &cache.price_magnifiers {
2702 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2703 self.price_magnifiers.insert(instrument_id, *magnifier);
2704 }
2705 }
2706
2707 tracing::debug!(
2708 "Loaded instrument cache from {} ({} instruments, created at {})",
2709 cache_path,
2710 loaded_count,
2711 cache.cache_timestamp
2712 );
2713 Ok(true)
2714 }
2715}
2716
2717#[cfg(test)]
2718mod tests {
2719 use std::{
2720 fs,
2721 sync::atomic::{AtomicBool, AtomicUsize, Ordering},
2722 };
2723
2724 use nautilus_core::{Params, UnixNanos};
2725 use nautilus_model::{
2726 identifiers::{Symbol, Venue},
2727 instruments::CurrencyPair,
2728 types::{Price, Quantity, currency::Currency},
2729 };
2730 use rstest::rstest;
2731 use tempfile::TempDir;
2732
2733 use super::*;
2734 use crate::common::contract_to_json_value;
2735
2736 struct TestStartupLoader {
2737 id_calls: AtomicUsize,
2738 contract_calls: AtomicUsize,
2739 fail_next_id: AtomicBool,
2740 resolve_ids: AtomicBool,
2741 resolve_contracts: AtomicBool,
2742 yield_on_load: bool,
2743 }
2744
2745 impl TestStartupLoader {
2746 fn new(resolve_ids: bool, resolve_contracts: bool) -> Self {
2747 Self {
2748 id_calls: AtomicUsize::new(0),
2749 contract_calls: AtomicUsize::new(0),
2750 fail_next_id: AtomicBool::new(false),
2751 resolve_ids: AtomicBool::new(resolve_ids),
2752 resolve_contracts: AtomicBool::new(resolve_contracts),
2753 yield_on_load: false,
2754 }
2755 }
2756 }
2757
2758 impl StartupInstrumentLoader for TestStartupLoader {
2759 async fn load_instrument_id(
2760 &self,
2761 instrument_id: InstrumentId,
2762 ) -> anyhow::Result<Option<InstrumentId>> {
2763 self.id_calls.fetch_add(1, Ordering::SeqCst);
2764
2765 if self.yield_on_load {
2766 tokio::task::yield_now().await;
2767 }
2768
2769 if self.fail_next_id.swap(false, Ordering::SeqCst) {
2770 anyhow::bail!("Socket disconnected");
2771 }
2772 Ok(self
2773 .resolve_ids
2774 .load(Ordering::SeqCst)
2775 .then_some(instrument_id))
2776 }
2777
2778 async fn load_contract(
2779 &self,
2780 _contract_spec: &serde_json::Value,
2781 ) -> anyhow::Result<Vec<InstrumentId>> {
2782 self.contract_calls.fetch_add(1, Ordering::SeqCst);
2783
2784 if self.yield_on_load {
2785 tokio::task::yield_now().await;
2786 }
2787 Ok(if self.resolve_contracts.load(Ordering::SeqCst) {
2788 vec![InstrumentId::new(
2789 Symbol::from("MSFT"),
2790 Venue::from("NASDAQ"),
2791 )]
2792 } else {
2793 Vec::new()
2794 })
2795 }
2796 }
2797
2798 fn create_test_provider_with_cache() -> (InteractiveBrokersInstrumentProvider, TempDir) {
2799 let temp_dir = TempDir::new().unwrap();
2800 let cache_path = temp_dir
2801 .path()
2802 .join("test_cache.json")
2803 .to_str()
2804 .unwrap()
2805 .to_string();
2806
2807 let config = InteractiveBrokersInstrumentProviderConfig::builder()
2808 .cache_path(cache_path)
2809 .cache_validity_days(7u32)
2810 .build();
2811
2812 let provider = InteractiveBrokersInstrumentProvider::new(config);
2813 (provider, temp_dir)
2814 }
2815
2816 fn opra_option_contract_details(mut contract: Contract) -> ibapi::contracts::ContractDetails {
2817 contract.contract_id = 12_345;
2818 contract.symbol = ibapi::contracts::Symbol::from("AAPL");
2819 contract.security_type = SecurityType::Option;
2820 contract.exchange = Exchange::from("SMART");
2821 contract.currency = ibapi::contracts::Currency::from("USD");
2822 contract.local_symbol = "AAPL 270115P00155000".to_string();
2823 contract.last_trade_date_or_contract_month = "20270115".to_string();
2824 contract.strike = 155.0;
2825 contract.right = Some(ibapi::contracts::OptionRight::Put);
2826 contract.multiplier = "100".to_string();
2827
2828 ibapi::contracts::ContractDetails {
2829 contract,
2830 min_tick: 0.01,
2831 under_symbol: "AAPL".to_string(),
2832 under_security_type: "STK".to_string(),
2833 valid_exchanges: vec!["SMART".to_string(), "CBOE".to_string()],
2834 ..Default::default()
2835 }
2836 }
2837
2838 fn create_test_instrument(instrument_id: InstrumentId) -> InstrumentAny {
2839 create_test_instrument_with_info(instrument_id, None)
2840 }
2841
2842 #[rstest]
2843 fn test_qualified_opra_details_preserve_canonical_instrument_identity() {
2844 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
2845 let requested_id = InstrumentId::from("AAPL 270115P00155000.OPRA");
2846 let request = instrument_id_to_ib_contract(requested_id, None).unwrap();
2847
2848 assert_eq!(request.security_type, SecurityType::Option);
2849 assert_eq!(request.exchange.as_str(), "SMART");
2850 assert!(request.symbol.as_str().is_empty());
2851 assert_eq!(request.currency.as_str(), "USD");
2852 assert_eq!(request.local_symbol, "AAPL 270115P00155000");
2853 assert!(request.last_trade_date_or_contract_month.is_empty());
2854 assert!(request.right.is_none());
2855 assert_eq!(request.strike, 0.0);
2856
2857 let details = opra_option_contract_details(request);
2858 let loaded_id = provider
2859 .process_contract_detail(&details, Some(requested_id.venue), false)
2860 .unwrap()
2861 .unwrap();
2862
2863 assert_eq!(loaded_id, requested_id);
2864 assert_eq!(provider.count(), 1);
2865 assert_eq!(
2866 provider.get_instrument_id_by_contract_id(12_345),
2867 Some(requested_id)
2868 );
2869 assert_eq!(
2870 provider
2871 .resolve_instrument_id_for_contract(&details.contract)
2872 .unwrap(),
2873 requested_id
2874 );
2875
2876 let cached = provider.find(&requested_id).unwrap();
2877 let InstrumentAny::OptionContract(option) = cached else {
2878 panic!("expected option contract");
2879 };
2880 assert_eq!(option.id, requested_id);
2881 assert_eq!(option.id.venue.as_str(), "OPRA");
2882 assert!(
2883 provider
2884 .find(&InstrumentId::from("AAPL 270115P00155000.SMART"))
2885 .is_none()
2886 );
2887
2888 let cached_contract = provider
2889 .instrument_id_to_ib_contract(&requested_id)
2890 .unwrap();
2891 assert_eq!(cached_contract.contract_id, 12_345);
2892 assert_eq!(cached_contract.security_type, SecurityType::Option);
2893 assert_eq!(cached_contract.exchange.as_str(), "SMART");
2894
2895 let resolved_contract = provider
2896 .resolve_contract_for_instrument(requested_id)
2897 .unwrap();
2898 assert_eq!(resolved_contract, cached_contract);
2899
2900 let cached_details = provider
2901 .instrument_id_to_ib_contract_details(&requested_id)
2902 .unwrap();
2903 assert_eq!(cached_details.contract.contract_id, 12_345);
2904 assert_eq!(
2905 cached_details.valid_exchanges,
2906 vec!["SMART".to_string(), "CBOE".to_string()]
2907 );
2908 }
2909
2910 fn create_test_instrument_with_info(
2911 instrument_id: InstrumentId,
2912 info: Option<Params>,
2913 ) -> InstrumentAny {
2914 CurrencyPair::builder()
2915 .instrument_id(instrument_id)
2916 .raw_symbol(Symbol::from("EUR/USD"))
2917 .base_currency(Currency::from("EUR"))
2918 .quote_currency(Currency::from("USD"))
2919 .price_precision(4)
2920 .size_precision(0)
2921 .price_increment(Price::from("0.0001"))
2922 .size_increment(Quantity::from(1))
2923 .maybe_info(info)
2924 .ts_event(UnixNanos::default())
2925 .ts_init(UnixNanos::default())
2926 .build()
2927 .unwrap()
2928 .into()
2929 }
2930
2931 fn create_contract_info(contract: &Contract, price_magnifier: Option<i32>) -> Params {
2932 let mut info = Params::new();
2933 info.insert(String::from("contract"), contract_to_json_value(contract));
2934 if let Some(price_magnifier) = price_magnifier {
2935 info.insert(
2936 String::from("priceMagnifier"),
2937 serde_json::Value::from(price_magnifier),
2938 );
2939 }
2940 info
2941 }
2942
2943 #[tokio::test]
2944 async fn test_initialize_loads_all_configured_inputs_once() {
2945 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2946 let contract_spec = serde_json::json!({
2947 "secType": "STK",
2948 "symbol": "MSFT",
2949 "exchange": "NASDAQ",
2950 });
2951 let config = InteractiveBrokersInstrumentProviderConfig {
2952 load_ids: [instrument_id].into_iter().collect(),
2953 load_contracts: vec![contract_spec],
2954 ..Default::default()
2955 };
2956 let provider = InteractiveBrokersInstrumentProvider::new(config);
2957 let loader = TestStartupLoader::new(true, true);
2958
2959 let loaded_ids = provider.initialize_with_loader(&loader).await.unwrap();
2960 let second_result = provider.initialize_with_loader(&loader).await.unwrap();
2961
2962 assert_eq!(
2963 loaded_ids,
2964 vec![
2965 instrument_id,
2966 InstrumentId::new(Symbol::from("MSFT"), Venue::from("NASDAQ")),
2967 ]
2968 );
2969 assert!(second_result.is_empty());
2970 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
2971 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 1);
2972 assert!(*provider.startup_initialized.lock().await);
2973 }
2974
2975 #[tokio::test]
2976 async fn test_initialize_fails_closed_and_retries_unresolved_input() {
2977 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2978 let contract_spec = serde_json::json!({
2979 "secType": "STK",
2980 "symbol": "MSFT",
2981 "exchange": "NASDAQ",
2982 });
2983 let config = InteractiveBrokersInstrumentProviderConfig {
2984 load_ids: [instrument_id].into_iter().collect(),
2985 load_contracts: vec![contract_spec],
2986 ..Default::default()
2987 };
2988 let provider = InteractiveBrokersInstrumentProvider::new(config);
2989 let loader = TestStartupLoader::new(true, false);
2990
2991 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
2992
2993 assert!(error.to_string().contains("contract at index 0"));
2994 assert!(!*provider.startup_initialized.lock().await);
2995
2996 loader.resolve_contracts.store(true, Ordering::SeqCst);
2997 provider.initialize_with_loader(&loader).await.unwrap();
2998
2999 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
3000 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 2);
3001 assert!(*provider.startup_initialized.lock().await);
3002 }
3003
3004 #[tokio::test]
3005 async fn test_initialize_preserves_load_error_and_allows_retry() {
3006 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
3007 let config = InteractiveBrokersInstrumentProviderConfig {
3008 load_ids: [instrument_id].into_iter().collect(),
3009 ..Default::default()
3010 };
3011 let provider = InteractiveBrokersInstrumentProvider::new(config);
3012 let loader = TestStartupLoader::new(true, true);
3013 loader.fail_next_id.store(true, Ordering::SeqCst);
3014
3015 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
3016
3017 let error_chain = format!("{error:#}");
3018 assert!(error_chain.contains("Failed to load configured IB instrument ID AAPL.NASDAQ"));
3019 assert!(error_chain.contains("Socket disconnected"));
3020 assert!(!*provider.startup_initialized.lock().await);
3021
3022 provider.initialize_with_loader(&loader).await.unwrap();
3023
3024 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
3025 assert!(*provider.startup_initialized.lock().await);
3026 }
3027
3028 #[tokio::test]
3029 async fn test_initialize_serializes_concurrent_calls() {
3030 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
3031 let config = InteractiveBrokersInstrumentProviderConfig {
3032 load_ids: [instrument_id].into_iter().collect(),
3033 ..Default::default()
3034 };
3035 let provider = InteractiveBrokersInstrumentProvider::new(config);
3036 let mut loader = TestStartupLoader::new(true, true);
3037 loader.yield_on_load = true;
3038
3039 let (first, second) = tokio::join!(
3040 provider.initialize_with_loader(&loader),
3041 provider.initialize_with_loader(&loader),
3042 );
3043
3044 assert_eq!(first.unwrap().len() + second.unwrap().len(), 1);
3045 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
3046 assert!(*provider.startup_initialized.lock().await);
3047 }
3048
3049 #[tokio::test]
3050 async fn test_save_cache() {
3051 let (provider, _temp_dir) = create_test_provider_with_cache();
3052 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3053
3054 let instrument_id1 = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
3056 let instrument_id2 = InstrumentId::new(Symbol::from("GBP/USD"), Venue::from("IDEALPRO"));
3057
3058 let instrument1 = create_test_instrument(instrument_id1);
3059 let instrument2 = create_test_instrument(instrument_id2);
3060
3061 provider.instruments.insert(instrument_id1, instrument1);
3062 provider.instruments.insert(instrument_id2, instrument2);
3063 provider
3064 .contract_id_to_instrument_id
3065 .insert(100, instrument_id1);
3066 provider
3067 .contract_id_to_instrument_id
3068 .insert(200, instrument_id2);
3069 provider.price_magnifiers.insert(instrument_id1, 1);
3070 provider.price_magnifiers.insert(instrument_id2, 1);
3071
3072 let result = provider.save_cache(&cache_path).await;
3074 assert!(result.is_ok(), "save_cache should succeed");
3075
3076 assert!(Path::new(&cache_path).exists(), "Cache file should exist");
3078
3079 let contents = fs::read_to_string(&cache_path).unwrap();
3081 assert!(
3082 contents.contains("EUR/USD"),
3083 "Cache should contain instrument data"
3084 );
3085 assert!(
3086 contents.contains("cache_timestamp"),
3087 "Cache should contain timestamp"
3088 );
3089 }
3090
3091 #[tokio::test]
3092 async fn test_load_cache_valid() {
3093 let (provider, _temp_dir) = create_test_provider_with_cache();
3094 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3095
3096 let instrument_id = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
3098 let instrument = create_test_instrument(instrument_id);
3099
3100 provider
3101 .instruments
3102 .insert(instrument_id, instrument.clone());
3103 provider
3104 .contract_id_to_instrument_id
3105 .insert(100, instrument_id);
3106 provider.price_magnifiers.insert(instrument_id, 1);
3107
3108 provider.save_cache(&cache_path).await.unwrap();
3109
3110 let new_config = InteractiveBrokersInstrumentProviderConfig::builder()
3112 .cache_path(cache_path.clone())
3113 .cache_validity_days(7u32)
3114 .build();
3115
3116 let new_provider = InteractiveBrokersInstrumentProvider::new(new_config);
3117
3118 let result = new_provider.load_cache(&cache_path).await;
3119 assert!(result.is_ok(), "load_cache should succeed");
3120 assert!(
3121 result.unwrap(),
3122 "load_cache should return true for valid cache"
3123 );
3124
3125 assert!(
3127 new_provider.find(&instrument_id).is_some(),
3128 "Instrument should be loaded from cache"
3129 );
3130 assert_eq!(new_provider.count(), 1, "Provider should have 1 instrument");
3131 }
3132
3133 #[tokio::test]
3134 async fn test_load_cache_reads_chrono_timestamp() {
3135 let provider = InteractiveBrokersInstrumentProvider::new(
3136 InteractiveBrokersInstrumentProviderConfig::builder()
3137 .cache_validity_days(7u32)
3138 .build(),
3139 );
3140 let cache_path = concat!(
3141 env!("CARGO_MANIFEST_DIR"),
3142 "/test_data/instrument_cache_chrono.json"
3143 );
3144
3145 let loaded = provider.load_cache(cache_path).await.unwrap();
3146
3147 assert!(loaded);
3148 assert_eq!(provider.count(), 0);
3149 }
3150
3151 #[tokio::test]
3152 async fn test_load_cache_restores_contract_details() {
3153 let (provider, _temp_dir) = create_test_provider_with_cache();
3154 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3155 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3156 let instrument = create_test_instrument(instrument_id);
3157 let contract = Contract {
3158 contract_id: 265598,
3159 symbol: ibapi::contracts::Symbol::from("AAPL"),
3160 security_type: SecurityType::Stock,
3161 exchange: Exchange::from("SMART"),
3162 primary_exchange: Exchange::from("NASDAQ"),
3163 currency: ibapi::contracts::Currency::from("USD"),
3164 ..Default::default()
3165 };
3166 let details = ibapi::contracts::ContractDetails {
3167 contract: contract.clone(),
3168 price_magnifier: 1,
3169 ..Default::default()
3170 };
3171
3172 provider.cache_instrument(
3173 instrument_id,
3174 instrument,
3175 Some(details),
3176 Some(contract),
3177 Some(1),
3178 false,
3179 );
3180 provider.save_cache(&cache_path).await.unwrap();
3181
3182 let new_provider = InteractiveBrokersInstrumentProvider::new(provider.config.clone());
3183
3184 assert!(new_provider.load_cache(&cache_path).await.unwrap());
3185 assert_eq!(
3186 new_provider
3187 .resolve_contract_for_instrument(instrument_id)
3188 .unwrap()
3189 .contract_id,
3190 265598
3191 );
3192 assert_eq!(
3193 new_provider
3194 .instrument_id_to_ib_contract_details(&instrument_id)
3195 .unwrap()
3196 .contract
3197 .contract_id,
3198 265598
3199 );
3200 }
3201
3202 #[rstest]
3203 fn test_filter_sec_types_uses_ib_codes_case_insensitive() {
3204 let config = InteractiveBrokersInstrumentProviderConfig {
3205 filter_sec_types: [String::from("opt")].into_iter().collect(),
3206 ..Default::default()
3207 };
3208 let provider = InteractiveBrokersInstrumentProvider::new(config);
3209
3210 assert!(provider.is_filtered_sec_type(&security_type_code(&SecurityType::Option)));
3211 assert!(!provider.is_filtered_sec_type(&security_type_code(&SecurityType::Stock)));
3212 }
3213
3214 #[rstest]
3215 fn test_add_cached_instruments_only_seeds_ib_contracts() {
3216 let provider = InteractiveBrokersInstrumentProvider::new(Default::default());
3217 let ib_instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3218 let non_ib_instrument_id =
3219 InstrumentId::new(Symbol::from("BTCUSDT"), Venue::from("BINANCE"));
3220 let contract = Contract {
3221 contract_id: 265598,
3222 symbol: ibapi::contracts::Symbol::from("AAPL"),
3223 security_type: SecurityType::Stock,
3224 exchange: Exchange::from("SMART"),
3225 primary_exchange: Exchange::from("NASDAQ"),
3226 currency: ibapi::contracts::Currency::from("USD"),
3227 ..Default::default()
3228 };
3229 let ib_instrument = create_test_instrument_with_info(
3230 ib_instrument_id,
3231 Some(create_contract_info(&contract, Some(100))),
3232 );
3233 let non_ib_instrument = create_test_instrument(non_ib_instrument_id);
3234
3235 let count = provider.add_cached_instruments([ib_instrument, non_ib_instrument]);
3236
3237 assert_eq!(count, 1);
3238 assert_eq!(provider.count(), 1);
3239 assert!(provider.find(&ib_instrument_id).is_some());
3240 assert!(provider.find(&non_ib_instrument_id).is_none());
3241 assert_eq!(
3242 provider
3243 .resolve_contract_for_instrument(ib_instrument_id)
3244 .unwrap()
3245 .contract_id,
3246 265598
3247 );
3248 assert_eq!(provider.get_price_magnifier(&ib_instrument_id), 100);
3249 }
3250
3251 #[tokio::test]
3252 async fn test_load_cache_missing_file() {
3253 let (provider, _temp_dir) = create_test_provider_with_cache();
3254 let cache_path = "/nonexistent/path/cache.json";
3255
3256 let result = provider.load_cache(cache_path).await;
3257 assert!(
3258 result.is_ok(),
3259 "load_cache should not error on missing file"
3260 );
3261 assert!(
3262 !result.unwrap(),
3263 "load_cache should return false for missing file"
3264 );
3265 }
3266
3267 #[tokio::test]
3268 async fn test_load_cache_expired() {
3269 let (provider, _temp_dir) = create_test_provider_with_cache();
3270 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3271
3272 let old_timestamp = Timestamp::now() - jiff::SignedDuration::from_hours(24 * (10));
3274 let expired_cache = InstrumentCache {
3275 cache_timestamp: old_timestamp,
3276 contract_id_to_instrument_id: vec![],
3277 price_magnifiers: vec![],
3278 contracts: vec![],
3279 contract_details: vec![],
3280 instruments: vec![],
3281 };
3282
3283 let json = serde_json::to_string_pretty(&expired_cache).unwrap();
3284 fs::write(&cache_path, json).unwrap();
3285
3286 let result = provider.load_cache(&cache_path).await;
3288 assert!(
3289 result.is_ok(),
3290 "load_cache should not error on expired cache"
3291 );
3292 assert!(
3293 !result.unwrap(),
3294 "load_cache should return false for expired cache"
3295 );
3296 }
3297}