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