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