Skip to main content

nautilus_interactive_brokers/historical/
client.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//! Historical data client for Interactive Brokers.
17
18use std::{fmt::Debug, str::FromStr, sync::Arc};
19
20use anyhow::Context;
21use ibapi::{
22    client::Client,
23    contracts::{Contract, SecurityType},
24    market_data::{IgnoreSize, TradingHours, historical},
25    prelude::{StreamExt, SubscriptionItemStreamExt},
26};
27use jiff::Timestamp;
28use nautilus_core::UnixNanos;
29use nautilus_model::{
30    data::{Bar, BarSpecification, BarType, Data, QuoteTick},
31    enums::{AggregationSource, BarAggregation, PriceType},
32    identifiers::InstrumentId,
33    instruments::{Instrument, any::InstrumentAny},
34    types::Quantity,
35};
36
37use crate::{
38    common::{
39        enums::IbHistoricalTickType,
40        shared_client::{self, SharedClientHandle},
41        symbology::is_crypto_contract,
42    },
43    config::{InteractiveBrokersDataClientConfig, MarketDataType},
44    data::{
45        convert::{
46            IbPriceConverter, bar_request_segments, bar_type_to_ib_bar_size,
47            calculate_duration_segments, extend_historical_tick_batch, ib_bar_to_nautilus_bar,
48            ib_timestamp_to_unix_nanos, jiff_to_ib_datetime,
49            price_type_to_ib_what_to_show_for_security, retain_historical_ticks_in_range,
50        },
51        parse::parse_trade_tick,
52    },
53    providers::instruments::InteractiveBrokersInstrumentProvider,
54};
55
56const HISTORICAL_TICK_DEFAULT_LIMIT: usize = 10_000;
57
58/// Historical data client for Interactive Brokers.
59///
60/// This client provides methods for requesting historical bars and ticks
61/// for backtesting and research purposes.
62#[cfg_attr(
63    feature = "python",
64    pyo3::pyclass(
65        module = "nautilus_trader.adapters.interactive_brokers",
66        subclass,
67        from_py_object
68    )
69)]
70#[cfg_attr(
71    feature = "python",
72    pyo3_stub_gen::derive::gen_stub_pyclass(
73        module = "nautilus_trader.adapters.interactive_brokers"
74    )
75)]
76pub struct HistoricalInteractiveBrokersClient {
77    /// IB API client.
78    ib_client: Arc<Client>,
79    /// Instrument provider.
80    instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
81    /// Shared client handle, when this client owns the connection lifecycle.
82    _shared_client: Option<Arc<SharedClientHandle>>,
83}
84
85impl Clone for HistoricalInteractiveBrokersClient {
86    fn clone(&self) -> Self {
87        Self {
88            ib_client: Arc::clone(&self.ib_client),
89            instrument_provider: Arc::clone(&self.instrument_provider),
90            _shared_client: self._shared_client.clone(),
91        }
92    }
93}
94
95impl Debug for HistoricalInteractiveBrokersClient {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct(stringify!(HistoricalInteractiveBrokersClient))
98            .field("ib_client", &"<Client>")
99            .field("instrument_provider", &"<InstrumentProvider>")
100            .finish()
101    }
102}
103
104impl HistoricalInteractiveBrokersClient {
105    /// Create a new historical data client.
106    pub fn new(
107        ib_client: Arc<Client>,
108        instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
109    ) -> Self {
110        Self {
111            ib_client,
112            instrument_provider,
113            _shared_client: None,
114        }
115    }
116
117    /// Connect to Interactive Brokers and create a historical data client.
118    ///
119    /// This initializes an instrument provider from `config.instrument_provider` and acquires the
120    /// shared IB client for the configured host, port, and client ID.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if configuration validation, provider initialization, or the IB connection fails.
125    pub async fn connect(config: InteractiveBrokersDataClientConfig) -> anyhow::Result<Self> {
126        config.validate()?;
127
128        let instrument_provider = Arc::new(InteractiveBrokersInstrumentProvider::new(
129            config.instrument_provider.clone(),
130        )?);
131        let shared_client = shared_client::get_or_connect(
132            &config.host,
133            config.port,
134            config.client_id,
135            config.connection_timeout,
136            config.max_reconnect_attempts,
137        )
138        .await?;
139        let client = shared_client.as_arc();
140
141        if config.market_data_type != MarketDataType::Realtime {
142            let market_data_type: ibapi::market_data::MarketDataType =
143                config.market_data_type.into();
144            client.switch_market_data_type(market_data_type).await?;
145        }
146
147        instrument_provider
148            .initialize_with_client(client.as_ref())
149            .await?;
150
151        Ok(Self::from_shared_client(shared_client, instrument_provider))
152    }
153
154    pub(crate) fn from_shared_client(
155        shared_client: SharedClientHandle,
156        instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
157    ) -> Self {
158        let ib_client = Arc::clone(shared_client.as_arc());
159
160        Self {
161            ib_client,
162            instrument_provider,
163            _shared_client: Some(Arc::new(shared_client)),
164        }
165    }
166
167    /// Connect a historical data client with a supplied provider using the shared IB client registry.
168    ///
169    /// This keeps standalone Rust callers from needing to acquire `common::shared_client`
170    /// directly before requesting instruments or historical data.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if configuration validation fails, the shared client cannot connect, or
175    /// the instrument provider cannot initialize.
176    pub async fn connect_with_provider(
177        instrument_provider: InteractiveBrokersInstrumentProvider,
178        config: InteractiveBrokersDataClientConfig,
179    ) -> anyhow::Result<Self> {
180        config.validate()?;
181
182        let shared_client = shared_client::get_or_connect(
183            &config.host,
184            config.port,
185            config.client_id,
186            config.connection_timeout,
187            config.max_reconnect_attempts,
188        )
189        .await?;
190        let client = shared_client.as_arc();
191
192        if config.market_data_type != MarketDataType::Realtime {
193            let market_data_type: ibapi::market_data::MarketDataType =
194                config.market_data_type.into();
195            client.switch_market_data_type(market_data_type).await?;
196        }
197
198        instrument_provider
199            .initialize_with_client(client.as_ref())
200            .await?;
201
202        Ok(Self::from_shared_client(
203            shared_client,
204            Arc::new(instrument_provider),
205        ))
206    }
207
208    /// Request historical bars.
209    ///
210    /// # Continuous futures
211    ///
212    /// Continuous futures (`CONTFUT`) reject an explicit end date/time with IB
213    /// error 10339. For these contracts the end date is dropped and only the
214    /// first duration segment is requested, anchored to the current time, so
215    /// the returned bars may fall outside `[start_date_time, end_date_time]`.
216    /// A warning is logged when the requested end date/time is in the past or
217    /// the range spans more than one duration segment.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the request fails.
222    #[allow(clippy::too_many_arguments)]
223    pub async fn request_bars(
224        &self,
225        bar_specifications: Vec<&str>,
226        end_date_time: Timestamp,
227        start_date_time: Option<Timestamp>,
228        duration: Option<&str>,
229        contracts: Option<Vec<Contract>>,
230        instrument_ids: Option<Vec<InstrumentId>>,
231        use_rth: bool,
232        timeout: u64,
233    ) -> anyhow::Result<Vec<Bar>> {
234        // Validate inputs
235        if start_date_time.is_some() && duration.is_some() {
236            anyhow::bail!("Either start_date_time or duration should be provided, not both");
237        }
238
239        if let Some(start) = start_date_time
240            && start >= end_date_time
241        {
242            anyhow::bail!("Start date must be before end date");
243        }
244
245        let duration = duration
246            .map(str::parse::<historical::Duration>)
247            .transpose()
248            .with_context(|| {
249                format!(
250                    "duration must be in format: 'int S|D|W|M|Y', was '{}'",
251                    duration.unwrap_or_default()
252                )
253            })?;
254
255        let contracts = contracts.unwrap_or_default();
256        let instrument_ids = instrument_ids.unwrap_or_default();
257
258        if contracts.is_empty() && instrument_ids.is_empty() {
259            anyhow::bail!("Either contracts or instrument_ids must be provided");
260        }
261
262        // Convert instrument IDs to contracts using instrument provider
263        let mut all_contracts = contracts;
264
265        for instrument_id in instrument_ids {
266            // Try to find instrument in provider first
267            if self.instrument_provider.find(&instrument_id).is_none() {
268                // Auto-fetch if not cached
269                if let Err(e) = self
270                    .instrument_provider
271                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
272                    .await
273                {
274                    tracing::warn!(
275                        "Failed to auto-fetch contract details for {}: {}",
276                        instrument_id,
277                        e
278                    );
279                }
280            }
281
282            // Try to convert instrument ID to contract
283            if let Ok(contract) = self
284                .instrument_provider
285                .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
286                .await
287            {
288                all_contracts.push(contract);
289            } else {
290                tracing::warn!(
291                    "Failed to convert instrument_id {} to IB contract, skipping",
292                    instrument_id
293                );
294            }
295        }
296
297        // Auto-fetch contracts if not cached (by contract ID)
298        for contract in &all_contracts {
299            if let Some(instrument_id) = self
300                .instrument_provider
301                .get_instrument_id_by_contract_id(contract.contract_id)
302                && self.instrument_provider.find(&instrument_id).is_none()
303                && let Err(e) = self
304                    .instrument_provider
305                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
306                    .await
307            {
308                tracing::warn!(
309                    "Failed to auto-fetch contract details for contract ID {}: {}",
310                    contract.contract_id,
311                    e
312                );
313            }
314        }
315
316        if all_contracts.is_empty() {
317            anyhow::bail!("No valid contracts found after conversion");
318        }
319
320        let trading_hours = if use_rth {
321            TradingHours::Regular
322        } else {
323            TradingHours::Extended
324        };
325
326        let mut all_bars = Vec::new();
327
328        for contract in all_contracts {
329            for bar_spec_str in &bar_specifications {
330                // Parse bar spec (e.g., "1-HOUR-LAST")
331                let parts: Vec<&str> = bar_spec_str.split('-').collect();
332                if parts.len() != 3 {
333                    anyhow::bail!("Invalid bar specification format: {bar_spec_str}");
334                }
335
336                let step = parts[0].parse::<usize>()?;
337                let aggregation = parts[1].to_lowercase();
338                let price_type = parts[2].to_uppercase();
339                let price_type = PriceType::from_str(&price_type)
340                    .with_context(|| format!("Invalid bar price type: {}", parts[2]))?;
341
342                let bar_spec = match aggregation.as_str() {
343                    "second" => BarSpecification::new(step, BarAggregation::Second, price_type),
344                    "minute" => BarSpecification::new(step, BarAggregation::Minute, price_type),
345                    "hour" => BarSpecification::new(step, BarAggregation::Hour, price_type),
346                    "day" => BarSpecification::new(step, BarAggregation::Day, price_type),
347                    "week" => BarSpecification::new(step, BarAggregation::Week, price_type),
348                    _ => anyhow::bail!("Unsupported aggregation: {aggregation}"),
349                };
350
351                let instrument_id = self.resolve_instrument_id(&contract).await?;
352                let bar_type_with_id =
353                    BarType::new(instrument_id, bar_spec, AggregationSource::External);
354
355                // Convert bar type to IB parameters. Crypto trade-price bars must
356                // request AGGTRADES, not TRADES (TWS rejects TRADES for crypto,
357                // error 10299) - same rule as the live data client's historical path.
358                let ib_bar_size = bar_type_to_ib_bar_size(&bar_type_with_id)?;
359                let is_crypto = is_crypto_contract(&contract);
360                let ib_what_to_show =
361                    price_type_to_ib_what_to_show_for_security(bar_spec.price_type, is_crypto);
362
363                // Omit the end date for continuous futures (IB error 10339).
364                let is_continuous_future = contract.security_type == SecurityType::ContinuousFuture;
365                let segments = bar_request_segments(
366                    calculate_duration_segments(start_date_time, Some(end_date_time), duration),
367                    is_continuous_future,
368                );
369
370                for (segment_end, segment_duration) in segments {
371                    tracing::debug!(
372                        "Requesting historical bars ending on {:?} with duration {}",
373                        segment_end,
374                        segment_duration
375                    );
376
377                    let mut request = self
378                        .ib_client
379                        .historical_data(&contract, ib_bar_size)
380                        .duration(segment_duration)
381                        .what_to_show(ib_what_to_show)
382                        .trading_hours(trading_hours);
383
384                    if let Some(end) = segment_end {
385                        request = request.ending(jiff_to_ib_datetime(&end));
386                    }
387
388                    let historical_data = tokio::time::timeout(
389                        std::time::Duration::from_secs(timeout),
390                        request.fetch(),
391                    )
392                    .await
393                    .context(format!(
394                        "Historical data request timed out after {timeout} seconds"
395                    ))??;
396
397                    let instrument =
398                        self.instrument_provider
399                            .find(&instrument_id)
400                            .with_context(|| {
401                                format!("Instrument {instrument_id} is missing from the provider")
402                            })?;
403
404                    let prices = IbPriceConverter::for_instrument(
405                        &instrument,
406                        self.instrument_provider.get_price_magnifier(&instrument_id),
407                    );
408                    let size_precision = instrument.size_precision();
409
410                    // Create new bar_type with correct instrument_id
411                    for ib_bar in &historical_data.bars {
412                        let nautilus_bar = ib_bar_to_nautilus_bar(
413                            ib_bar,
414                            bar_type_with_id,
415                            prices,
416                            size_precision,
417                        )?;
418                        all_bars.push(nautilus_bar);
419                    }
420
421                    tracing::debug!("Retrieved {} bars in batch", historical_data.bars.len());
422                }
423            }
424        }
425
426        // Sort by timestamp
427        all_bars.sort_by_key(|b| b.ts_event);
428
429        Ok(all_bars)
430    }
431
432    /// Request historical ticks with pagination support.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the request fails.
437    #[allow(clippy::too_many_arguments)]
438    pub async fn request_ticks(
439        &self,
440        tick_type: IbHistoricalTickType,
441        start_date_time: Timestamp,
442        end_date_time: Timestamp,
443        contracts: Option<Vec<Contract>>,
444        instrument_ids: Option<Vec<InstrumentId>>,
445        use_rth: bool,
446        timeout: u64,
447        limit: usize,
448    ) -> anyhow::Result<Vec<Data>> {
449        if start_date_time >= end_date_time {
450            anyhow::bail!("Start date must be before end date");
451        }
452
453        let limit = Some(if limit > 0 {
454            limit
455        } else {
456            HISTORICAL_TICK_DEFAULT_LIMIT
457        });
458
459        if end_date_time.duration_since(start_date_time) > jiff::SignedDuration::from_hours(24) {
460            tracing::warn!(
461                "Requesting tick data for more than 1 day may take a long time, particularly for liquid instruments"
462            );
463        }
464
465        let contracts = contracts.unwrap_or_default();
466        let instrument_ids = instrument_ids.unwrap_or_default();
467
468        if contracts.is_empty() && instrument_ids.is_empty() {
469            anyhow::bail!("Either contracts or instrument_ids must be provided");
470        }
471
472        let trading_hours = if use_rth {
473            TradingHours::Regular
474        } else {
475            TradingHours::Extended
476        };
477
478        // Convert instrument IDs to contracts and auto-fetch if not cached
479        let mut all_contracts = contracts;
480
481        for instrument_id in instrument_ids {
482            // Auto-fetch if not cached
483            if self.instrument_provider.find(&instrument_id).is_none()
484                && let Err(e) = self
485                    .instrument_provider
486                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
487                    .await
488            {
489                tracing::warn!(
490                    "Failed to auto-fetch contract details for {}: {}",
491                    instrument_id,
492                    e
493                );
494            }
495
496            if let Ok(contract) = self
497                .instrument_provider
498                .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
499                .await
500            {
501                all_contracts.push(contract);
502            } else {
503                tracing::warn!(
504                    "Failed to convert instrument_id {} to IB contract, skipping",
505                    instrument_id
506                );
507            }
508        }
509
510        // Auto-fetch contracts if not cached
511        for contract in &all_contracts {
512            if let Some(instrument_id) = self
513                .instrument_provider
514                .get_instrument_id_by_contract_id(contract.contract_id)
515                && self.instrument_provider.find(&instrument_id).is_none()
516                && let Err(e) = self
517                    .instrument_provider
518                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
519                    .await
520            {
521                tracing::warn!(
522                    "Failed to auto-fetch contract details for contract ID {}: {}",
523                    contract.contract_id,
524                    e
525                );
526            }
527        }
528
529        if all_contracts.is_empty() {
530            anyhow::bail!("No valid contracts found after conversion");
531        }
532
533        let mut all_ticks = Vec::new();
534
535        for contract in all_contracts {
536            let instrument_id = self.resolve_instrument_id(&contract).await?;
537
538            let instrument = self
539                .instrument_provider
540                .find(&instrument_id)
541                .with_context(|| {
542                    format!("Instrument {instrument_id} is missing from the provider")
543                })?;
544
545            let prices = IbPriceConverter::for_instrument(
546                &instrument,
547                self.instrument_provider.get_price_magnifier(&instrument_id),
548            );
549            let size_precision = instrument.size_precision();
550            let mut contract_ticks = Vec::new();
551
552            // Pagination loop for ticks (similar to Python _handle_timestamp_iteration)
553            let mut current_end_date = Some(end_date_time);
554            let current_start_date = Some(start_date_time);
555            let start_date_time_ns = UnixNanos::from(
556                u64::try_from(start_date_time.as_nanosecond())
557                    .context("Historical tick start date precedes the Unix epoch")?,
558            );
559            let end_date_time_ns = UnixNanos::from(
560                u64::try_from(end_date_time.as_nanosecond())
561                    .context("Historical tick end date precedes the Unix epoch")?,
562            );
563
564            match tick_type {
565                IbHistoricalTickType::Trades => {
566                    while let Some(request_end) = current_end_date {
567                        // Make request for this batch
568                        let subscription = tokio::time::timeout(
569                            std::time::Duration::from_secs(timeout),
570                            self.ib_client
571                                .historical_ticks(&contract, 1000)
572                                .ending(jiff_to_ib_datetime(&request_end))
573                                .trading_hours(trading_hours)
574                                .trade(),
575                        )
576                        .await
577                        .context(format!(
578                            "Historical trades request timed out after {timeout} seconds"
579                        ))??;
580
581                        let mut subscription = subscription.filter_data();
582                        let mut batch_ticks = Vec::new();
583
584                        while let Some(tick_result) = subscription.next().await {
585                            let tick = match tick_result {
586                                Ok(tick) => tick,
587                                Err(e) => {
588                                    tracing::warn!("Historical trade ticks stream error: {e:?}");
589                                    continue;
590                                }
591                            };
592
593                            let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
594
595                            if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
596                                continue;
597                            }
598
599                            let ts_init = ts_event;
600
601                            let Some(raw_size) = tick.size else {
602                                tracing::warn!(
603                                    "Skipping historical trade tick with no size for {}",
604                                    instrument_id
605                                );
606                                continue;
607                            };
608
609                            if raw_size == 0.0 {
610                                tracing::warn!(
611                                    "Skipping historical trade tick with zero size for {instrument_id}"
612                                );
613                                continue;
614                            }
615
616                            let trade_tick = parse_trade_tick(
617                                instrument_id,
618                                tick.price,
619                                raw_size,
620                                prices,
621                                size_precision,
622                                ts_event,
623                                ts_init,
624                                None,
625                            )
626                            .with_context(|| {
627                                format!("Invalid historical trade tick for {instrument_id}")
628                            })?;
629
630                            batch_ticks.push(Data::Trade(trade_tick));
631                        }
632
633                        if !extend_historical_tick_batch(
634                            &mut contract_ticks,
635                            batch_ticks,
636                            current_start_date,
637                            &mut current_end_date,
638                            Some(start_date_time_ns),
639                            Some(end_date_time_ns),
640                            limit,
641                            data_ts_event,
642                        ) {
643                            break;
644                        }
645                    }
646                }
647                IbHistoricalTickType::BidAsk => {
648                    while let Some(request_end) = current_end_date {
649                        // Make request for this batch
650                        let subscription = tokio::time::timeout(
651                            std::time::Duration::from_secs(timeout),
652                            self.ib_client
653                                .historical_ticks(&contract, 1000)
654                                .ending(jiff_to_ib_datetime(&request_end))
655                                .trading_hours(trading_hours)
656                                .bid_ask(IgnoreSize::No),
657                        )
658                        .await
659                        .context(format!(
660                            "Historical bid/ask ticks request timed out after {timeout} seconds"
661                        ))??;
662
663                        let mut subscription = subscription.filter_data();
664                        let mut batch_ticks = Vec::new();
665
666                        while let Some(tick_result) = subscription.next().await {
667                            let tick = match tick_result {
668                                Ok(tick) => tick,
669                                Err(e) => {
670                                    tracing::warn!("Historical bid/ask ticks stream error: {e:?}");
671                                    continue;
672                                }
673                            };
674
675                            let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
676
677                            if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
678                                continue;
679                            }
680
681                            let ts_init = ts_event;
682
683                            let raw_bid_price = tick.price_bid;
684
685                            let bid_price = prices
686                                .bid_price(raw_bid_price)
687                                .with_context(|| {
688                                    format!(
689                                        "Invalid historical bid price {raw_bid_price} for {instrument_id}"
690                                    )
691                                })?;
692
693                            let (Some(raw_bid_size), Some(raw_ask_size)) =
694                                (tick.size_bid, tick.size_ask)
695                            else {
696                                tracing::warn!(
697                                    "Skipping historical quote tick with an absent size for {}",
698                                    instrument_id
699                                );
700                                continue;
701                            };
702
703                            let bid_size = Quantity::new_checked(raw_bid_size, size_precision)
704                                .with_context(|| {
705                                    format!(
706                                        "Invalid historical bid size {raw_bid_size} for {instrument_id}"
707                                    )
708                                })?;
709
710                            let raw_ask_price = tick.price_ask;
711
712                            let ask_price = prices
713                                .ask_price(raw_ask_price)
714                                .with_context(|| {
715                                    format!(
716                                        "Invalid historical ask price {raw_ask_price} for {instrument_id}"
717                                    )
718                                })?;
719
720                            let ask_size = Quantity::new_checked(raw_ask_size, size_precision)
721                                .with_context(|| {
722                                    format!(
723                                        "Invalid historical ask size {raw_ask_size} for {instrument_id}"
724                                    )
725                                })?;
726
727                            let quote_tick = QuoteTick::new(
728                                instrument_id,
729                                bid_price,
730                                ask_price,
731                                bid_size,
732                                ask_size,
733                                ts_event,
734                                ts_init,
735                            );
736
737                            batch_ticks.push(Data::Quote(quote_tick));
738                        }
739
740                        if !extend_historical_tick_batch(
741                            &mut contract_ticks,
742                            batch_ticks,
743                            current_start_date,
744                            &mut current_end_date,
745                            Some(start_date_time_ns),
746                            Some(end_date_time_ns),
747                            limit,
748                            data_ts_event,
749                        ) {
750                            break;
751                        }
752                    }
753                }
754            }
755
756            retain_historical_ticks_in_range(
757                &mut contract_ticks,
758                Some(start_date_time_ns),
759                Some(end_date_time_ns),
760                data_ts_event,
761            );
762            contract_ticks.sort_by_key(data_ts_event);
763            if let Some(limit) = limit
764                && contract_ticks.len() > limit
765            {
766                contract_ticks = contract_ticks.split_off(contract_ticks.len() - limit);
767            }
768
769            all_ticks.extend(contract_ticks);
770        }
771
772        // Sort by timestamp
773        all_ticks.sort_by_key(data_ts_event);
774
775        Ok(all_ticks)
776    }
777
778    /// Request instruments given instrument IDs or contracts.
779    ///
780    /// This method uses the instrument provider to load and return instruments.
781    ///
782    /// # Returns
783    ///
784    /// Returns a list of instruments.
785    ///
786    /// # Errors
787    ///
788    /// Returns an error if loading fails.
789    pub async fn request_instruments(
790        &self,
791        instrument_ids: Option<Vec<InstrumentId>>,
792        contracts: Option<Vec<Contract>>,
793    ) -> anyhow::Result<Vec<InstrumentAny>> {
794        let instrument_ids = instrument_ids.unwrap_or_default();
795        let contracts = contracts.unwrap_or_default();
796
797        if instrument_ids.is_empty() && contracts.is_empty() {
798            anyhow::bail!("Either instrument_ids or contracts must be provided");
799        }
800
801        let loaded_ids = self
802            .instrument_provider
803            .load_ids_with_return_async(&self.ib_client, instrument_ids, None)
804            .await?;
805        let mut loaded_instruments = self.instrument_provider.find_all(&loaded_ids);
806
807        // Load instruments from contracts (equivalent to Python's _fetch_instruments_if_not_cached)
808        for contract in contracts {
809            match self
810                .instrument_provider
811                .get_instrument(&self.ib_client, &contract)
812                .await
813            {
814                Ok(Some(instrument)) => {
815                    if !loaded_instruments.iter().any(|i| i.id() == instrument.id()) {
816                        loaded_instruments.push(instrument);
817                    }
818
819                    continue;
820                }
821                Ok(None) => {}
822                Err(e) => {
823                    tracing::warn!(
824                        "Failed to fetch contract details from original contract {:?}: {}",
825                        contract,
826                        e
827                    );
828                }
829            }
830
831            // Try to find instrument by contract ID first
832            let instrument_id = if let Some(cached_id) = self
833                .instrument_provider
834                .get_instrument_id_by_contract_id(contract.contract_id)
835            {
836                Some(cached_id)
837            } else {
838                // Convert contract to instrument ID using provider's venue determination
839                // This matches Python's logic: venue = instrument_provider.determine_venue_from_contract(contract)
840                let venue = self.instrument_provider.determine_venue(&contract, None);
841                self.instrument_provider
842                    .instrument_id_from_contract(&contract, venue)
843                    .ok()
844            };
845
846            if let Some(instrument_id) = instrument_id {
847                // Check if already loaded (skip if already in results)
848                if loaded_instruments.iter().any(|i| i.id() == instrument_id) {
849                    continue;
850                }
851
852                // Fetch if not cached (matching Python: if not self._client._cache.instrument(instrument_id))
853                if self.instrument_provider.find(&instrument_id).is_none() {
854                    tracing::debug!("Fetching Instrument for: {}", instrument_id);
855
856                    if let Err(e) = self
857                        .instrument_provider
858                        .fetch_contract_details(&self.ib_client, instrument_id, false, None)
859                        .await
860                    {
861                        tracing::warn!(
862                            "Failed to fetch contract details for {}: {}",
863                            instrument_id,
864                            e
865                        );
866                        continue;
867                    }
868                }
869
870                if let Some(instrument) = self.instrument_provider.find(&instrument_id) {
871                    loaded_instruments.push(instrument);
872                }
873            } else {
874                // Fallback: try using get_instrument which handles BAG contracts
875                if let Ok(Some(instrument)) = self
876                    .instrument_provider
877                    .get_instrument(&self.ib_client, &contract)
878                    .await
879                    && !loaded_instruments.iter().any(|i| i.id() == instrument.id())
880                {
881                    loaded_instruments.push(instrument);
882                }
883            }
884        }
885
886        tracing::debug!("Loaded {} instruments", loaded_instruments.len());
887
888        Ok(loaded_instruments)
889    }
890
891    async fn resolve_instrument_id(&self, contract: &Contract) -> anyhow::Result<InstrumentId> {
892        if let Some(instrument_id) = self
893            .instrument_provider
894            .get_instrument_id_by_contract_id(contract.contract_id)
895        {
896            return Ok(instrument_id);
897        }
898
899        let venue = self.instrument_provider.determine_venue(contract, None);
900        let parsed = self
901            .instrument_provider
902            .instrument_id_from_contract(contract, venue)
903            .ok();
904
905        if let Some(instrument_id) = parsed {
906            return Ok(instrument_id);
907        }
908
909        if let Ok(Some(instrument)) = self
910            .instrument_provider
911            .get_instrument(&self.ib_client, contract)
912            .await
913        {
914            return Ok(instrument.id());
915        }
916
917        anyhow::bail!(
918            "Failed to resolve instrument ID for contract {}:{}:{}",
919            contract.symbol,
920            contract.security_type,
921            contract.exchange
922        );
923    }
924}
925
926fn data_ts_event(data: &Data) -> UnixNanos {
927    match data {
928        Data::Trade(tick) => tick.ts_event,
929        Data::Quote(tick) => tick.ts_event,
930        _ => UnixNanos::default(),
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use nautilus_common::config::ConfigError;
937    use rstest::rstest;
938
939    use super::*;
940    use crate::config::InteractiveBrokersInstrumentProviderConfig;
941
942    #[rstest]
943    #[case::config(false)]
944    #[case::supplied_config(true)]
945    #[tokio::test]
946    async fn test_historical_connect_rejects_duplicates_before_connection(#[case] supplied: bool) {
947        let duplicate_provider: InteractiveBrokersInstrumentProviderConfig =
948            serde_json::from_value(serde_json::json!({
949                "load_contracts": [{"conId": 265598}, {"conId": 265598}]
950            }))
951            .unwrap();
952
953        let config = InteractiveBrokersDataClientConfig {
954            host: "127.0.0.1".to_string(),
955            port: 1,
956            connection_timeout: 1,
957            instrument_provider: duplicate_provider,
958            ..Default::default()
959        };
960
961        let result = if supplied {
962            let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
963
964            HistoricalInteractiveBrokersClient::connect_with_provider(provider, config).await
965        } else {
966            HistoricalInteractiveBrokersClient::connect(config).await
967        };
968
969        let error = result.expect_err("duplicate config must fail before gateway connection");
970        assert_eq!(
971            error.downcast_ref::<ConfigError>(),
972            Some(&ConfigError::duplicate("load_contracts", None))
973        );
974    }
975}