1use std::{collections::HashMap, fs, path::Path, str::FromStr, sync::Arc, time::Duration};
19
20use anyhow::Context;
21use dashmap::DashMap;
22use ibapi::{
23 contracts::{ComboLegOpenClose, Contract, Exchange, LegAction, SecurityType, Symbol},
24 prelude::StreamExt,
25 subscriptions::SubscriptionItem,
26};
27use jiff::{Span, Timestamp, tz::Offset};
28use nautilus_common::{cache::Cache, config::ConfigResult};
29use nautilus_model::{
30 identifiers::{InstrumentId, Venue},
31 instruments::{Instrument, InstrumentAny},
32};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 common::{
37 Symbology,
38 contracts::{
39 ConfiguredContract, KEY_BUILD_FUTURES_CHAIN, KEY_BUILD_OPTIONS_CHAIN,
40 KEY_MAX_EXPIRY_DAYS, KEY_MIN_EXPIRY_DAYS, KEY_OPTIONS_CHAIN_EXCHANGE,
41 KEY_OPTIONS_CHAIN_EXCHANGE_ALT, parse_contract_from_json,
42 },
43 enums::{IbAction, IbSecurityType},
44 spreads::{
45 create_spread_instrument_id, is_spread_instrument_id,
46 parse_spread_instrument_id_to_legs,
47 },
48 symbology::{
49 determine_venue_from_contract, exchange_to_mic_venue, possible_exchanges_for_venue,
50 symbol_mic_venue,
51 },
52 },
53 config::{InteractiveBrokersInstrumentProviderConfig, SymbologyMethod},
54 providers::parse::{
55 expiry_timestring_to_unix_nanos, parse_ib_contract_to_instrument,
56 parse_spread_instrument_any, split_last_trade_date, trim_last_trade_date,
57 },
58};
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62struct InstrumentCache {
63 cache_timestamp: Timestamp,
65 contract_id_to_instrument_id: Vec<(i32, String)>,
67 price_magnifiers: Vec<(String, i32)>,
69 #[serde(default)]
71 contracts: Vec<(String, Contract)>,
72 #[serde(default)]
74 contract_details: Vec<(String, ibapi::contracts::ContractDetails)>,
75 instruments: Vec<(String, String)>, }
78
79#[cfg_attr(
84 feature = "python",
85 pyo3::pyclass(
86 module = "nautilus_trader.adapters.interactive_brokers",
87 unsendable,
88 from_py_object
89 )
90)]
91#[cfg_attr(
92 feature = "python",
93 pyo3_stub_gen::derive::gen_stub_pyclass(
94 module = "nautilus_trader.adapters.interactive_brokers"
95 )
96)]
97#[derive(Debug, Clone)]
98pub struct InteractiveBrokersInstrumentProvider {
99 config: InteractiveBrokersInstrumentProviderConfig,
101 symbology: Symbology,
103 contract_id_to_instrument_id: Arc<DashMap<i32, InstrumentId>>,
105 instruments: Arc<DashMap<InstrumentId, InstrumentAny>>,
107 contract_details: Arc<DashMap<InstrumentId, ibapi::contracts::ContractDetails>>,
109 contracts: Arc<DashMap<InstrumentId, Contract>>,
111 price_magnifiers: Arc<DashMap<InstrumentId, i32>>,
113 startup_initialized: Arc<tokio::sync::Mutex<bool>>,
115}
116
117trait StartupInstrumentLoader {
118 async fn load_instrument_id(
119 &self,
120 instrument_id: InstrumentId,
121 ) -> anyhow::Result<Option<InstrumentId>>;
122
123 async fn load_contract(
124 &self,
125 configured: &ConfiguredContract,
126 ) -> anyhow::Result<Vec<InstrumentId>>;
127}
128
129struct IbStartupInstrumentLoader<'a> {
130 provider: &'a InteractiveBrokersInstrumentProvider,
131 client: &'a ibapi::Client,
132}
133
134impl StartupInstrumentLoader for IbStartupInstrumentLoader<'_> {
135 async fn load_instrument_id(
136 &self,
137 instrument_id: InstrumentId,
138 ) -> anyhow::Result<Option<InstrumentId>> {
139 self.provider
140 .load_with_return_async(self.client, instrument_id, None)
141 .await
142 }
143
144 async fn load_contract(
145 &self,
146 configured: &ConfiguredContract,
147 ) -> anyhow::Result<Vec<InstrumentId>> {
148 let spec = configured.chain_spec_json();
149 self.provider
150 .load_contract_spec(self.client, &configured.contract, spec.as_ref())
151 .await
152 }
153}
154
155impl InteractiveBrokersInstrumentProvider {
156 pub fn new(config: InteractiveBrokersInstrumentProviderConfig) -> ConfigResult<Self> {
162 config.validate()?;
163
164 let symbology = Symbology::new(config.symbology_method);
165
166 Ok(Self {
167 config,
168 symbology,
169 contract_id_to_instrument_id: Arc::new(DashMap::new()),
170 instruments: Arc::new(DashMap::new()),
171 contract_details: Arc::new(DashMap::new()),
172 contracts: Arc::new(DashMap::new()),
173 price_magnifiers: Arc::new(DashMap::new()),
174 startup_initialized: Arc::new(tokio::sync::Mutex::new(false)),
175 })
176 }
177
178 #[cfg(test)]
179 pub(crate) fn insert_test_instrument(
180 &self,
181 instrument: InstrumentAny,
182 contract_id: i32,
183 price_magnifier: i32,
184 ) {
185 let instrument_id = instrument.id();
186 self.instruments.insert(instrument_id, instrument);
187 self.contract_id_to_instrument_id
188 .insert(contract_id, instrument_id);
189 self.contracts.insert(
190 instrument_id,
191 Contract {
192 contract_id,
193 ..Default::default()
194 },
195 );
196
197 self.price_magnifiers.insert(instrument_id, price_magnifier);
198 }
199
200 #[cfg(test)]
201 pub(crate) fn insert_test_contract_id_mapping(
202 &self,
203 contract_id: i32,
204 instrument_id: InstrumentId,
205 ) {
206 self.contract_id_to_instrument_id
207 .insert(contract_id, instrument_id);
208 }
209
210 pub async fn initialize(&self) -> anyhow::Result<()> {
219 self.validate()?;
220
221 if let Some(ref cache_path) = self.config.cache_path {
222 match self.load_cache(cache_path).await {
223 Ok(cache_loaded) => {
224 if cache_loaded {
225 tracing::debug!(
226 "Initialized provider with {} instruments from cache",
227 self.count()
228 );
229 } else {
230 tracing::debug!(
231 "Cache file not found or expired, starting with empty cache"
232 );
233 }
234 }
235 Err(e) => {
236 tracing::warn!("Failed to load cache during initialization: {}", e);
237 }
238 }
239 }
240
241 Ok(())
242 }
243
244 pub async fn initialize_with_client(
252 &self,
253 client: &ibapi::Client,
254 ) -> anyhow::Result<Vec<InstrumentId>> {
255 let loader = IbStartupInstrumentLoader {
256 provider: self,
257 client,
258 };
259
260 self.initialize_with_loader(&loader).await
261 }
262
263 async fn initialize_with_loader<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
264 where
265 L: StartupInstrumentLoader + Sync,
266 {
267 let mut initialized = self.startup_initialized.lock().await;
268 if *initialized {
269 return Ok(Vec::new());
270 }
271
272 self.initialize().await?;
273 let loaded_ids = self.load_configured_instruments(loader).await?;
274 *initialized = true;
275 Ok(loaded_ids)
276 }
277
278 async fn load_configured_instruments<L>(&self, loader: &L) -> anyhow::Result<Vec<InstrumentId>>
279 where
280 L: StartupInstrumentLoader + Sync,
281 {
282 let mut loaded_ids = Vec::new();
283 let mut unresolved = Vec::new();
284 let mut configured_ids: Vec<_> = self.config.load_ids.iter().copied().collect();
285 configured_ids.sort_unstable();
286
287 for instrument_id in configured_ids {
288 match loader
289 .load_instrument_id(instrument_id)
290 .await
291 .with_context(|| {
292 format!("Failed to load configured IB instrument ID {instrument_id}")
293 })? {
294 Some(loaded_id) => loaded_ids.push(loaded_id),
295 None => unresolved.push(format!("instrument ID {instrument_id}")),
296 }
297 }
298
299 for (index, configured) in self.config.load_contracts.iter().enumerate() {
300 let contract = &configured.contract;
301
302 let mut contract_ids = loader.load_contract(configured).await.with_context(|| {
303 format!("Failed to load configured IB contract at index {index}: {contract:?}")
304 })?;
305
306 if contract_ids.is_empty() {
307 unresolved.push(format!("contract at index {index}: {contract:?}"));
308 } else {
309 loaded_ids.append(&mut contract_ids);
310 }
311 }
312
313 if !unresolved.is_empty() {
314 anyhow::bail!(
315 "Unable to resolve configured Interactive Brokers instruments: {}",
316 unresolved.join(", ")
317 );
318 }
319
320 loaded_ids.sort_unstable();
321 loaded_ids.dedup();
322 Ok(loaded_ids)
323 }
324
325 pub(crate) fn seed_from_cache(&self, cache: &Cache) -> usize {
326 let instruments = cache
327 .instrument_ids(None)
328 .into_iter()
329 .filter_map(|instrument_id| cache.instrument(instrument_id).cloned());
330 let count = self.add_cached_instruments(instruments);
331 if count > 0 {
332 tracing::debug!("Seeded IB instrument provider with {count} cached instruments");
333 }
334
335 count
336 }
337
338 pub fn add_cached_instruments<I>(&self, instruments: I) -> usize
343 where
344 I: IntoIterator<Item = InstrumentAny>,
345 {
346 let mut added = 0;
347
348 for instrument in instruments {
349 let instrument_id = instrument.id();
350
351 let Some(contract) = contract_from_instrument_info(&instrument) else {
352 continue;
353 };
354
355 let Some(price_magnifier) =
356 price_magnifier_from_instrument_info(&instrument).filter(|value| *value >= 0)
357 else {
358 continue;
359 };
360
361 if self.cache_instrument(
362 instrument_id,
363 instrument,
364 None,
365 Some(contract),
366 Some(price_magnifier),
367 false,
368 ) {
369 added += 1;
370 }
371 }
372
373 added
374 }
375
376 pub fn determine_venue(
385 &self,
386 contract: &Contract,
387 contract_details: Option<&ibapi::contracts::ContractDetails>,
388 ) -> Venue {
389 if matches!(contract.security_type, SecurityType::Stock) {
390 if let Some(venue) = symbol_mic_venue(contract, &self.config.symbol_to_mic_venue) {
391 return Venue::from(venue.as_str());
392 }
393 return Venue::from(self.resolve_stock_exchange_from_contract(contract).as_str());
394 }
395
396 let valid_exchanges = contract_details.map(|details| details.valid_exchanges.join(","));
397 let venue_str = determine_venue_from_contract(
398 contract,
399 &self.config.symbol_to_mic_venue,
400 self.config.convert_exchange_to_mic_venue,
401 valid_exchanges.as_deref(),
402 );
403 Venue::from(venue_str.as_str())
404 }
405
406 fn resolve_stock_exchange_from_contract(&self, contract: &Contract) -> String {
407 let cached_venue = self.resolve_cached_symbol_venue(contract);
408 if let Some(venue) = cached_venue.as_deref()
409 && Self::is_compatible_cached_stock_venue(venue, contract.primary_exchange.as_str())
410 {
411 return venue.to_string();
412 }
413
414 if !contract.primary_exchange.as_str().is_empty()
415 && contract.primary_exchange.as_str() != "SMART"
416 {
417 return if self.config.convert_exchange_to_mic_venue {
418 exchange_to_mic_venue(contract.primary_exchange.as_str())
419 .unwrap_or_else(|| contract.primary_exchange.as_str().to_string())
420 } else {
421 contract.primary_exchange.as_str().to_string()
422 };
423 }
424
425 if contract.exchange.as_str() == "SMART"
426 && let Some(venue) = cached_venue
427 {
428 return venue;
429 }
430
431 let exchange = contract.exchange.as_str();
432 if self.config.convert_exchange_to_mic_venue {
433 exchange_to_mic_venue(exchange).unwrap_or_else(|| exchange.to_string())
434 } else {
435 exchange.to_string()
436 }
437 }
438
439 fn is_compatible_cached_stock_venue(venue: &str, primary_exchange: &str) -> bool {
440 if primary_exchange.is_empty() || primary_exchange == "SMART" {
441 return true;
442 }
443
444 venue == primary_exchange
445 || exchange_to_mic_venue(primary_exchange).is_some_and(|mic| mic == venue)
446 }
447
448 fn resolve_cached_symbol_venue(&self, contract: &Contract) -> Option<String> {
449 self.instruments.iter().find_map(|entry| {
450 let instrument = entry.value();
451 let instrument_id = instrument.id();
452 (instrument_id.symbol.as_str() == contract.symbol.as_str())
453 .then(|| instrument_id.venue.to_string())
454 })
455 }
456
457 pub fn symbology_method(&self) -> SymbologyMethod {
459 self.symbology.method()
460 }
461
462 #[must_use]
468 pub fn find(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
469 self.instruments
470 .get(instrument_id)
471 .map(|entry| entry.value().clone())
472 }
473
474 #[must_use]
475 pub(crate) fn find_all(&self, instrument_ids: &[InstrumentId]) -> Vec<InstrumentAny> {
476 instrument_ids
477 .iter()
478 .filter_map(|instrument_id| self.find(instrument_id))
479 .collect()
480 }
481
482 #[must_use]
488 pub fn find_by_contract_id(&self, contract_id: i32) -> Option<InstrumentAny> {
489 self.get_instrument_id_by_contract_id(contract_id)
490 .and_then(|instrument_id| self.find(&instrument_id))
491 }
492
493 #[must_use]
499 pub fn get_instrument_id_by_contract_id(&self, contract_id: i32) -> Option<InstrumentId> {
500 self.contract_id_to_instrument_id
501 .get(&contract_id)
502 .map(|entry| *entry.value())
503 }
504
505 pub fn resolve_instrument_id_for_contract(
514 &self,
515 contract: &Contract,
516 ) -> anyhow::Result<InstrumentId> {
517 if contract.contract_id != 0
518 && let Some(instrument_id) = self.get_instrument_id_by_contract_id(contract.contract_id)
519 {
520 return Ok(instrument_id);
521 }
522
523 if contract.security_type == SecurityType::Spread {
524 return self.resolve_spread_instrument_id_for_contract(contract);
525 }
526
527 let venue = self.determine_venue(contract, None);
528
529 self.symbology.instrument_id(contract, Some(venue))
530 }
531
532 fn resolve_spread_instrument_id_for_contract(
533 &self,
534 contract: &Contract,
535 ) -> anyhow::Result<InstrumentId> {
536 let spread_instrument_id = self.spread_instrument_id_for_contract(contract)?;
537
538 if self.find(&spread_instrument_id).is_none() {
539 anyhow::bail!("Resolved BAG spread {spread_instrument_id} is not cached");
540 }
541
542 Ok(spread_instrument_id)
543 }
544
545 pub fn spread_instrument_id_for_contract(
552 &self,
553 contract: &Contract,
554 ) -> anyhow::Result<InstrumentId> {
555 if contract.combo_legs.is_empty() {
556 anyhow::bail!("Cannot resolve BAG contract without combo legs or cached contract ID");
557 }
558
559 let mut leg_tuples = Vec::with_capacity(contract.combo_legs.len());
560
561 for combo_leg in &contract.combo_legs {
562 let leg_instrument_id = self
563 .get_instrument_id_by_contract_id(combo_leg.contract_id)
564 .with_context(|| {
565 format!(
566 "Cannot resolve BAG leg con_id {} to cached instrument ID",
567 combo_leg.contract_id
568 )
569 })?;
570
571 let ratio = IbAction::from_str(combo_leg.action.as_str())
572 .context("Invalid BAG combo leg action")?
573 .signed_multiplier()
574 * combo_leg.ratio;
575
576 leg_tuples.push((leg_instrument_id, ratio));
577 }
578
579 create_spread_instrument_id(&leg_tuples)
580 .context("Failed to create spread instrument ID from BAG combo legs")
581 }
582
583 #[must_use]
589 pub fn is_filtered_sec_type(&self, sec_type: &str) -> bool {
590 IbSecurityType::from_str(sec_type)
591 .is_ok_and(|sec_type| self.config.filter_sec_types.contains(&sec_type))
592 }
593
594 #[must_use]
600 pub fn get_all(&self) -> Vec<InstrumentAny> {
601 self.instruments
602 .iter()
603 .map(|entry| entry.value().clone())
604 .collect()
605 }
606
607 #[must_use]
613 pub fn count(&self) -> usize {
614 self.instruments.len()
615 }
616
617 #[must_use]
630 pub fn get_price_magnifier(&self, instrument_id: &InstrumentId) -> i32 {
631 if let Some(magnifier) = self.price_magnifier(instrument_id) {
632 return magnifier;
633 }
634
635 if self.instruments.contains_key(instrument_id) {
637 tracing::debug!(
638 "Price magnifier not found for instrument {} (has instrument but no contract details), using default 1",
639 instrument_id
640 );
641 } else {
642 tracing::trace!(
643 "Price magnifier not found for instrument {} (instrument not loaded), using default 1",
644 instrument_id
645 );
646 }
647
648 1
650 }
651
652 #[must_use]
655 pub fn price_magnifier(&self, instrument_id: &InstrumentId) -> Option<i32> {
656 if let Some(magnifier) = self.price_magnifiers.get(instrument_id) {
657 return Some(normalize_price_magnifier(*magnifier.value()));
658 }
659
660 let magnifier = normalize_price_magnifier(
661 self.contract_details
662 .get(instrument_id)?
663 .value()
664 .price_magnifier,
665 );
666 self.price_magnifiers.insert(*instrument_id, magnifier);
667 Some(magnifier)
668 }
669
670 pub(crate) fn seed_price_magnifier_from_cache(
677 &self,
678 cache: &Cache,
679 instrument_id: &InstrumentId,
680 ) -> Option<i32> {
681 if let Some(magnifier) = self.price_magnifier(instrument_id) {
682 return Some(magnifier);
683 }
684
685 if let Some(instrument) = cache.instrument(instrument_id) {
686 self.add_cached_instruments([instrument.clone()]);
687 }
688
689 self.price_magnifier(instrument_id)
690 }
691
692 pub async fn ensure_price_magnifier_async(
700 &self,
701 client: &ibapi::Client,
702 instrument_id: InstrumentId,
703 timeout: Duration,
704 ) -> anyhow::Result<i32> {
705 self.ensure_price_magnifier_with(instrument_id, timeout, || async {
706 if is_spread_instrument_id(&instrument_id) {
707 self.fetch_spread_instrument(client, instrument_id, true, None)
708 .await
709 .map(|_| ())
710 } else {
711 self.fetch_contract_details(client, instrument_id, true, None)
712 .await
713 }
714 })
715 .await
716 }
717
718 async fn ensure_price_magnifier_with<F, Fut>(
719 &self,
720 instrument_id: InstrumentId,
721 timeout: Duration,
722 load: F,
723 ) -> anyhow::Result<i32>
724 where
725 F: FnOnce() -> Fut,
726 Fut: std::future::Future<Output = anyhow::Result<()>>,
727 {
728 if let Some(magnifier) = self.price_magnifier(&instrument_id) {
729 return Ok(magnifier);
730 }
731
732 tokio::time::timeout(timeout, load())
735 .await
736 .map_err(|_| {
737 anyhow::anyhow!(
738 "Timed out after {timeout:?} loading IB contract details for {instrument_id}"
739 )
740 })?
741 .with_context(|| format!("Failed to load IB contract details for {instrument_id}"))?;
742
743 self.price_magnifier(&instrument_id).with_context(|| {
744 format!("IB price magnifier for {instrument_id} is unknown: no contract details loaded")
745 })
746 }
747
748 pub async fn get_instrument(
761 &self,
762 client: &ibapi::Client,
763 contract: &Contract,
764 ) -> anyhow::Result<Option<InstrumentAny>> {
765 log::debug!(
766 "IB get_instrument request sec_type={:?} con_id={} symbol={} local_symbol={} exchange={} expiry={}",
767 contract.security_type,
768 contract.contract_id,
769 contract.symbol.as_str(),
770 contract.local_symbol.as_str(),
771 contract.exchange.as_str(),
772 contract.last_trade_date_or_contract_month.as_str()
773 );
774 let sec_type_str = contract.security_type.to_string();
776 if self.is_filtered_sec_type(&sec_type_str) {
777 tracing::debug!(
778 "Skipping filtered security type {} for contract",
779 sec_type_str
780 );
781 return Ok(None);
782 }
783
784 let contract_id = contract.contract_id;
785
786 if let Some(cached_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id) {
788 log::debug!(
789 "IB get_instrument cache hit for contract_id={} -> {}",
790 contract_id,
791 cached_instrument_id.value()
792 );
793
794 if let Some(instrument) = self.find(cached_instrument_id.value()) {
795 return Ok(Some(instrument));
796 }
797 }
798
799 if contract.security_type == SecurityType::Spread && !contract.combo_legs.is_empty() {
801 self.fetch_bag_contract(client, contract).await?;
803
804 if let Some(spread_instrument_id) = self.contract_id_to_instrument_id.get(&contract_id)
806 {
807 return Ok(self.find(spread_instrument_id.value()));
808 }
809
810 if let Ok(spread_instrument_id) =
811 self.resolve_spread_instrument_id_for_contract(contract)
812 {
813 return Ok(self.find(&spread_instrument_id));
814 }
815 }
816
817 let details_vec = request_contract_details(client, contract)
819 .await
820 .context("Failed to fetch contract details from IB")?;
821
822 log::debug!(
823 "IB get_instrument received {} contract details for sec_type={:?} symbol={} local_symbol={}",
824 details_vec.len(),
825 contract.security_type,
826 contract.symbol.as_str(),
827 contract.local_symbol.as_str()
828 );
829
830 if details_vec.is_empty() {
831 tracing::warn!("No contract details returned for contract {}", contract_id);
832 return Ok(None);
833 }
834
835 let loaded_ids = self.process_contract_details(details_vec, None, false);
836
837 if contract_id != 0
838 && let Some(instrument) = self.find_by_contract_id(contract_id)
839 {
840 return Ok(Some(instrument));
841 }
842
843 Ok(loaded_ids
844 .first()
845 .and_then(|instrument_id| self.find(instrument_id)))
846 }
847
848 pub(crate) async fn load_contract_spec(
849 &self,
850 client: &ibapi::Client,
851 contract: &Contract,
852 spec: Option<&serde_json::Value>,
853 ) -> anyhow::Result<Vec<InstrumentId>> {
854 let mut loaded_ids = Vec::new();
855 let build_futures_chain = json_opt_bool(spec, KEY_BUILD_FUTURES_CHAIN)
856 .or(self.config.build_futures_chain)
857 .unwrap_or(false);
858 let build_options_chain = json_opt_bool(spec, KEY_BUILD_OPTIONS_CHAIN)
859 .or(self.config.build_options_chain)
860 .unwrap_or(false);
861 let min_expiry_days = json_u32(spec, KEY_MIN_EXPIRY_DAYS).or(self.config.min_expiry_days);
862 let max_expiry_days = json_u32(spec, KEY_MAX_EXPIRY_DAYS).or(self.config.max_expiry_days);
863 let options_chain_exchange = json_string(spec, KEY_OPTIONS_CHAIN_EXCHANGE)
864 .or_else(|| json_string(spec, KEY_OPTIONS_CHAIN_EXCHANGE_ALT));
865
866 let chain_contract = if contract.security_type == SecurityType::ContinuousFuture
867 && (build_futures_chain || build_options_chain)
868 {
869 match request_contract_details(client, contract).await {
870 Ok(details_vec) => details_vec
871 .into_iter()
872 .next().map_or_else(|| contract.clone(), |details| {
873 tracing::debug!(
874 "Qualified continuous future contract {}.{} as local_symbol={} trading_class={} con_id={}",
875 contract.symbol.as_str(),
876 contract.exchange.as_str(),
877 details.contract.local_symbol.as_str(),
878 details.contract.trading_class.as_str(),
879 details.contract.contract_id,
880 );
881 details.contract
882 }),
883 Err(e) if e.is_connection_lost() => {
884 return Err(e).context("Failed to qualify continuous future contract");
885 }
886 Err(e) => {
887 tracing::warn!(
888 "Failed to qualify continuous future contract {:?}: {}",
889 contract,
890 e
891 );
892 contract.clone()
893 }
894 }
895 } else {
896 contract.clone()
897 };
898
899 let chain_trading_class = (!chain_contract.trading_class.is_empty())
900 .then_some(chain_contract.trading_class.as_str());
901
902 if build_futures_chain {
903 let loaded = self
904 .fetch_futures_chain(
905 client,
906 chain_contract.symbol.as_str(),
907 chain_contract.exchange.as_str(),
908 chain_contract.currency.as_str(),
909 chain_trading_class,
910 contract.security_type == SecurityType::ContinuousFuture,
911 min_expiry_days,
912 max_expiry_days,
913 )
914 .await?;
915 tracing::debug!(
916 "Loaded {} futures instruments for chain request {}.{}",
917 loaded,
918 chain_contract.symbol.as_str(),
919 chain_contract.exchange.as_str(),
920 );
921 loaded_ids.extend(self.cached_contract_ids_for(
922 chain_contract.symbol.as_str(),
923 chain_contract.exchange.as_str(),
924 &[SecurityType::Future],
925 ));
926 }
927
928 if build_options_chain {
929 let expiry_min = expiry_bound_from_days(min_expiry_days);
930 let expiry_max = expiry_bound_from_days(max_expiry_days);
931 let options_exchange = options_chain_exchange
934 .as_deref()
935 .unwrap_or_else(|| contract.exchange.as_str());
936 let mut underlyings = Vec::new();
937
938 if contract.security_type == SecurityType::ContinuousFuture {
939 self.fetch_futures_chain(
942 client,
943 chain_contract.symbol.as_str(),
944 chain_contract.exchange.as_str(),
945 chain_contract.currency.as_str(),
946 chain_trading_class,
947 true,
948 Some(0),
949 Some(u32::MAX),
950 )
951 .await?;
952
953 underlyings.extend(
954 self.cached_contracts_for(
955 contract.symbol.as_str(),
956 chain_contract.exchange.as_str(),
957 &[SecurityType::Future],
958 )
959 .into_iter()
960 .map(|(_, contract)| contract),
961 );
962 } else if let Some(instrument) = self.get_instrument(client, contract).await? {
963 let instrument_id = instrument.id();
964 loaded_ids.push(instrument_id);
965 if let Some(underlying) = self.instrument_id_to_ib_contract(&instrument_id) {
966 underlyings.push(underlying);
967 }
968 }
969
970 for underlying in underlyings {
971 let loaded = self
972 .fetch_option_chain_by_range(
973 client,
974 &underlying,
975 expiry_min.as_deref(),
976 expiry_max.as_deref(),
977 Some(options_exchange),
978 )
979 .await?;
980 tracing::debug!(
981 "Loaded {} option instruments for chain request {}.{}",
982 loaded,
983 underlying.symbol.as_str(),
984 underlying.exchange.as_str(),
985 );
986 }
987
988 loaded_ids.extend(self.cached_contract_ids_for(
989 contract.symbol.as_str(),
990 options_exchange,
991 &[SecurityType::Option, SecurityType::FuturesOption],
992 ));
993 }
994
995 if !build_futures_chain
996 && !build_options_chain
997 && let Some(instrument) = self.get_instrument(client, contract).await?
998 {
999 loaded_ids.push(instrument.id());
1000 }
1001
1002 loaded_ids.sort_unstable();
1003 loaded_ids.dedup();
1004 Ok(loaded_ids)
1005 }
1006
1007 fn cached_contract_ids_for(
1008 &self,
1009 symbol: &str,
1010 exchange: &str,
1011 security_types: &[SecurityType],
1012 ) -> Vec<InstrumentId> {
1013 self.cached_contracts_for(symbol, exchange, security_types)
1014 .into_iter()
1015 .map(|(instrument_id, _)| instrument_id)
1016 .collect()
1017 }
1018
1019 fn cached_contracts_for(
1020 &self,
1021 symbol: &str,
1022 exchange: &str,
1023 security_types: &[SecurityType],
1024 ) -> Vec<(InstrumentId, Contract)> {
1025 self.contracts
1026 .iter()
1027 .filter_map(|entry| {
1028 let instrument_id = *entry.key();
1029 let contract = entry.value();
1030 let exchange_matches =
1031 exchange.is_empty() || contract.exchange.as_str() == exchange;
1032 if contract.symbol.as_str() == symbol
1033 && exchange_matches
1034 && security_types.contains(&contract.security_type)
1035 {
1036 Some((instrument_id, contract.clone()))
1037 } else {
1038 None
1039 }
1040 })
1041 .collect()
1042 }
1043
1044 #[must_use]
1052 pub fn instrument_id_to_ib_contract_details(
1053 &self,
1054 instrument_id: &InstrumentId,
1055 ) -> Option<ibapi::contracts::ContractDetails> {
1056 self.contract_details
1057 .get(instrument_id)
1058 .map(|entry| entry.value().clone())
1059 }
1060
1061 #[must_use]
1062 pub fn instrument_id_to_ib_contract(&self, instrument_id: &InstrumentId) -> Option<Contract> {
1063 self.contracts
1064 .get(instrument_id)
1065 .map(|entry| entry.value().clone())
1066 }
1067
1068 pub fn resolve_contract_for_instrument(
1069 &self,
1070 instrument_id: InstrumentId,
1071 ) -> anyhow::Result<Contract> {
1072 let cached_contract = self.instrument_id_to_ib_contract(&instrument_id);
1073 if let Some(contract) = cached_contract.as_ref()
1074 && (contract.contract_id != 0 || is_spread_instrument_id(&instrument_id))
1075 {
1076 return Ok(contract.clone());
1077 }
1078
1079 if let Some(details) = self.instrument_id_to_ib_contract_details(&instrument_id) {
1080 return Ok(details.contract);
1081 }
1082
1083 if let Some(contract) = cached_contract {
1084 return Ok(contract);
1085 }
1086
1087 let currency = self
1088 .instruments
1089 .get(&instrument_id)
1090 .map(|instrument| instrument.quote_currency().code.to_string());
1091 self.symbology
1092 .contract_with_currency(instrument_id, None, currency.as_deref())
1093 }
1094
1095 pub async fn resolve_contract_for_instrument_async(
1096 &self,
1097 client: &ibapi::Client,
1098 instrument_id: InstrumentId,
1099 ) -> anyhow::Result<Contract> {
1100 if let Ok(contract) = self.resolve_contract_for_instrument(instrument_id)
1101 && (contract.contract_id != 0 || self.contract_details.contains_key(&instrument_id))
1102 {
1103 return Ok(contract);
1104 }
1105
1106 if is_spread_instrument_id(&instrument_id) {
1107 self.fetch_spread_instrument(client, instrument_id, false, None)
1108 .await?;
1109 } else {
1110 self.fetch_contract_details(client, instrument_id, false, None)
1111 .await?;
1112 }
1113
1114 self.resolve_contract_for_instrument(instrument_id)
1115 }
1116
1117 pub async fn load_with_return_async(
1129 &self,
1130 client: &ibapi::Client,
1131 instrument_id: InstrumentId,
1132 filters: Option<HashMap<String, String>>,
1133 ) -> anyhow::Result<Option<InstrumentId>> {
1134 let filters: Option<HashMap<String, String>> = filters;
1135 let force_instrument_update = filters
1136 .as_ref()
1137 .and_then(|f| f.get("force_instrument_update"))
1138 .is_some_and(|v| v == "true");
1139
1140 if is_spread_instrument_id(&instrument_id) {
1141 self.fetch_spread_instrument(client, instrument_id, force_instrument_update, filters)
1142 .await?;
1143 } else {
1144 self.fetch_contract_details(client, instrument_id, force_instrument_update, filters)
1145 .await?;
1146 }
1147
1148 if self.instruments.contains_key(&instrument_id) {
1149 Ok(Some(instrument_id))
1150 } else {
1151 Ok(None)
1152 }
1153 }
1154
1155 pub async fn load_ids_with_return_async(
1167 &self,
1168 client: &ibapi::Client,
1169 instrument_ids: Vec<InstrumentId>,
1170 filters: Option<HashMap<String, String>>,
1171 ) -> anyhow::Result<Vec<InstrumentId>> {
1172 let mut loaded_ids = Vec::new();
1173
1174 for instrument_id in instrument_ids {
1175 match self
1176 .load_with_return_async(client, instrument_id, filters.clone())
1177 .await
1178 {
1179 Ok(Some(loaded_id)) => loaded_ids.push(loaded_id),
1180 Ok(None) => {}
1181 Err(e) => {
1182 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1183 }
1184 }
1185 }
1186
1187 Ok(loaded_ids)
1188 }
1189
1190 fn create_bag_contract_from_legs(
1191 &self,
1192 leg_contract_details: &[(ibapi::contracts::ContractDetails, i32)],
1193 instrument_id: Option<InstrumentId>,
1194 bag_contract: Option<&Contract>,
1195 ) -> anyhow::Result<Contract> {
1196 if let Some(bag_contract) = bag_contract {
1197 return Ok(bag_contract.clone());
1198 }
1199
1200 let (first_details, _) = leg_contract_details
1201 .first()
1202 .ok_or_else(|| anyhow::anyhow!("Cannot create BAG contract without leg details"))?;
1203
1204 let combo_legs = leg_contract_details
1205 .iter()
1206 .map(|(details, ratio)| ibapi::contracts::ComboLeg {
1207 contract_id: details.contract.contract_id,
1208 ratio: ratio.abs(),
1209 action: if *ratio > 0 {
1210 LegAction::Buy
1211 } else {
1212 LegAction::Sell
1213 },
1214 exchange: details.contract.exchange.to_string(),
1215 open_close: ComboLegOpenClose::Same,
1216 short_sale_slot: 0,
1217 designated_location: String::new(),
1218 exempt_code: -1,
1219 })
1220 .collect();
1221
1222 Ok(Contract {
1223 symbol: first_details.contract.symbol.clone(),
1224 security_type: SecurityType::Spread,
1225 exchange: Exchange::from("SMART"),
1226 currency: first_details.contract.currency.clone(),
1227 local_symbol: instrument_id.map_or_else(String::new, |id| id.symbol.to_string()),
1228 combo_legs_description: instrument_id.map_or_else(
1229 || "Spread".to_string(),
1230 |id| format!("Spread: {}", id.symbol),
1231 ),
1232 combo_legs,
1233 ..Default::default()
1234 })
1235 }
1236
1237 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 {leg_instrument_id} not found in contract details after loading"
1306 )
1307 })?;
1308
1309 leg_contract_details.push((leg_details, *ratio));
1310 }
1311
1312 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1314 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
1315 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
1316
1317 let bag_contract = self.create_bag_contract_from_legs(
1318 &leg_contract_details,
1319 Some(spread_instrument_id),
1320 None,
1321 )?;
1322 let spread_instrument = parse_spread_instrument_any(
1323 spread_instrument_id,
1324 &leg_details_refs,
1325 Some(&bag_contract),
1326 Some(timestamp),
1327 )
1328 .context("Failed to parse spread instrument")?;
1329
1330 self.instruments
1332 .insert(spread_instrument_id, spread_instrument);
1333 self.contracts.insert(spread_instrument_id, bag_contract);
1334
1335 if let Some((first_details, _)) = leg_contract_details.first() {
1336 self.price_magnifiers
1337 .insert(spread_instrument_id, first_details.price_magnifier);
1338 }
1339
1340 tracing::debug!(
1341 "Successfully loaded spread instrument {}",
1342 spread_instrument_id
1343 );
1344 Ok(true)
1345 }
1346
1347 pub async fn load_all_async(
1357 &self,
1358 client: &ibapi::Client,
1359 instrument_ids: Option<Vec<InstrumentId>>,
1360 contracts: Option<Vec<Contract>>,
1361 force_instrument_update: bool,
1362 ) -> anyhow::Result<Vec<InstrumentId>> {
1363 self.validate()?;
1364 let mut loaded_ids = Vec::new();
1365
1366 let ids_to_load =
1368 instrument_ids.unwrap_or_else(|| self.config.load_ids.iter().copied().collect());
1369
1370 if !ids_to_load.is_empty() {
1371 let mut filters = std::collections::HashMap::new();
1372 if force_instrument_update {
1373 filters.insert("force_instrument_update".to_string(), "true".to_string());
1374 }
1375
1376 let filters = if filters.is_empty() {
1377 None
1378 } else {
1379 Some(filters)
1380 };
1381
1382 let ids_result = self
1383 .load_ids_with_return_async(client, ids_to_load, filters)
1384 .await
1385 .context("Failed to load instruments from IDs")?;
1386 loaded_ids.extend(ids_result);
1387 }
1388
1389 if let Some(contracts_to_load) = contracts {
1391 for contract in contracts_to_load {
1392 match self.load_contract_spec(client, &contract, None).await {
1393 Ok(mut instrument_ids) => {
1394 loaded_ids.append(&mut instrument_ids);
1395 }
1396 Err(e) => {
1397 tracing::warn!(
1398 "Error loading instrument from contract {:?}: {}",
1399 contract,
1400 e
1401 );
1402 }
1403 }
1404 }
1405 } else {
1406 for configured in &self.config.load_contracts {
1407 let spec = configured.chain_spec_json();
1408
1409 match self
1410 .load_contract_spec(client, &configured.contract, spec.as_ref())
1411 .await
1412 {
1413 Ok(mut instrument_ids) => {
1414 loaded_ids.append(&mut instrument_ids);
1415 }
1416 Err(e) => {
1417 tracing::warn!(
1418 "Error loading instrument from contract {:?}: {}",
1419 configured.contract,
1420 e
1421 );
1422 }
1423 }
1424 }
1425 }
1426
1427 if loaded_ids.is_empty() {
1428 tracing::debug!("load_all_async called but no instruments were loaded");
1429 } else {
1430 tracing::debug!("load_all_async loaded {} instruments", loaded_ids.len());
1431 }
1432
1433 Ok(loaded_ids)
1434 }
1435
1436 fn validate(&self) -> ConfigResult<()> {
1437 self.config.validate()
1438 }
1439}
1440
1441fn normalize_price_magnifier(price_magnifier: i32) -> i32 {
1442 if price_magnifier > 0 {
1443 price_magnifier
1444 } else {
1445 1
1446 }
1447}
1448
1449async fn request_contract_details(
1452 client: &ibapi::Client,
1453 contract: &Contract,
1454) -> Result<Vec<ibapi::contracts::ContractDetails>, ibapi::Error> {
1455 let mut details_vec = client.contract_details(contract).await?;
1456 details_vec.iter_mut().for_each(split_last_trade_date);
1457 Ok(details_vec)
1458}
1459
1460fn json_opt_bool(spec: Option<&serde_json::Value>, key: &str) -> Option<bool> {
1461 spec.and_then(|value| value.get(key))
1462 .and_then(serde_json::Value::as_bool)
1463}
1464
1465fn json_u32(spec: Option<&serde_json::Value>, key: &str) -> Option<u32> {
1466 spec.and_then(|value| value.get(key))
1467 .and_then(serde_json::Value::as_u64)
1468 .and_then(|value| u32::try_from(value).ok())
1469}
1470
1471fn json_string(spec: Option<&serde_json::Value>, key: &str) -> Option<String> {
1472 spec.and_then(|value| value.get(key))
1473 .and_then(serde_json::Value::as_str)
1474 .filter(|value| !value.is_empty())
1475 .map(ToString::to_string)
1476}
1477
1478fn contract_from_instrument_info(instrument: &InstrumentAny) -> Option<Contract> {
1479 let value = serde_json::to_value(instrument).ok()?;
1480 let contract_json = find_contract_json(&value)?;
1481 let mut contract = parse_contract_from_json(contract_json).ok()?;
1482 trim_last_trade_date(&mut contract);
1484 Some(contract)
1485}
1486
1487fn price_magnifier_from_instrument_info(instrument: &InstrumentAny) -> Option<i32> {
1488 let value = serde_json::to_value(instrument).ok()?;
1489 let price_magnifier = find_price_magnifier_json(&value)?;
1490 parse_i32_json(price_magnifier)
1491}
1492
1493fn find_contract_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1494 if let Some(contract_json) = value.get("info").and_then(|info| info.get("contract")) {
1495 return Some(contract_json);
1496 }
1497
1498 value.as_object()?.values().find_map(find_contract_json)
1499}
1500
1501fn find_price_magnifier_json(value: &serde_json::Value) -> Option<&serde_json::Value> {
1502 if let Some(info) = value.get("info")
1503 && let Some(price_magnifier) = info
1504 .get("priceMagnifier")
1505 .or_else(|| info.get("price_magnifier"))
1506 {
1507 return Some(price_magnifier);
1508 }
1509
1510 value
1511 .as_object()?
1512 .values()
1513 .find_map(find_price_magnifier_json)
1514}
1515
1516fn parse_i32_json(value: &serde_json::Value) -> Option<i32> {
1517 if let Some(value) = value.as_i64() {
1518 return i32::try_from(value).ok();
1519 }
1520
1521 if let Some(value) = value.as_u64() {
1522 return i32::try_from(value).ok();
1523 }
1524
1525 value.as_str()?.parse::<i32>().ok()
1526}
1527
1528fn expiry_bound_from_days(days: Option<u32>) -> Option<String> {
1529 days.map(|days| {
1530 Offset::UTC
1531 .to_datetime(Timestamp::now())
1532 .date()
1533 .checked_add(Span::new().days(i64::from(days)))
1534 .expect("expiry bound date in range")
1535 .strftime("%Y%m%d")
1536 .to_string()
1537 })
1538}
1539
1540impl InteractiveBrokersInstrumentProvider {
1541 pub async fn fetch_contract_details(
1547 &self,
1548 client: &ibapi::Client,
1549 instrument_id: InstrumentId,
1550 force_instrument_update: bool,
1551 filters: Option<HashMap<String, String>>,
1552 ) -> anyhow::Result<()> {
1553 if !force_instrument_update
1554 && self.instruments.contains_key(&instrument_id)
1555 && (self.contract_details.contains_key(&instrument_id)
1556 || self.contracts.contains_key(&instrument_id))
1557 {
1558 tracing::debug!(
1559 "Instrument {} already cached, skipping fetch",
1560 instrument_id
1561 );
1562 return Ok(());
1563 }
1564
1565 let exchange = filters
1567 .as_ref()
1568 .and_then(|f| f.get("exchange"))
1569 .map(|s| s.as_str());
1570
1571 let exchanges_to_try: Vec<String> = if let Some(exchange) = exchange {
1572 vec![exchange.to_string()]
1573 } else {
1574 possible_exchanges_for_venue(instrument_id.venue.as_str())
1575 };
1576
1577 let mut details_vec = Vec::new();
1578 let mut last_error = None;
1579
1580 for candidate_exchange in exchanges_to_try {
1581 let contract = self
1582 .symbology
1583 .contract(instrument_id, Some(candidate_exchange.as_str()))
1584 .with_context(|| format!("Failed to convert instrument_id {instrument_id} to IB contract. Check that the instrument ID format is correct and the venue/symbol are valid."))?;
1585
1586 match request_contract_details(client, &contract).await {
1587 Ok(result) if !result.is_empty() => {
1588 details_vec = result;
1589 break;
1590 }
1591 Ok(_) => {}
1592 Err(e) => {
1593 last_error = Some((candidate_exchange.clone(), e));
1594 }
1595 }
1596 }
1597
1598 if details_vec.is_empty() {
1599 if let Some((candidate_exchange, e)) = last_error {
1600 return Err(e).with_context(|| {
1601 format!(
1602 "Failed to fetch contract details for {instrument_id} on {candidate_exchange}"
1603 )
1604 });
1605 } else {
1606 tracing::warn!(
1607 "No contract details returned for {} - instrument may not exist in IB or contract specification is incomplete",
1608 instrument_id
1609 );
1610 }
1611
1612 return Ok(());
1613 }
1614
1615 let loaded_ids = self.process_contract_details(
1616 details_vec,
1617 Some(instrument_id.venue),
1618 force_instrument_update,
1619 );
1620
1621 if loaded_ids.is_empty() {
1622 tracing::warn!("No contract details were processed for {}", instrument_id);
1623 } else {
1624 tracing::debug!(
1625 "Successfully loaded {} instrument(s) for {}",
1626 loaded_ids.len(),
1627 instrument_id
1628 );
1629 }
1630
1631 Ok(())
1632 }
1633
1634 fn process_contract_details(
1635 &self,
1636 details_vec: Vec<ibapi::contracts::ContractDetails>,
1637 venue: Option<Venue>,
1638 force_instrument_update: bool,
1639 ) -> Vec<InstrumentId> {
1640 let mut processed_ids = Vec::new();
1641
1642 for details in details_vec {
1643 match self.process_contract_detail(&details, venue, force_instrument_update) {
1644 Ok(Some(instrument_id)) => processed_ids.push(instrument_id),
1645 Ok(None) => {}
1646 Err(e) => {
1647 tracing::warn!(
1648 "Failed to process IB contract details con_id={} sec_type={}: {}",
1649 details.contract.contract_id,
1650 details.contract.security_type,
1651 e
1652 );
1653 }
1654 }
1655 }
1656
1657 processed_ids
1658 }
1659
1660 fn process_contract_detail(
1661 &self,
1662 details: &ibapi::contracts::ContractDetails,
1663 venue: Option<Venue>,
1664 force_instrument_update: bool,
1665 ) -> anyhow::Result<Option<InstrumentId>> {
1666 let sec_type = details.contract.security_type.to_string();
1667 if self.is_filtered_sec_type(&sec_type) {
1668 tracing::debug!(
1669 "Skipping filtered security type {} for contract {:?}",
1670 sec_type,
1671 details.contract
1672 );
1673 return Ok(None);
1674 }
1675
1676 let resolved_venue =
1677 venue.unwrap_or_else(|| self.determine_venue(&details.contract, Some(details)));
1678 let instrument_id = self
1679 .instrument_id_from_contract(&details.contract, resolved_venue)
1680 .context("Failed to convert IB contract to instrument ID")?;
1681
1682 let instrument = match parse_ib_contract_to_instrument(details, instrument_id) {
1683 Ok(instrument) => instrument,
1684 Err(e) => {
1685 tracing::warn!(
1686 "Failed to parse IB contract details for {}: {}",
1687 instrument_id,
1688 e
1689 );
1690 return Ok(None);
1691 }
1692 };
1693
1694 if !self.passes_filter_callable(&instrument)? {
1695 return Ok(None);
1696 }
1697
1698 self.cache_instrument(
1699 instrument_id,
1700 instrument,
1701 Some(details.clone()),
1702 None,
1703 None,
1704 force_instrument_update,
1705 );
1706
1707 Ok(Some(instrument_id))
1708 }
1709
1710 pub(crate) fn instrument_id_from_contract(
1711 &self,
1712 contract: &Contract,
1713 venue: Venue,
1714 ) -> anyhow::Result<InstrumentId> {
1715 self.symbology.instrument_id(contract, Some(venue))
1716 }
1717
1718 fn cache_instrument(
1719 &self,
1720 instrument_id: InstrumentId,
1721 instrument: InstrumentAny,
1722 details: Option<ibapi::contracts::ContractDetails>,
1723 contract: Option<Contract>,
1724 price_magnifier: Option<i32>,
1725 force_instrument_update: bool,
1726 ) -> bool {
1727 let should_update =
1728 force_instrument_update || !self.instruments.contains_key(&instrument_id);
1729 if should_update {
1730 self.instruments.insert(instrument_id, instrument);
1731 }
1732
1733 if let Some(details) = details {
1734 let contract_id = details.contract.contract_id;
1735 self.contracts
1736 .insert(instrument_id, details.contract.clone());
1737 self.contract_details.insert(instrument_id, details.clone());
1738
1739 if contract_id != 0 {
1740 self.contract_id_to_instrument_id
1741 .insert(contract_id, instrument_id);
1742 }
1743
1744 self.price_magnifiers.insert(
1745 instrument_id,
1746 normalize_price_magnifier(details.price_magnifier),
1747 );
1748 } else if let Some(contract) = contract {
1749 if contract.contract_id != 0 {
1750 self.contract_id_to_instrument_id
1751 .insert(contract.contract_id, instrument_id);
1752 }
1753
1754 self.contracts.insert(instrument_id, contract);
1755 }
1756
1757 if let Some(price_magnifier) = price_magnifier {
1758 self.price_magnifiers
1759 .insert(instrument_id, normalize_price_magnifier(price_magnifier));
1760 }
1761
1762 should_update
1763 }
1764
1765 fn passes_filter_callable(&self, instrument: &InstrumentAny) -> anyhow::Result<bool> {
1766 let Some(filter_callable) = self.config.filter_callable.as_deref() else {
1767 return Ok(true);
1768 };
1769
1770 #[cfg(feature = "python")]
1771 {
1772 use nautilus_model::python::instruments::instrument_any_to_pyobject;
1773 use pyo3::{prelude::*, types::PyModule};
1774
1775 Python::attach(|py| {
1776 let (module_name, callable_name) =
1777 filter_callable.rsplit_once('.').ok_or_else(|| {
1778 anyhow::anyhow!(
1779 "Invalid filter_callable path {filter_callable:?}; expected module.callable"
1780 )
1781 })?;
1782
1783 let callable = PyModule::import(py, module_name)
1784 .map_err(|e| anyhow::anyhow!("Failed to import {module_name}: {e}"))?
1785 .getattr(callable_name)
1786 .map_err(|e| anyhow::anyhow!("Failed to resolve {filter_callable}: {e}"))?;
1787 let py_instrument = instrument_any_to_pyobject(py, instrument.clone())
1788 .map_err(|e| anyhow::anyhow!("Failed to convert instrument to Python: {e}"))?;
1789 callable
1790 .call1((py_instrument,))
1791 .and_then(|result| result.extract::<bool>())
1792 .map_err(|e| anyhow::anyhow!("filter_callable {filter_callable} failed: {e}"))
1793 })
1794 }
1795
1796 #[cfg(not(feature = "python"))]
1797 {
1798 let _ = instrument;
1799 anyhow::bail!(
1800 "filter_callable {filter_callable:?} requires the Interactive Brokers adapter to be built with the python feature"
1801 );
1802 }
1803 }
1804
1805 pub async fn batch_load(
1817 &self,
1818 client: &ibapi::Client,
1819 instrument_ids: Vec<InstrumentId>,
1820 filters: Option<&[String]>,
1821 ) -> anyhow::Result<Vec<InstrumentId>> {
1822 let mut loaded_ids = Vec::new();
1823
1824 let filtered_ids: Vec<InstrumentId> = if let Some(filter_list) = filters {
1826 instrument_ids
1832 .into_iter()
1833 .filter(|instrument_id| {
1834 for filter in filter_list {
1836 if instrument_id
1838 .symbol
1839 .as_str()
1840 .to_lowercase()
1841 .contains(&filter.to_lowercase())
1842 {
1843 return true;
1844 }
1845
1846 if instrument_id.venue.as_str() == filter {
1848 return true;
1849 }
1850
1851 if let Some(contract_details) = self.contract_details.get(instrument_id) {
1853 let sec_type_str = contract_details.contract.security_type.to_string();
1854
1855 if sec_type_str.to_uppercase().contains(&filter.to_uppercase()) {
1856 return true;
1857 }
1858 }
1859 }
1860
1861 false
1862 })
1863 .collect()
1864 } else {
1865 instrument_ids
1866 };
1867
1868 let filtered_count = filtered_ids.len();
1870 for instrument_id in filtered_ids {
1871 match self
1872 .fetch_contract_details(client, instrument_id, false, None)
1873 .await
1874 {
1875 Ok(()) => loaded_ids.push(instrument_id),
1876 Err(e) => {
1877 tracing::warn!("Failed to load instrument {}: {}", instrument_id, e);
1878 }
1879 }
1880 }
1881
1882 tracing::debug!(
1883 "Batch loaded {} instruments ({} after filtering)",
1884 loaded_ids.len(),
1885 filtered_count
1886 );
1887
1888 if !loaded_ids.is_empty()
1890 && let Some(ref cache_path) = self.config.cache_path
1891 && let Err(e) = self.save_cache(cache_path).await
1892 {
1893 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
1894 }
1895
1896 Ok(loaded_ids)
1897 }
1898
1899 pub async fn fetch_option_chain_by_range(
1913 &self,
1914 client: &ibapi::Client,
1915 underlying: &Contract,
1916 expiry_min: Option<&str>,
1917 expiry_max: Option<&str>,
1918 option_chain_exchange: Option<&str>,
1919 ) -> anyhow::Result<usize> {
1920 let exchange = option_chain_exchange.unwrap_or_else(|| underlying.exchange.as_str());
1921 tracing::debug!(
1922 "Building option chain for {}.{} (sec_type={:?}, contract_id={}, expiry_min={:?}, expiry_max={:?}, config_min_days={:?}, config_max_days={:?})",
1923 underlying.symbol.as_str(),
1924 exchange,
1925 underlying.security_type,
1926 underlying.contract_id,
1927 expiry_min,
1928 expiry_max,
1929 self.config.min_expiry_days,
1930 self.config.max_expiry_days,
1931 );
1932
1933 let symbol = underlying.symbol.as_str();
1935 let mut option_chain_request = client.option_chain(
1936 symbol,
1937 underlying.security_type.clone(),
1938 underlying.contract_id,
1939 );
1940
1941 if underlying.security_type == SecurityType::Future {
1944 option_chain_request = option_chain_request.exchange(exchange);
1945 }
1946
1947 let mut option_chain_stream = option_chain_request
1948 .subscribe()
1949 .await
1950 .context("Failed to request option chain from IB")?;
1951
1952 let mut total_loaded = 0;
1953
1954 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
1956
1957 let mut all_expirations = Vec::new();
1959
1960 while let Some(result) = option_chain_stream.next().await {
1961 match result {
1962 Ok(SubscriptionItem::Data(chain)) => {
1963 tracing::debug!(
1964 "Received option chain metadata exchange={} trading_class={} expirations={} strikes={}",
1965 chain.exchange,
1966 chain.trading_class,
1967 chain.expirations.len(),
1968 chain.strikes.len(),
1969 );
1970
1971 if chain.exchange != exchange {
1972 continue;
1973 }
1974
1975 for expiration in &chain.expirations {
1976 let date_filter_pass = match (expiry_min, expiry_max) {
1978 (Some(min), Some(max)) => {
1979 expiration.as_str() >= min && expiration.as_str() <= max
1980 }
1981 (Some(min), None) => expiration.as_str() >= min,
1982 (None, Some(max)) => expiration.as_str() <= max,
1983 (None, None) => true,
1984 };
1985
1986 let days_filter_pass = {
1988 let expiry_ns =
1989 expiry_timestring_to_unix_nanos(expiration.as_str(), None)
1990 .unwrap_or(now);
1991 let days_until_expiry =
1992 (expiry_ns.as_u64().saturating_sub(now.as_u64()))
1993 / (24 * 60 * 60 * 1_000_000_000);
1994
1995 let min_days_ok = self
1996 .config
1997 .min_expiry_days
1998 .is_none_or(|min| days_until_expiry >= min as u64);
1999 let max_days_ok = self
2000 .config
2001 .max_expiry_days
2002 .is_none_or(|max| days_until_expiry <= max as u64);
2003
2004 min_days_ok && max_days_ok
2005 };
2006
2007 if date_filter_pass
2008 && days_filter_pass
2009 && !all_expirations.contains(expiration)
2010 {
2011 all_expirations.push(expiration.clone());
2012 }
2013 }
2014 }
2015 Ok(SubscriptionItem::Notice(notice)) => {
2016 tracing::debug!("Received option chain notice: {notice:?}");
2017 }
2018 Err(e) => {
2019 tracing::warn!("Error receiving option chain metadata: {e}");
2020 }
2021 }
2022 }
2023
2024 all_expirations.sort_unstable();
2025
2026 tracing::debug!(
2027 "Filtered {} option expirations for {}.{}",
2028 all_expirations.len(),
2029 underlying.symbol.as_str(),
2030 exchange,
2031 );
2032
2033 for expiration in all_expirations {
2035 tracing::debug!(
2036 "Requesting option contract details for {}.{} expiry {}",
2037 underlying.symbol.as_str(),
2038 exchange,
2039 expiration,
2040 );
2041
2042 let option_contract = Contract {
2043 contract_id: 0,
2044 symbol: underlying.symbol.clone(),
2045 security_type: if underlying.security_type == SecurityType::Future {
2046 SecurityType::FuturesOption
2047 } else {
2048 SecurityType::Option
2049 },
2050 last_trade_date_or_contract_month: expiration.clone(),
2051 strike: f64::MAX,
2052 right: None,
2053 multiplier: String::new(),
2054 exchange: Exchange::from(exchange),
2055 currency: underlying.currency.clone(),
2056 local_symbol: String::new(),
2057 primary_exchange: Exchange::from(""),
2058 trading_class: String::new(),
2059 include_expired: false,
2060 security_id_type: None,
2061 security_id: String::new(),
2062 combo_legs_description: String::new(),
2063 combo_legs: Vec::new(),
2064 delta_neutral_contract: None,
2065 issuer_id: String::new(),
2066 description: String::new(),
2067 last_trade_date: None,
2068 };
2069
2070 match request_contract_details(client, &option_contract).await {
2071 Ok(details_vec) => {
2072 tracing::debug!(
2073 "Received {} raw option contract details for {}.{} expiry {}",
2074 details_vec.len(),
2075 underlying.symbol.as_str(),
2076 exchange,
2077 expiration,
2078 );
2079
2080 for details in details_vec {
2081 if details.under_contract_id != underlying.contract_id {
2083 continue;
2084 }
2085
2086 let contract_id = details.contract.contract_id;
2087
2088 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2089 continue;
2090 }
2091
2092 match self.process_contract_detail(&details, None, false) {
2093 Ok(Some(_instrument_id)) => {
2094 total_loaded += 1;
2095 }
2096 Ok(None) => {}
2097 Err(e) => {
2098 tracing::warn!("Failed to parse option instrument: {}", e);
2099 }
2100 }
2101 }
2102 }
2103 Err(e) => {
2104 tracing::warn!(
2105 "Failed to fetch contract details for expiration {}: {}",
2106 expiration,
2107 e
2108 );
2109 }
2110 }
2111 }
2112
2113 tracing::debug!(
2114 "Successfully loaded {} option instruments from chain for {}.{}",
2115 total_loaded,
2116 underlying.symbol.as_str(),
2117 exchange,
2118 );
2119
2120 if total_loaded > 0
2122 && let Some(ref cache_path) = self.config.cache_path
2123 && let Err(e) = self.save_cache(cache_path).await
2124 {
2125 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2126 }
2127
2128 Ok(total_loaded)
2129 }
2130
2131 #[allow(clippy::too_many_arguments)] pub async fn fetch_futures_chain(
2145 &self,
2146 client: &ibapi::Client,
2147 symbol: &str,
2148 exchange: &str,
2149 currency: &str,
2150 trading_class: Option<&str>,
2151 include_expired: bool,
2152 min_expiry_days: Option<u32>,
2153 max_expiry_days: Option<u32>,
2154 ) -> anyhow::Result<usize> {
2155 tracing::debug!(
2156 "Building futures chain for {}.{} (currency={}, trading_class={:?}, include_expired={}, min_days={:?}, max_days={:?}, config_min_days={:?}, config_max_days={:?})",
2157 symbol,
2158 exchange,
2159 currency,
2160 trading_class,
2161 include_expired,
2162 min_expiry_days,
2163 max_expiry_days,
2164 self.config.min_expiry_days,
2165 self.config.max_expiry_days,
2166 );
2167
2168 let futures_contract = Contract {
2170 contract_id: 0,
2171 symbol: Symbol::from(symbol.to_string()),
2172 security_type: SecurityType::Future,
2173 last_trade_date_or_contract_month: String::new(),
2174 strike: f64::MAX,
2175 right: None,
2176 multiplier: String::new(),
2177 exchange: Exchange::from(exchange.to_string()),
2178 currency: ibapi::contracts::Currency::from(currency.to_string()),
2179 local_symbol: String::new(),
2180 primary_exchange: Exchange::from(""),
2181 trading_class: trading_class.unwrap_or_default().to_string(),
2182 include_expired,
2183 security_id_type: None,
2184 security_id: String::new(),
2185 combo_legs_description: String::new(),
2186 combo_legs: Vec::new(),
2187 delta_neutral_contract: None,
2188 issuer_id: String::new(),
2189 description: String::new(),
2190 last_trade_date: None,
2191 };
2192
2193 let details_vec = request_contract_details(client, &futures_contract)
2195 .await
2196 .context("Failed to fetch futures chain from IB")?;
2197
2198 tracing::debug!(
2199 "Received {} raw futures contract details for {}.{}",
2200 details_vec.len(),
2201 symbol,
2202 exchange,
2203 );
2204
2205 let mut total_loaded = 0;
2206 let now = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2207
2208 for details in details_vec {
2209 let contract_id = details.contract.contract_id;
2210
2211 if self.contract_id_to_instrument_id.contains_key(&contract_id) {
2213 continue;
2214 }
2215
2216 let sec_type_str = details.contract.security_type.to_string();
2218 if self.is_filtered_sec_type(&sec_type_str) {
2219 continue;
2220 }
2221
2222 if !details
2224 .contract
2225 .last_trade_date_or_contract_month
2226 .is_empty()
2227 && let Ok(expiry_ns) = expiry_timestring_to_unix_nanos(
2228 &details.contract.last_trade_date_or_contract_month,
2229 Some(&details),
2230 )
2231 {
2232 let days_until_expiry = (expiry_ns.as_u64().saturating_sub(now.as_u64()))
2233 / (24 * 60 * 60 * 1_000_000_000);
2234
2235 let min_days_ok = min_expiry_days
2236 .or(self.config.min_expiry_days)
2237 .is_none_or(|min| days_until_expiry >= min as u64);
2238 let max_days_ok = max_expiry_days
2239 .or(self.config.max_expiry_days)
2240 .is_none_or(|max| days_until_expiry <= max as u64);
2241
2242 if !min_days_ok || !max_days_ok {
2243 continue;
2244 }
2245 }
2246
2247 match self.process_contract_detail(&details, None, false) {
2248 Ok(Some(_instrument_id)) => {
2249 total_loaded += 1;
2250 }
2251 Ok(None) => {}
2252 Err(e) => {
2253 tracing::warn!("Failed to parse futures instrument: {}", e);
2254 }
2255 }
2256 }
2257
2258 tracing::debug!(
2259 "Successfully loaded {} futures instruments from chain",
2260 total_loaded
2261 );
2262
2263 if total_loaded > 0
2265 && let Some(ref cache_path) = self.config.cache_path
2266 && let Err(e) = self.save_cache(cache_path).await
2267 {
2268 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2269 }
2270
2271 Ok(total_loaded)
2272 }
2273
2274 pub async fn fetch_bag_contract(
2293 &self,
2294 client: &ibapi::Client,
2295 bag_contract: &Contract,
2296 ) -> anyhow::Result<usize> {
2297 if bag_contract.security_type != SecurityType::Spread || bag_contract.combo_legs.is_empty()
2299 {
2300 anyhow::bail!(
2301 "Invalid BAG contract: must have security_type=Spread and non-empty combo_legs"
2302 );
2303 }
2304
2305 tracing::debug!(
2306 "Loading BAG contract with {} legs",
2307 bag_contract.combo_legs.len()
2308 );
2309
2310 let mut leg_contract_details = Vec::new();
2312 let mut leg_tuples = Vec::new();
2313
2314 for combo_leg in &bag_contract.combo_legs {
2315 let leg_contract = Contract {
2317 contract_id: combo_leg.contract_id, symbol: bag_contract.symbol.clone(), security_type: SecurityType::Option, last_trade_date_or_contract_month: String::new(),
2321 strike: 0.0,
2322 right: None,
2323 multiplier: String::new(),
2324 exchange: Exchange::from(combo_leg.exchange.as_str()),
2325 currency: bag_contract.currency.clone(), local_symbol: String::new(),
2327 primary_exchange: Exchange::default(),
2328 trading_class: String::new(),
2329 include_expired: false,
2330 security_id_type: None,
2331 security_id: String::new(),
2332 combo_legs_description: String::new(),
2333 combo_legs: Vec::new(),
2334 delta_neutral_contract: None,
2335 issuer_id: String::new(),
2336 description: String::new(),
2337 last_trade_date: None,
2338 };
2339
2340 let leg_details_vec = request_contract_details(client, &leg_contract)
2342 .await
2343 .with_context(|| {
2344 format!(
2345 "Failed to fetch contract details for leg conId {}",
2346 combo_leg.contract_id
2347 )
2348 })?;
2349
2350 if leg_details_vec.is_empty() {
2351 tracing::warn!(
2352 "No contract details returned for leg conId {}",
2353 combo_leg.contract_id
2354 );
2355 continue;
2356 }
2357
2358 let leg_details = &leg_details_vec[0];
2359 let leg_contract_id = leg_details.contract.contract_id;
2360
2361 let leg_instrument_id =
2363 if let Some(cached_id) = self.contract_id_to_instrument_id.get(&leg_contract_id) {
2364 *cached_id.value()
2365 } else {
2366 let leg_venue = self.determine_venue(&leg_details.contract, Some(leg_details));
2368 let leg_instrument_id = self
2369 .symbology
2370 .instrument_id(&leg_details.contract, Some(leg_venue))
2371 .context("Failed to convert leg contract to instrument ID")?;
2372
2373 let leg_instrument =
2375 parse_ib_contract_to_instrument(leg_details, leg_instrument_id)
2376 .context("Failed to parse leg instrument")?;
2377
2378 self.instruments.insert(leg_instrument_id, leg_instrument);
2379 self.contract_details
2380 .insert(leg_instrument_id, leg_details.clone());
2381 self.contracts
2382 .insert(leg_instrument_id, leg_details.contract.clone());
2383 self.contract_id_to_instrument_id
2384 .insert(leg_contract_id, leg_instrument_id);
2385 self.price_magnifiers
2386 .insert(leg_instrument_id, leg_details.price_magnifier);
2387
2388 leg_instrument_id
2389 };
2390
2391 let ratio = IbAction::from_str(combo_leg.action.as_str())
2393 .context("Invalid combo leg action")?
2394 .signed_multiplier()
2395 * combo_leg.ratio;
2396
2397 let leg_details_clone = self
2399 .contract_details
2400 .get(&leg_instrument_id)
2401 .map(|entry| entry.value().clone())
2402 .ok_or_else(|| {
2403 anyhow::anyhow!(
2404 "Contract details not found for leg {leg_instrument_id} after loading"
2405 )
2406 })?;
2407
2408 leg_contract_details.push((leg_details_clone, ratio));
2409 leg_tuples.push((leg_instrument_id, ratio));
2410 }
2411
2412 if leg_tuples.is_empty() {
2413 anyhow::bail!("No valid legs loaded for BAG contract");
2414 }
2415
2416 let spread_instrument_id = create_spread_instrument_id(&leg_tuples)
2418 .context("Failed to create spread instrument ID from leg tuples")?;
2419
2420 let bag_details_vec = request_contract_details(client, bag_contract)
2422 .await
2423 .context("Failed to fetch BAG contract details from IB")?;
2424
2425 if bag_details_vec.is_empty() {
2426 tracing::warn!("No contract details returned for BAG contract");
2427
2428 if bag_contract.contract_id != 0 && self.instruments.contains_key(&spread_instrument_id)
2429 {
2430 self.contract_id_to_instrument_id
2431 .insert(bag_contract.contract_id, spread_instrument_id);
2432 }
2433
2434 return Ok(0);
2435 }
2436
2437 let bag_details = &bag_details_vec[0];
2438 let bag_contract_id = bag_details.contract.contract_id;
2439
2440 if bag_contract_id != 0 {
2441 self.contract_id_to_instrument_id
2442 .insert(bag_contract_id, spread_instrument_id);
2443 }
2444
2445 if self.instruments.contains_key(&spread_instrument_id) {
2447 tracing::debug!("Spread instrument {} already cached", spread_instrument_id);
2448 self.contract_details
2449 .insert(spread_instrument_id, bag_details.clone());
2450 self.contracts
2451 .insert(spread_instrument_id, bag_details.contract.clone());
2452 self.price_magnifiers
2453 .insert(spread_instrument_id, bag_details.price_magnifier);
2454 return Ok(0);
2455 }
2456
2457 let timestamp = nautilus_core::time::get_atomic_clock_realtime().get_time_ns();
2459
2460 let leg_details_refs: Vec<(&ibapi::contracts::ContractDetails, i32)> =
2462 leg_contract_details.iter().map(|(d, r)| (d, *r)).collect();
2463
2464 let spread_instrument = parse_spread_instrument_any(
2465 spread_instrument_id,
2466 &leg_details_refs,
2467 Some(&bag_details.contract),
2468 Some(timestamp),
2469 )
2470 .context("Failed to parse spread instrument")?;
2471
2472 self.instruments
2474 .insert(spread_instrument_id, spread_instrument);
2475 self.contract_details
2476 .insert(spread_instrument_id, bag_details.clone());
2477 self.contracts
2478 .insert(spread_instrument_id, bag_details.contract.clone());
2479 self.price_magnifiers
2480 .insert(spread_instrument_id, bag_details.price_magnifier);
2481
2482 tracing::debug!(
2483 "Successfully loaded spread instrument {} with {} legs",
2484 spread_instrument_id,
2485 leg_tuples.len()
2486 );
2487
2488 if let Some(ref cache_path) = self.config.cache_path
2490 && let Err(e) = self.save_cache(cache_path).await
2491 {
2492 tracing::warn!("Failed to save instrument cache to {}: {}", cache_path, e);
2493 }
2494
2495 Ok(1)
2496 }
2497
2498 pub async fn save_cache(&self, cache_path: &str) -> anyhow::Result<()> {
2504 let cache = InstrumentCache {
2505 cache_timestamp: Timestamp::now(),
2506 contract_id_to_instrument_id: self
2507 .contract_id_to_instrument_id
2508 .iter()
2509 .map(|entry| (*entry.key(), entry.value().to_string()))
2510 .collect(),
2511 price_magnifiers: self
2512 .price_magnifiers
2513 .iter()
2514 .map(|entry| (entry.key().to_string(), *entry.value()))
2515 .collect(),
2516 contracts: self
2517 .contracts
2518 .iter()
2519 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2520 .collect(),
2521 contract_details: self
2522 .contract_details
2523 .iter()
2524 .map(|entry| (entry.key().to_string(), entry.value().clone()))
2525 .collect(),
2526 instruments: self
2527 .instruments
2528 .iter()
2529 .map(|entry| {
2530 let instrument_id = entry.key().to_string();
2531 let json =
2532 serde_json::to_string(entry.value()).unwrap_or_else(|_| String::new());
2533 (instrument_id, json)
2534 })
2535 .collect(),
2536 };
2537
2538 if let Some(parent) = Path::new(cache_path).parent() {
2540 fs::create_dir_all(parent)?;
2541 }
2542
2543 let json = serde_json::to_string_pretty(&cache)?;
2545 fs::write(cache_path, json)?;
2546 tracing::debug!(
2547 "Saved instrument cache to {} ({} instruments)",
2548 cache_path,
2549 cache.instruments.len()
2550 );
2551 Ok(())
2552 }
2553
2554 pub async fn load_cache(&self, cache_path: &str) -> anyhow::Result<bool> {
2564 if !Path::new(cache_path).exists() {
2566 tracing::debug!("Cache file does not exist: {}", cache_path);
2567 return Ok(false);
2568 }
2569
2570 let json = fs::read_to_string(cache_path)?;
2572 let cache: InstrumentCache = serde_json::from_str(&json)?;
2573
2574 if let Some(validity_days) = self.config.cache_validity_days {
2576 let cache_age = cache.cache_timestamp.duration_until(Timestamp::now());
2577 let max_age = jiff::SignedDuration::from_hours(24 * (validity_days as i64));
2578 if cache_age > max_age {
2579 tracing::debug!(
2580 "Cache is expired (age: {} days, max: {} days). Ignoring cache",
2581 cache_age.as_secs() / (24 * 60 * 60),
2582 validity_days
2583 );
2584 return Ok(false);
2585 }
2586 }
2587
2588 let mut loaded_count = 0;
2590
2591 for (instrument_id_str, instrument_json) in &cache.instruments {
2592 match InstrumentId::from_str(instrument_id_str) {
2593 Ok(instrument_id) => match serde_json::from_str::<InstrumentAny>(instrument_json) {
2594 Ok(instrument) => {
2595 self.instruments.insert(instrument_id, instrument);
2596
2597 if let Ok(value) =
2598 serde_json::from_str::<serde_json::Value>(instrument_json)
2599 && let Some(contract_json) = find_contract_json(&value)
2600 && let Ok(mut contract) = parse_contract_from_json(contract_json)
2601 {
2602 trim_last_trade_date(&mut contract);
2603
2604 if contract.contract_id != 0 {
2605 self.contract_id_to_instrument_id
2606 .insert(contract.contract_id, instrument_id);
2607 }
2608
2609 self.contracts.insert(instrument_id, contract);
2610 }
2611
2612 loaded_count += 1;
2613 }
2614 Err(e) => {
2615 tracing::warn!(
2616 "Failed to deserialize instrument {}: {}",
2617 instrument_id_str,
2618 e
2619 );
2620 }
2621 },
2622 Err(e) => {
2623 tracing::warn!("Failed to parse instrument ID {}: {}", instrument_id_str, e);
2624 }
2625 }
2626 }
2627
2628 for (instrument_id_str, contract) in &cache.contracts {
2631 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2632 if contract.contract_id != 0 {
2633 self.contract_id_to_instrument_id
2634 .insert(contract.contract_id, instrument_id);
2635 }
2636
2637 let mut contract = contract.clone();
2638 trim_last_trade_date(&mut contract);
2639 self.contracts.insert(instrument_id, contract);
2640 }
2641 }
2642
2643 for (instrument_id_str, details) in &cache.contract_details {
2644 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2645 if details.contract.contract_id != 0 {
2646 self.contract_id_to_instrument_id
2647 .insert(details.contract.contract_id, instrument_id);
2648 }
2649
2650 let mut details = details.clone();
2651 split_last_trade_date(&mut details);
2652 self.contracts
2653 .insert(instrument_id, details.contract.clone());
2654 self.contract_details.insert(instrument_id, details);
2655 }
2656 }
2657
2658 for (contract_id, instrument_id_str) in &cache.contract_id_to_instrument_id {
2660 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2661 self.contract_id_to_instrument_id
2662 .insert(*contract_id, instrument_id);
2663 }
2664 }
2665
2666 for (instrument_id_str, magnifier) in &cache.price_magnifiers {
2668 if let Ok(instrument_id) = InstrumentId::from_str(instrument_id_str) {
2669 self.price_magnifiers.insert(instrument_id, *magnifier);
2670 }
2671 }
2672
2673 tracing::debug!(
2674 "Loaded instrument cache from {} ({} instruments, created at {})",
2675 cache_path,
2676 loaded_count,
2677 cache.cache_timestamp
2678 );
2679 Ok(true)
2680 }
2681}
2682
2683#[cfg(test)]
2684mod tests {
2685 use std::{
2686 fs,
2687 sync::atomic::{AtomicBool, AtomicUsize, Ordering},
2688 };
2689
2690 use indexmap::IndexMap;
2691 use nautilus_common::config::ConfigError;
2692 use nautilus_core::{Params, UnixNanos};
2693 use nautilus_model::{
2694 identifiers::{Symbol, Venue},
2695 instruments::CurrencyPair,
2696 types::{Price, Quantity, currency::Currency},
2697 };
2698 use rstest::rstest;
2699 use tempfile::TempDir;
2700
2701 use super::*;
2702 use crate::common::contract_to_json_value;
2703
2704 struct TestStartupLoader {
2705 id_calls: AtomicUsize,
2706 contract_calls: AtomicUsize,
2707 fail_next_id: AtomicBool,
2708 resolve_ids: AtomicBool,
2709 resolve_contracts: AtomicBool,
2710 yield_on_load: bool,
2711 }
2712
2713 impl TestStartupLoader {
2714 fn new(resolve_ids: bool, resolve_contracts: bool) -> Self {
2715 Self {
2716 id_calls: AtomicUsize::new(0),
2717 contract_calls: AtomicUsize::new(0),
2718 fail_next_id: AtomicBool::new(false),
2719 resolve_ids: AtomicBool::new(resolve_ids),
2720 resolve_contracts: AtomicBool::new(resolve_contracts),
2721 yield_on_load: false,
2722 }
2723 }
2724 }
2725
2726 impl StartupInstrumentLoader for TestStartupLoader {
2727 async fn load_instrument_id(
2728 &self,
2729 instrument_id: InstrumentId,
2730 ) -> anyhow::Result<Option<InstrumentId>> {
2731 self.id_calls.fetch_add(1, Ordering::SeqCst);
2732
2733 if self.yield_on_load {
2734 tokio::task::yield_now().await;
2735 }
2736
2737 if self.fail_next_id.swap(false, Ordering::SeqCst) {
2738 anyhow::bail!("Socket disconnected");
2739 }
2740
2741 Ok(self
2742 .resolve_ids
2743 .load(Ordering::SeqCst)
2744 .then_some(instrument_id))
2745 }
2746
2747 async fn load_contract(
2748 &self,
2749 _configured: &ConfiguredContract,
2750 ) -> anyhow::Result<Vec<InstrumentId>> {
2751 self.contract_calls.fetch_add(1, Ordering::SeqCst);
2752
2753 if self.yield_on_load {
2754 tokio::task::yield_now().await;
2755 }
2756
2757 Ok(if self.resolve_contracts.load(Ordering::SeqCst) {
2758 vec![InstrumentId::new(
2759 Symbol::from("MSFT"),
2760 Venue::from("NASDAQ"),
2761 )]
2762 } else {
2763 Vec::new()
2764 })
2765 }
2766 }
2767
2768 fn create_test_provider_with_cache() -> (InteractiveBrokersInstrumentProvider, TempDir) {
2769 let temp_dir = TempDir::new().unwrap();
2770 let cache_path = temp_dir
2771 .path()
2772 .join("test_cache.json")
2773 .to_str()
2774 .unwrap()
2775 .to_string();
2776
2777 let config = InteractiveBrokersInstrumentProviderConfig::builder()
2778 .cache_path(cache_path)
2779 .cache_validity_days(7u32)
2780 .build();
2781
2782 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
2783 (provider, temp_dir)
2784 }
2785
2786 fn opra_option_contract_details(mut contract: Contract) -> ibapi::contracts::ContractDetails {
2787 contract.contract_id = 12_345;
2788 contract.symbol = ibapi::contracts::Symbol::from("AAPL");
2789 contract.security_type = SecurityType::Option;
2790 contract.exchange = Exchange::from("SMART");
2791 contract.currency = ibapi::contracts::Currency::from("USD");
2792 contract.local_symbol = "AAPL 270115P00155000".to_string();
2793 contract.last_trade_date_or_contract_month = "20270115".to_string();
2794 contract.strike = 155.0;
2795 contract.right = Some(ibapi::contracts::OptionRight::Put);
2796 contract.multiplier = "100".to_string();
2797
2798 ibapi::contracts::ContractDetails {
2799 contract,
2800 min_tick: 0.01,
2801 under_symbol: "AAPL".to_string(),
2802 under_security_type: "STK".to_string(),
2803 valid_exchanges: vec!["SMART".to_string(), "CBOE".to_string()],
2804 ..Default::default()
2805 }
2806 }
2807
2808 fn create_test_instrument(instrument_id: InstrumentId) -> InstrumentAny {
2809 create_test_instrument_with_info(instrument_id, None)
2810 }
2811
2812 #[rstest]
2813 fn test_qualified_opra_details_preserve_canonical_instrument_identity() {
2814 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
2815 let requested_id = InstrumentId::from("AAPL 270115P00155000.OPRA");
2816 let request = provider
2817 .resolve_contract_for_instrument(requested_id)
2818 .unwrap();
2819
2820 assert_eq!(request.security_type, SecurityType::Option);
2821 assert_eq!(request.exchange.as_str(), "SMART");
2822 assert!(request.symbol.as_str().is_empty());
2823 assert_eq!(request.currency.as_str(), "USD");
2824 assert_eq!(request.local_symbol, "AAPL 270115P00155000");
2825 assert!(request.last_trade_date_or_contract_month.is_empty());
2826 assert!(request.right.is_none());
2827 assert_eq!(request.strike, 0.0);
2828
2829 let details = opra_option_contract_details(request);
2830 let loaded_id = provider
2831 .process_contract_detail(&details, Some(requested_id.venue), false)
2832 .unwrap()
2833 .unwrap();
2834
2835 assert_eq!(loaded_id, requested_id);
2836 assert_eq!(provider.count(), 1);
2837 assert_eq!(
2838 provider.get_instrument_id_by_contract_id(12_345),
2839 Some(requested_id)
2840 );
2841 assert_eq!(
2842 provider
2843 .resolve_instrument_id_for_contract(&details.contract)
2844 .unwrap(),
2845 requested_id
2846 );
2847
2848 let cached = provider.find(&requested_id).unwrap();
2849
2850 let InstrumentAny::OptionContract(option) = cached else {
2851 panic!("expected option contract");
2852 };
2853
2854 assert_eq!(option.id, requested_id);
2855 assert_eq!(option.id.venue.as_str(), "OPRA");
2856 assert!(
2857 provider
2858 .find(&InstrumentId::from("AAPL 270115P00155000.SMART"))
2859 .is_none()
2860 );
2861
2862 let cached_contract = provider
2863 .instrument_id_to_ib_contract(&requested_id)
2864 .unwrap();
2865 assert_eq!(cached_contract.contract_id, 12_345);
2866 assert_eq!(cached_contract.security_type, SecurityType::Option);
2867 assert_eq!(cached_contract.exchange.as_str(), "SMART");
2868
2869 let resolved_contract = provider
2870 .resolve_contract_for_instrument(requested_id)
2871 .unwrap();
2872 assert_eq!(resolved_contract, cached_contract);
2873
2874 let cached_details = provider
2875 .instrument_id_to_ib_contract_details(&requested_id)
2876 .unwrap();
2877 assert_eq!(cached_details.contract.contract_id, 12_345);
2878 assert_eq!(
2879 cached_details.valid_exchanges,
2880 vec!["SMART".to_string(), "CBOE".to_string()]
2881 );
2882 }
2883
2884 fn create_test_instrument_with_info(
2885 instrument_id: InstrumentId,
2886 info: Option<Params>,
2887 ) -> InstrumentAny {
2888 CurrencyPair::builder()
2889 .instrument_id(instrument_id)
2890 .raw_symbol(Symbol::from("EUR/USD"))
2891 .base_currency(Currency::from("EUR"))
2892 .quote_currency(Currency::from("USD"))
2893 .price_precision(4)
2894 .size_precision(0)
2895 .price_increment(Price::from("0.0001"))
2896 .size_increment(Quantity::from(1))
2897 .maybe_info(info)
2898 .ts_event(UnixNanos::default())
2899 .ts_init(UnixNanos::default())
2900 .build()
2901 .unwrap()
2902 .into()
2903 }
2904
2905 fn create_contract_info(contract: &Contract, price_magnifier: Option<i32>) -> Params {
2906 let mut info =
2907 IndexMap::from([(String::from("contract"), contract_to_json_value(contract))]);
2908
2909 if let Some(price_magnifier) = price_magnifier {
2910 info.insert(
2911 String::from("priceMagnifier"),
2912 serde_json::Value::from(price_magnifier),
2913 );
2914 }
2915
2916 Params::new(info)
2917 }
2918
2919 #[tokio::test]
2920 async fn test_initialize_loads_all_configured_inputs_once() {
2921 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2922
2923 let contract_spec = Contract {
2924 security_type: SecurityType::Stock,
2925 symbol: ibapi::contracts::Symbol::from("MSFT"),
2926 exchange: Exchange::from("NASDAQ"),
2927 ..Default::default()
2928 };
2929
2930 let config = InteractiveBrokersInstrumentProviderConfig {
2931 load_ids: [instrument_id].into_iter().collect(),
2932 load_contracts: vec![ConfiguredContract::from(contract_spec)],
2933 ..Default::default()
2934 };
2935
2936 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
2937 let loader = TestStartupLoader::new(true, true);
2938
2939 let loaded_ids = provider.initialize_with_loader(&loader).await.unwrap();
2940 let second_result = provider.initialize_with_loader(&loader).await.unwrap();
2941
2942 assert_eq!(
2943 loaded_ids,
2944 vec![
2945 instrument_id,
2946 InstrumentId::new(Symbol::from("MSFT"), Venue::from("NASDAQ")),
2947 ]
2948 );
2949 assert!(second_result.is_empty());
2950 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
2951 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 1);
2952 assert!(*provider.startup_initialized.lock().await);
2953 }
2954
2955 #[tokio::test]
2956 async fn test_initialize_fails_closed_and_retries_unresolved_input() {
2957 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2958
2959 let contract_spec = Contract {
2960 security_type: SecurityType::Stock,
2961 symbol: ibapi::contracts::Symbol::from("MSFT"),
2962 exchange: Exchange::from("NASDAQ"),
2963 ..Default::default()
2964 };
2965
2966 let config = InteractiveBrokersInstrumentProviderConfig {
2967 load_ids: [instrument_id].into_iter().collect(),
2968 load_contracts: vec![ConfiguredContract::from(contract_spec)],
2969 ..Default::default()
2970 };
2971
2972 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
2973 let loader = TestStartupLoader::new(true, false);
2974
2975 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
2976
2977 assert!(error.to_string().contains("contract at index 0"));
2978 assert!(!*provider.startup_initialized.lock().await);
2979
2980 loader.resolve_contracts.store(true, Ordering::SeqCst);
2981 provider.initialize_with_loader(&loader).await.unwrap();
2982
2983 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
2984 assert_eq!(loader.contract_calls.load(Ordering::SeqCst), 2);
2985 assert!(*provider.startup_initialized.lock().await);
2986 }
2987
2988 #[tokio::test]
2989 async fn test_initialize_preserves_load_error_and_allows_retry() {
2990 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
2991
2992 let config = InteractiveBrokersInstrumentProviderConfig {
2993 load_ids: [instrument_id].into_iter().collect(),
2994 ..Default::default()
2995 };
2996
2997 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
2998 let loader = TestStartupLoader::new(true, true);
2999 loader.fail_next_id.store(true, Ordering::SeqCst);
3000
3001 let error = provider.initialize_with_loader(&loader).await.unwrap_err();
3002
3003 let error_chain = format!("{error:#}");
3004 assert!(error_chain.contains("Failed to load configured IB instrument ID AAPL.NASDAQ"));
3005 assert!(error_chain.contains("Socket disconnected"));
3006 assert!(!*provider.startup_initialized.lock().await);
3007
3008 provider.initialize_with_loader(&loader).await.unwrap();
3009
3010 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 2);
3011 assert!(*provider.startup_initialized.lock().await);
3012 }
3013
3014 #[tokio::test]
3015 async fn test_initialize_serializes_concurrent_calls() {
3016 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"));
3017
3018 let config = InteractiveBrokersInstrumentProviderConfig {
3019 load_ids: [instrument_id].into_iter().collect(),
3020 ..Default::default()
3021 };
3022
3023 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
3024 let mut loader = TestStartupLoader::new(true, true);
3025 loader.yield_on_load = true;
3026
3027 let (first, second) = tokio::join!(
3028 provider.initialize_with_loader(&loader),
3029 provider.initialize_with_loader(&loader),
3030 );
3031
3032 assert_eq!(first.unwrap().len() + second.unwrap().len(), 1);
3033 assert_eq!(loader.id_calls.load(Ordering::SeqCst), 1);
3034 assert!(*provider.startup_initialized.lock().await);
3035 }
3036
3037 #[tokio::test]
3038 async fn test_save_cache() {
3039 let (provider, _temp_dir) = create_test_provider_with_cache();
3040 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3041
3042 let instrument_id1 = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
3044 let instrument_id2 = InstrumentId::new(Symbol::from("GBP/USD"), Venue::from("IDEALPRO"));
3045
3046 let instrument1 = create_test_instrument(instrument_id1);
3047 let instrument2 = create_test_instrument(instrument_id2);
3048
3049 provider.instruments.insert(instrument_id1, instrument1);
3050 provider.instruments.insert(instrument_id2, instrument2);
3051 provider
3052 .contract_id_to_instrument_id
3053 .insert(100, instrument_id1);
3054 provider
3055 .contract_id_to_instrument_id
3056 .insert(200, instrument_id2);
3057 provider.price_magnifiers.insert(instrument_id1, 1);
3058 provider.price_magnifiers.insert(instrument_id2, 1);
3059
3060 let result = provider.save_cache(&cache_path).await;
3062 assert!(result.is_ok(), "save_cache should succeed");
3063
3064 assert!(Path::new(&cache_path).exists(), "Cache file should exist");
3066
3067 let contents = fs::read_to_string(&cache_path).unwrap();
3069 assert!(
3070 contents.contains("EUR/USD"),
3071 "Cache should contain instrument data"
3072 );
3073 assert!(
3074 contents.contains("cache_timestamp"),
3075 "Cache should contain timestamp"
3076 );
3077 }
3078
3079 #[tokio::test]
3080 async fn test_load_cache_valid() {
3081 let (provider, _temp_dir) = create_test_provider_with_cache();
3082 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3083
3084 let instrument_id = InstrumentId::new(Symbol::from("EUR/USD"), Venue::from("IDEALPRO"));
3086 let instrument = create_test_instrument(instrument_id);
3087
3088 provider
3089 .instruments
3090 .insert(instrument_id, instrument.clone());
3091 provider
3092 .contract_id_to_instrument_id
3093 .insert(100, instrument_id);
3094 provider.price_magnifiers.insert(instrument_id, 1);
3095
3096 provider.save_cache(&cache_path).await.unwrap();
3097
3098 let new_config = InteractiveBrokersInstrumentProviderConfig::builder()
3100 .cache_path(cache_path.clone())
3101 .cache_validity_days(7u32)
3102 .build();
3103
3104 let new_provider = InteractiveBrokersInstrumentProvider::new(new_config).unwrap();
3105
3106 let result = new_provider.load_cache(&cache_path).await;
3107 assert!(result.is_ok(), "load_cache should succeed");
3108 assert!(
3109 result.unwrap(),
3110 "load_cache should return true for valid cache"
3111 );
3112
3113 assert!(
3115 new_provider.find(&instrument_id).is_some(),
3116 "Instrument should be loaded from cache"
3117 );
3118 assert_eq!(new_provider.count(), 1, "Provider should have 1 instrument");
3119 }
3120
3121 #[tokio::test]
3122 async fn test_load_cache_reads_chrono_timestamp() {
3123 let provider = InteractiveBrokersInstrumentProvider::new(
3124 InteractiveBrokersInstrumentProviderConfig::builder()
3125 .cache_validity_days(7u32)
3126 .build(),
3127 )
3128 .unwrap();
3129 let cache_path = concat!(
3130 env!("CARGO_MANIFEST_DIR"),
3131 "/test_data/instrument_cache_chrono.json"
3132 );
3133
3134 let loaded = provider.load_cache(cache_path).await.unwrap();
3135
3136 assert!(loaded);
3137 assert_eq!(provider.count(), 0);
3138 }
3139
3140 #[tokio::test]
3141 async fn test_load_cache_restores_contract_details() {
3142 let (provider, _temp_dir) = create_test_provider_with_cache();
3143 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3144 let instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3145 let instrument = create_test_instrument(instrument_id);
3146
3147 let contract = Contract {
3148 contract_id: 265598,
3149 symbol: ibapi::contracts::Symbol::from("AAPL"),
3150 security_type: SecurityType::Stock,
3151 exchange: Exchange::from("SMART"),
3152 primary_exchange: Exchange::from("NASDAQ"),
3153 currency: ibapi::contracts::Currency::from("USD"),
3154 ..Default::default()
3155 };
3156
3157 let details = ibapi::contracts::ContractDetails {
3158 contract: contract.clone(),
3159 price_magnifier: 1,
3160 ..Default::default()
3161 };
3162
3163 provider.cache_instrument(
3164 instrument_id,
3165 instrument,
3166 Some(details),
3167 Some(contract),
3168 Some(1),
3169 false,
3170 );
3171 provider.save_cache(&cache_path).await.unwrap();
3172
3173 let new_provider =
3174 InteractiveBrokersInstrumentProvider::new(provider.config.clone()).unwrap();
3175
3176 assert!(new_provider.load_cache(&cache_path).await.unwrap());
3177 assert_eq!(
3178 new_provider
3179 .resolve_contract_for_instrument(instrument_id)
3180 .unwrap()
3181 .contract_id,
3182 265598
3183 );
3184 assert_eq!(
3185 new_provider
3186 .instrument_id_to_ib_contract_details(&instrument_id)
3187 .unwrap()
3188 .contract
3189 .contract_id,
3190 265598
3191 );
3192 }
3193
3194 #[rstest]
3195 fn test_filter_sec_types_uses_typed_ib_codes() {
3196 let config = InteractiveBrokersInstrumentProviderConfig {
3197 filter_sec_types: [IbSecurityType::Option].into_iter().collect(),
3198 ..Default::default()
3199 };
3200
3201 let provider = InteractiveBrokersInstrumentProvider::new(config).unwrap();
3202
3203 assert!(provider.is_filtered_sec_type(&SecurityType::Option.to_string()));
3204 assert!(!provider.is_filtered_sec_type(&SecurityType::Stock.to_string()));
3205 }
3206
3207 #[rstest]
3208 fn test_add_cached_instruments_only_seeds_ib_contracts() {
3209 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3210 let ib_instrument_id = InstrumentId::new(Symbol::from("AAPL"), Venue::from("XNAS"));
3211 let non_ib_instrument_id =
3212 InstrumentId::new(Symbol::from("BTCUSDT"), Venue::from("BINANCE"));
3213
3214 let contract = Contract {
3215 contract_id: 265598,
3216 symbol: ibapi::contracts::Symbol::from("AAPL"),
3217 security_type: SecurityType::Stock,
3218 exchange: Exchange::from("SMART"),
3219 primary_exchange: Exchange::from("NASDAQ"),
3220 currency: ibapi::contracts::Currency::from("USD"),
3221 ..Default::default()
3222 };
3223
3224 let ib_instrument = create_test_instrument_with_info(
3225 ib_instrument_id,
3226 Some(create_contract_info(&contract, Some(100))),
3227 );
3228 let non_ib_instrument = create_test_instrument(non_ib_instrument_id);
3229
3230 let count = provider.add_cached_instruments([ib_instrument, non_ib_instrument]);
3231
3232 assert_eq!(count, 1);
3233 assert_eq!(provider.count(), 1);
3234 assert!(provider.find(&ib_instrument_id).is_some());
3235 assert!(provider.find(&non_ib_instrument_id).is_none());
3236 assert_eq!(
3237 provider
3238 .resolve_contract_for_instrument(ib_instrument_id)
3239 .unwrap()
3240 .contract_id,
3241 265598
3242 );
3243 assert_eq!(provider.get_price_magnifier(&ib_instrument_id), 100);
3244 }
3245
3246 fn create_future_contract_with_expiry_time() -> Contract {
3247 Contract {
3248 contract_id: 515416632,
3249 symbol: ibapi::contracts::Symbol::from("ES"),
3250 security_type: SecurityType::Future,
3251 exchange: Exchange::from("CME"),
3252 currency: ibapi::contracts::Currency::from("USD"),
3253 local_symbol: String::from("ESZ6"),
3254 last_trade_date_or_contract_month: String::from("20261218 08:30:00 US/Central"),
3255 ..Default::default()
3256 }
3257 }
3258
3259 #[rstest]
3260 fn test_add_cached_instruments_cuts_stored_expiry_to_date() {
3261 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3262 let instrument_id = InstrumentId::new(Symbol::from("ESZ6"), Venue::from("CME"));
3263 let contract = create_future_contract_with_expiry_time();
3264 let instrument = create_test_instrument_with_info(
3265 instrument_id,
3266 Some(create_contract_info(&contract, Some(1))),
3267 );
3268
3269 assert_eq!(provider.add_cached_instruments([instrument]), 1);
3270 assert_eq!(
3271 provider
3272 .resolve_contract_for_instrument(instrument_id)
3273 .unwrap()
3274 .last_trade_date_or_contract_month,
3275 "20261218"
3276 );
3277 }
3278
3279 #[rstest]
3280 #[case::instrument_info(false, false)]
3281 #[case::contract(true, false)]
3282 #[case::contract_details(true, true)]
3283 #[tokio::test]
3284 async fn test_load_cache_cuts_cached_expiry_to_date(
3285 #[case] with_contract: bool,
3286 #[case] with_details: bool,
3287 ) {
3288 let (provider, _temp_dir) = create_test_provider_with_cache();
3289 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3290 let instrument_id = InstrumentId::new(Symbol::from("ESZ6"), Venue::from("CME"));
3291 let contract = create_future_contract_with_expiry_time();
3292 let instrument = create_test_instrument_with_info(
3293 instrument_id,
3294 Some(create_contract_info(&contract, Some(1))),
3295 );
3296 let details = ibapi::contracts::ContractDetails {
3297 contract: contract.clone(),
3298 price_magnifier: 1,
3299 time_zone_id: String::from("US/Central"),
3300 ..Default::default()
3301 };
3302
3303 provider.cache_instrument(
3304 instrument_id,
3305 instrument,
3306 with_details.then_some(details),
3307 with_contract.then_some(contract),
3308 Some(1),
3309 false,
3310 );
3311 provider.save_cache(&cache_path).await.unwrap();
3312
3313 let new_provider =
3314 InteractiveBrokersInstrumentProvider::new(provider.config.clone()).unwrap();
3315
3316 assert!(new_provider.load_cache(&cache_path).await.unwrap());
3317 assert_eq!(
3318 new_provider
3319 .resolve_contract_for_instrument(instrument_id)
3320 .unwrap()
3321 .last_trade_date_or_contract_month,
3322 "20261218"
3323 );
3324
3325 if with_details {
3326 let details = new_provider
3327 .instrument_id_to_ib_contract_details(&instrument_id)
3328 .unwrap();
3329 assert_eq!(
3330 details.contract.last_trade_date_or_contract_month,
3331 "20261218"
3332 );
3333 assert_eq!(details.last_trade_time, "08:30:00 US/Central");
3334 }
3335 }
3336
3337 #[tokio::test]
3338 async fn test_load_cache_missing_file() {
3339 let (provider, _temp_dir) = create_test_provider_with_cache();
3340 let cache_path = "/nonexistent/path/cache.json";
3341
3342 let result = provider.load_cache(cache_path).await;
3343 assert!(
3344 result.is_ok(),
3345 "load_cache should not error on missing file"
3346 );
3347 assert!(
3348 !result.unwrap(),
3349 "load_cache should return false for missing file"
3350 );
3351 }
3352
3353 #[tokio::test]
3354 async fn test_load_cache_expired() {
3355 let (provider, _temp_dir) = create_test_provider_with_cache();
3356 let cache_path = provider.config.cache_path.as_ref().unwrap().clone();
3357
3358 let old_timestamp = Timestamp::now() - jiff::SignedDuration::from_hours(24 * (10));
3360
3361 let expired_cache = InstrumentCache {
3362 cache_timestamp: old_timestamp,
3363 contract_id_to_instrument_id: vec![],
3364 price_magnifiers: vec![],
3365 contracts: vec![],
3366 contract_details: vec![],
3367 instruments: vec![],
3368 };
3369
3370 let json = serde_json::to_string_pretty(&expired_cache).unwrap();
3371 fs::write(&cache_path, json).unwrap();
3372
3373 let result = provider.load_cache(&cache_path).await;
3375 assert!(
3376 result.is_ok(),
3377 "load_cache should not error on expired cache"
3378 );
3379 assert!(
3380 !result.unwrap(),
3381 "load_cache should return false for expired cache"
3382 );
3383 }
3384 #[rstest]
3385 fn restored_cache_seeds_provider_with_price_magnifier() {
3386 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3387 let mut cache = Cache::default();
3388 assert_eq!(provider.seed_from_cache(&cache), 0);
3389 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
3390
3391 let contract = Contract {
3392 contract_id: 265598,
3393 symbol: ibapi::contracts::Symbol::from("AAPL"),
3394 security_type: SecurityType::Stock,
3395 exchange: Exchange::from("SMART"),
3396 currency: ibapi::contracts::Currency::from("USD"),
3397 ..Default::default()
3398 };
3399
3400 let instrument = create_test_instrument_with_info(
3401 instrument_id,
3402 Some(create_contract_info(&contract, Some(100))),
3403 );
3404 cache.add_instrument(instrument.clone()).unwrap();
3405 let seeded = provider.seed_from_cache(&cache);
3406 assert_eq!(seeded, 1);
3407 assert_eq!(provider.find(&instrument_id), Some(instrument));
3408 assert_eq!(provider.get_price_magnifier(&instrument_id), 100);
3409 assert_eq!(
3410 provider
3411 .resolve_contract_for_instrument(instrument_id)
3412 .unwrap(),
3413 contract
3414 );
3415 }
3416
3417 #[rstest]
3418 #[case(None)]
3419 #[case(Some(-1))]
3420 fn cached_instrument_without_valid_magnifier_requires_qualification(
3421 #[case] magnifier: Option<i32>,
3422 ) {
3423 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3424 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
3425
3426 let contract = Contract {
3427 contract_id: 265598,
3428 ..Default::default()
3429 };
3430
3431 let instrument = create_test_instrument_with_info(
3432 instrument_id,
3433 Some(create_contract_info(&contract, magnifier)),
3434 );
3435 assert_eq!(provider.add_cached_instruments([instrument]), 0);
3436 assert_eq!(provider.find(&instrument_id), None);
3437 }
3438
3439 #[rstest]
3440 fn price_magnifier_is_none_until_contract_metadata_is_loaded() {
3441 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3442 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3443 provider
3444 .instruments
3445 .insert(instrument_id, create_test_instrument(instrument_id));
3446
3447 assert_eq!(provider.price_magnifier(&instrument_id), None);
3448 assert_eq!(provider.get_price_magnifier(&instrument_id), 1);
3449
3450 let details = ibapi::contracts::ContractDetails {
3451 price_magnifier: 100,
3452 ..Default::default()
3453 };
3454 provider.contract_details.insert(instrument_id, details);
3455
3456 assert_eq!(provider.price_magnifier(&instrument_id), Some(100));
3457 assert_eq!(provider.get_price_magnifier(&instrument_id), 100);
3458 }
3459
3460 #[rstest]
3461 fn instrument_cached_after_seeding_supplies_its_price_magnifier() {
3462 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3463 let mut cache = Cache::default();
3464 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3465 assert_eq!(provider.seed_from_cache(&cache), 0);
3466 assert_eq!(
3467 provider.seed_price_magnifier_from_cache(&cache, &instrument_id),
3468 None
3469 );
3470
3471 let contract = Contract {
3472 contract_id: 815824252,
3473 symbol: ibapi::contracts::Symbol::from("ZC"),
3474 security_type: SecurityType::Future,
3475 exchange: Exchange::from("CBOT"),
3476 currency: ibapi::contracts::Currency::from("USD"),
3477 ..Default::default()
3478 };
3479 let instrument = create_test_instrument_with_info(
3480 instrument_id,
3481 Some(create_contract_info(&contract, Some(100))),
3482 );
3483 cache.add_instrument(instrument).unwrap();
3484
3485 assert_eq!(
3486 provider.seed_price_magnifier_from_cache(&cache, &instrument_id),
3487 Some(100)
3488 );
3489 assert_eq!(
3490 provider
3491 .resolve_contract_for_instrument(instrument_id)
3492 .unwrap(),
3493 contract
3494 );
3495 }
3496
3497 #[tokio::test]
3498 async fn ensure_price_magnifier_skips_the_request_when_the_magnifier_is_known() {
3499 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3500 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3501 provider.price_magnifiers.insert(instrument_id, 100);
3502 let requests = AtomicUsize::new(0);
3503
3504 let magnifier = provider
3505 .ensure_price_magnifier_with(instrument_id, Duration::from_secs(5), || async {
3506 requests.fetch_add(1, Ordering::SeqCst);
3507 Ok(())
3508 })
3509 .await
3510 .unwrap();
3511
3512 assert_eq!(magnifier, 100);
3513 assert_eq!(requests.load(Ordering::SeqCst), 0);
3514 }
3515
3516 #[tokio::test]
3517 async fn ensure_price_magnifier_takes_it_from_requested_contract_details() {
3518 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3519 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3520
3521 let magnifier = provider
3522 .ensure_price_magnifier_with(instrument_id, Duration::from_secs(5), || async {
3523 let details = ibapi::contracts::ContractDetails {
3524 price_magnifier: 100,
3525 ..Default::default()
3526 };
3527 provider.contract_details.insert(instrument_id, details);
3528 Ok(())
3529 })
3530 .await
3531 .unwrap();
3532
3533 assert_eq!(magnifier, 100);
3534 }
3535
3536 #[rstest]
3537 #[case::no_details(false, "IB price magnifier for ZCZ6.CBOT is unknown")]
3538 #[case::request_failed(true, "Failed to load IB contract details for ZCZ6.CBOT")]
3539 #[tokio::test]
3540 async fn ensure_price_magnifier_fails_when_ib_supplies_none(
3541 #[case] request_fails: bool,
3542 #[case] expected: &str,
3543 ) {
3544 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3545 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3546
3547 let error = provider
3548 .ensure_price_magnifier_with(instrument_id, Duration::from_secs(5), || async {
3549 if request_fails {
3550 anyhow::bail!("Socket disconnected");
3551 }
3552 Ok(())
3553 })
3554 .await
3555 .unwrap_err();
3556
3557 assert!(format!("{error:#}").starts_with(expected), "{error:#}");
3558 assert_eq!(provider.price_magnifier(&instrument_id), None);
3559 }
3560
3561 #[rstest]
3562 #[tokio::test(start_paused = true)]
3563 async fn ensure_price_magnifier_times_out_when_the_request_never_completes() {
3564 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
3565 let instrument_id = InstrumentId::from("ZCZ6.CBOT");
3566 let timeout = Duration::from_secs(5);
3567 let start = tokio::time::Instant::now();
3568
3569 let error = provider
3570 .ensure_price_magnifier_with(instrument_id, timeout, std::future::pending)
3571 .await
3572 .unwrap_err();
3573
3574 assert_eq!(
3575 format!("{error:#}"),
3576 "Timed out after 5s loading IB contract details for ZCZ6.CBOT"
3577 );
3578 assert_eq!(tokio::time::Instant::now() - start, timeout);
3579 assert_eq!(provider.price_magnifier(&instrument_id), None);
3580 }
3581
3582 #[rstest]
3583 fn test_provider_rejects_duplicate_contracts_before_loading() {
3584 let config = serde_json::from_value(serde_json::json!({
3585 "load_contracts": [{"conId": 265598}, {"conId": 265598}]
3586 }))
3587 .unwrap();
3588 let error = InteractiveBrokersInstrumentProvider::new(config).unwrap_err();
3589
3590 assert_eq!(error, ConfigError::duplicate("load_contracts", None));
3591 }
3592}