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