Skip to main content

nautilus_interactive_brokers/providers/
instruments.rs

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