Skip to main content

nautilus_interactive_brokers/providers/
instruments.rs

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