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, TradeTick},
31    enums::{AggregationSource, AggressorSide, 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    },
42    config::InteractiveBrokersDataClientConfig,
43    data::convert::{
44        apply_bar_price_magnifier, apply_price_magnifier, bar_request_segments,
45        bar_type_to_ib_bar_size, ib_bar_to_nautilus_bar, ib_timestamp_to_unix_nanos,
46        jiff_to_ib_datetime, price_type_to_ib_what_to_show_for_security,
47    },
48    providers::instruments::InteractiveBrokersInstrumentProvider,
49};
50
51/// Historical data client for Interactive Brokers.
52///
53/// This client provides methods for requesting historical bars and ticks
54/// for backtesting and research purposes.
55#[cfg_attr(
56    feature = "python",
57    pyo3::pyclass(
58        module = "nautilus_trader.adapters.interactive_brokers",
59        subclass,
60        from_py_object
61    )
62)]
63#[cfg_attr(
64    feature = "python",
65    pyo3_stub_gen::derive::gen_stub_pyclass(
66        module = "nautilus_trader.adapters.interactive_brokers"
67    )
68)]
69pub struct HistoricalInteractiveBrokersClient {
70    /// IB API client.
71    ib_client: Arc<Client>,
72    /// Instrument provider.
73    instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
74    /// Shared client handle, when this client owns the connection lifecycle.
75    _shared_client: Option<Arc<SharedClientHandle>>,
76}
77
78impl Clone for HistoricalInteractiveBrokersClient {
79    fn clone(&self) -> Self {
80        Self {
81            ib_client: Arc::clone(&self.ib_client),
82            instrument_provider: Arc::clone(&self.instrument_provider),
83            _shared_client: self._shared_client.clone(),
84        }
85    }
86}
87
88impl Debug for HistoricalInteractiveBrokersClient {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct(stringify!(HistoricalInteractiveBrokersClient))
91            .field("ib_client", &"<Client>")
92            .field("instrument_provider", &"<InstrumentProvider>")
93            .finish()
94    }
95}
96
97impl HistoricalInteractiveBrokersClient {
98    /// Create a new historical data client.
99    ///
100    /// # Arguments
101    ///
102    /// * `ib_client` - The IB API client
103    /// * `instrument_provider` - The instrument provider
104    pub fn new(
105        ib_client: Arc<Client>,
106        instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
107    ) -> Self {
108        Self {
109            ib_client,
110            instrument_provider,
111            _shared_client: None,
112        }
113    }
114
115    /// Connect to Interactive Brokers and create a historical data client.
116    ///
117    /// This initializes an instrument provider from `config.instrument_provider` and acquires the
118    /// shared IB client for the configured host, port, and client ID.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if provider initialization or the IB connection fails.
123    pub async fn connect(config: InteractiveBrokersDataClientConfig) -> anyhow::Result<Self> {
124        let instrument_provider = Arc::new(InteractiveBrokersInstrumentProvider::new(
125            config.instrument_provider.clone(),
126        ));
127        let shared_client = shared_client::get_or_connect(
128            &config.host,
129            config.port,
130            config.client_id,
131            config.connection_timeout,
132        )
133        .await?;
134        let client = shared_client.as_arc();
135
136        if config.market_data_type != crate::config::MarketDataType::Realtime {
137            let market_data_type: ibapi::market_data::MarketDataType =
138                config.market_data_type.into();
139            client.switch_market_data_type(market_data_type).await?;
140        }
141        instrument_provider
142            .initialize_with_client(client.as_ref())
143            .await?;
144
145        Ok(Self::from_shared_client(shared_client, instrument_provider))
146    }
147
148    pub(crate) fn from_shared_client(
149        shared_client: SharedClientHandle,
150        instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
151    ) -> Self {
152        let ib_client = Arc::clone(shared_client.as_arc());
153
154        Self {
155            ib_client,
156            instrument_provider,
157            _shared_client: Some(Arc::new(shared_client)),
158        }
159    }
160
161    /// Connect a historical data client with a supplied provider using the shared IB client registry.
162    ///
163    /// This keeps standalone Rust callers from needing to acquire `common::shared_client`
164    /// directly before requesting instruments or historical data.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the shared client cannot connect or the instrument provider cannot
169    /// initialize.
170    pub async fn connect_with_provider(
171        instrument_provider: InteractiveBrokersInstrumentProvider,
172        config: InteractiveBrokersDataClientConfig,
173    ) -> anyhow::Result<Self> {
174        let shared_client = shared_client::get_or_connect(
175            &config.host,
176            config.port,
177            config.client_id,
178            config.connection_timeout,
179        )
180        .await?;
181        let client = shared_client.as_arc();
182
183        if config.market_data_type != crate::config::MarketDataType::Realtime {
184            let market_data_type: ibapi::market_data::MarketDataType =
185                config.market_data_type.into();
186            client.switch_market_data_type(market_data_type).await?;
187        }
188        instrument_provider
189            .initialize_with_client(client.as_ref())
190            .await?;
191
192        Ok(Self::from_shared_client(
193            shared_client,
194            Arc::new(instrument_provider),
195        ))
196    }
197
198    /// Request historical bars.
199    ///
200    /// # Continuous futures
201    ///
202    /// Continuous futures (`CONTFUT`) reject an explicit end date/time with IB
203    /// error 10339. For these contracts the end date is dropped and only the
204    /// first duration segment is requested, anchored to the current time, so
205    /// the returned bars may fall outside `[start_date_time, end_date_time]`.
206    /// A warning is logged when the requested end date/time is in the past or
207    /// the range spans more than one duration segment.
208    ///
209    /// # Arguments
210    ///
211    /// * `bar_specifications` - List of bar specifications (e.g., "1-HOUR-LAST")
212    /// * `end_date_time` - End date for bars
213    /// * `start_date_time` - Optional start date
214    /// * `duration` - Optional duration string (e.g., "1 D")
215    /// * `contracts` - List of IB contracts
216    /// * `instrument_ids` - List of instrument IDs
217    /// * `use_rth` - Use regular trading hours only
218    /// * `timeout` - Request timeout in seconds
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the request fails.
223    #[allow(clippy::too_many_arguments)]
224    pub async fn request_bars(
225        &self,
226        bar_specifications: Vec<&str>,
227        end_date_time: Timestamp,
228        start_date_time: Option<Timestamp>,
229        duration: Option<&str>,
230        contracts: Option<Vec<Contract>>,
231        instrument_ids: Option<Vec<InstrumentId>>,
232        use_rth: bool,
233        timeout: u64,
234    ) -> anyhow::Result<Vec<Bar>> {
235        // Validate inputs
236        if start_date_time.is_some() && duration.is_some() {
237            anyhow::bail!("Either start_date_time or duration should be provided, not both");
238        }
239
240        if let Some(start) = start_date_time
241            && start >= end_date_time
242        {
243            anyhow::bail!("Start date must be before end date");
244        }
245
246        if let Some(duration) = duration {
247            duration.parse::<historical::Duration>().with_context(|| {
248                format!("duration must be in format: 'int S|D|W|M|Y', was '{duration}'")
249            })?;
250        }
251
252        let contracts = contracts.unwrap_or_default();
253        let instrument_ids = instrument_ids.unwrap_or_default();
254
255        if contracts.is_empty() && instrument_ids.is_empty() {
256            anyhow::bail!("Either contracts or instrument_ids must be provided");
257        }
258
259        // Convert instrument IDs to contracts using instrument provider
260        let mut all_contracts = contracts;
261
262        for instrument_id in instrument_ids {
263            // Try to find instrument in provider first
264            if self.instrument_provider.find(&instrument_id).is_none() {
265                // Auto-fetch if not cached
266                if let Err(e) = self
267                    .instrument_provider
268                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
269                    .await
270                {
271                    tracing::warn!(
272                        "Failed to auto-fetch contract details for {}: {}",
273                        instrument_id,
274                        e
275                    );
276                }
277            }
278
279            // Try to convert instrument ID to contract
280            if let Ok(contract) = self
281                .instrument_provider
282                .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
283                .await
284            {
285                all_contracts.push(contract);
286            } else {
287                tracing::warn!(
288                    "Failed to convert instrument_id {} to IB contract, skipping",
289                    instrument_id
290                );
291            }
292        }
293
294        // Auto-fetch contracts if not cached (by contract ID)
295        for contract in &all_contracts {
296            if let Some(instrument_id) = self
297                .instrument_provider
298                .get_instrument_id_by_contract_id(contract.contract_id)
299                && self.instrument_provider.find(&instrument_id).is_none()
300                && let Err(e) = self
301                    .instrument_provider
302                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
303                    .await
304            {
305                tracing::warn!(
306                    "Failed to auto-fetch contract details for contract ID {}: {}",
307                    contract.contract_id,
308                    e
309                );
310            }
311        }
312
313        if all_contracts.is_empty() {
314            anyhow::bail!("No valid contracts found after conversion");
315        }
316
317        let trading_hours = if use_rth {
318            TradingHours::Regular
319        } else {
320            TradingHours::Extended
321        };
322
323        let mut all_bars = Vec::new();
324
325        for contract in all_contracts {
326            for bar_spec_str in &bar_specifications {
327                // Parse bar spec (e.g., "1-HOUR-LAST")
328                let parts: Vec<&str> = bar_spec_str.split('-').collect();
329                if parts.len() != 3 {
330                    anyhow::bail!("Invalid bar specification format: {}", bar_spec_str);
331                }
332
333                let step = parts[0].parse::<usize>()?;
334                let aggregation = parts[1].to_lowercase();
335                let price_type = parts[2].to_uppercase();
336
337                let bar_spec = match aggregation.as_str() {
338                    "second" => BarSpecification::new(
339                        step,
340                        BarAggregation::Second,
341                        PriceType::from_str(&price_type).unwrap_or(PriceType::Last),
342                    ),
343                    "minute" => BarSpecification::new(
344                        step,
345                        BarAggregation::Minute,
346                        PriceType::from_str(&price_type).unwrap_or(PriceType::Last),
347                    ),
348                    "hour" => BarSpecification::new(
349                        step,
350                        BarAggregation::Hour,
351                        PriceType::from_str(&price_type).unwrap_or(PriceType::Last),
352                    ),
353                    "day" => BarSpecification::new(
354                        step,
355                        BarAggregation::Day,
356                        PriceType::from_str(&price_type).unwrap_or(PriceType::Last),
357                    ),
358                    "week" => BarSpecification::new(
359                        step,
360                        BarAggregation::Week,
361                        PriceType::from_str(&price_type).unwrap_or(PriceType::Last),
362                    ),
363                    _ => anyhow::bail!("Unsupported aggregation: {}", aggregation),
364                };
365
366                let instrument_id = self.resolve_instrument_id(&contract).await?;
367                let bar_type_with_id =
368                    BarType::new(instrument_id, bar_spec, AggregationSource::External);
369
370                // Convert bar type to IB parameters. Crypto trade-price bars must
371                // request AGGTRADES, not TRADES (TWS rejects TRADES for crypto,
372                // error 10299) - same rule as the live data client's historical path.
373                let ib_bar_size = bar_type_to_ib_bar_size(&bar_type_with_id)?;
374                let is_crypto = crate::common::parse::is_crypto_contract(&contract);
375                let ib_what_to_show =
376                    price_type_to_ib_what_to_show_for_security(bar_spec.price_type, is_crypto);
377
378                // Omit the end date for continuous futures (IB error 10339).
379                let is_continuous_future = contract.security_type == SecurityType::ContinuousFuture;
380                let segments = bar_request_segments(
381                    self.calculate_duration_segments(start_date_time, end_date_time, duration),
382                    is_continuous_future,
383                );
384
385                for (segment_end, segment_duration) in segments {
386                    tracing::debug!(
387                        "Requesting historical bars ending on {:?} with duration {}",
388                        segment_end,
389                        segment_duration
390                    );
391
392                    let mut request = self
393                        .ib_client
394                        .historical_data(&contract, ib_bar_size)
395                        .duration(segment_duration)
396                        .what_to_show(ib_what_to_show)
397                        .trading_hours(trading_hours);
398
399                    if let Some(end) = segment_end {
400                        request = request.ending(jiff_to_ib_datetime(&end));
401                    }
402
403                    let historical_data = tokio::time::timeout(
404                        std::time::Duration::from_secs(timeout),
405                        request.fetch(),
406                    )
407                    .await
408                    .context(format!(
409                        "Historical data request timed out after {} seconds",
410                        timeout
411                    ))??;
412
413                    // Get precision from instrument if available
414                    let (price_precision, size_precision) =
415                        if let Some(instrument) = self.instrument_provider.find(&instrument_id) {
416                            (instrument.price_precision(), instrument.size_precision())
417                        } else {
418                            (5, 0) // Default fallback
419                        };
420                    let price_magnifier =
421                        self.instrument_provider.get_price_magnifier(&instrument_id);
422
423                    // Create new bar_type with correct instrument_id
424                    for ib_bar in &historical_data.bars {
425                        let ib_bar = apply_bar_price_magnifier(ib_bar, price_magnifier);
426                        let nautilus_bar = ib_bar_to_nautilus_bar(
427                            &ib_bar,
428                            bar_type_with_id,
429                            price_precision,
430                            size_precision,
431                        )?;
432                        all_bars.push(nautilus_bar);
433                    }
434
435                    tracing::debug!("Retrieved {} bars in batch", historical_data.bars.len());
436                }
437            }
438        }
439
440        // Sort by timestamp
441        all_bars.sort_by_key(|b| b.ts_event);
442
443        Ok(all_bars)
444    }
445
446    /// Request historical ticks with pagination support.
447    ///
448    /// # Arguments
449    ///
450    /// * `tick_type` - historical tick type.
451    /// * `start_date_time` - Start date
452    /// * `end_date_time` - End date
453    /// * `contracts` - List of IB contracts
454    /// * `instrument_ids` - List of instrument IDs
455    /// * `use_rth` - Use regular trading hours only
456    /// * `timeout` - Request timeout in seconds
457    /// * `limit` - Maximum number of ticks to return, or 0 for no explicit limit
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if the request fails.
462    #[allow(clippy::too_many_arguments)]
463    pub async fn request_ticks(
464        &self,
465        tick_type: IbHistoricalTickType,
466        start_date_time: Timestamp,
467        end_date_time: Timestamp,
468        contracts: Option<Vec<Contract>>,
469        instrument_ids: Option<Vec<InstrumentId>>,
470        use_rth: bool,
471        timeout: u64,
472        limit: usize,
473    ) -> anyhow::Result<Vec<Data>> {
474        if start_date_time >= end_date_time {
475            anyhow::bail!("Start date must be before end date");
476        }
477
478        let limit = (limit > 0).then_some(limit);
479
480        if end_date_time.duration_since(start_date_time) > jiff::SignedDuration::from_hours(24) {
481            tracing::warn!(
482                "Requesting tick data for more than 1 day may take a long time, particularly for liquid instruments"
483            );
484        }
485
486        let contracts = contracts.unwrap_or_default();
487        let instrument_ids = instrument_ids.unwrap_or_default();
488
489        if contracts.is_empty() && instrument_ids.is_empty() {
490            anyhow::bail!("Either contracts or instrument_ids must be provided");
491        }
492
493        let trading_hours = if use_rth {
494            TradingHours::Regular
495        } else {
496            TradingHours::Extended
497        };
498
499        // Convert instrument IDs to contracts and auto-fetch if not cached
500        let mut all_contracts = contracts;
501
502        for instrument_id in instrument_ids {
503            // Auto-fetch if not cached
504            if self.instrument_provider.find(&instrument_id).is_none()
505                && let Err(e) = self
506                    .instrument_provider
507                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
508                    .await
509            {
510                tracing::warn!(
511                    "Failed to auto-fetch contract details for {}: {}",
512                    instrument_id,
513                    e
514                );
515            }
516
517            if let Ok(contract) = self
518                .instrument_provider
519                .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
520                .await
521            {
522                all_contracts.push(contract);
523            } else {
524                tracing::warn!(
525                    "Failed to convert instrument_id {} to IB contract, skipping",
526                    instrument_id
527                );
528            }
529        }
530
531        // Auto-fetch contracts if not cached
532        for contract in &all_contracts {
533            if let Some(instrument_id) = self
534                .instrument_provider
535                .get_instrument_id_by_contract_id(contract.contract_id)
536                && self.instrument_provider.find(&instrument_id).is_none()
537                && let Err(e) = self
538                    .instrument_provider
539                    .fetch_contract_details(&self.ib_client, instrument_id, false, None)
540                    .await
541            {
542                tracing::warn!(
543                    "Failed to auto-fetch contract details for contract ID {}: {}",
544                    contract.contract_id,
545                    e
546                );
547            }
548        }
549
550        if all_contracts.is_empty() {
551            anyhow::bail!("No valid contracts found after conversion");
552        }
553
554        let mut all_ticks = Vec::new();
555
556        for contract in all_contracts {
557            let instrument_id = self.resolve_instrument_id(&contract).await?;
558
559            // Get precision from instrument if available
560            let (price_precision, size_precision) =
561                if let Some(instrument) = self.instrument_provider.find(&instrument_id) {
562                    (instrument.price_precision(), instrument.size_precision())
563                } else {
564                    (5, 0) // Default fallback
565                };
566            let price_magnifier = self.instrument_provider.get_price_magnifier(&instrument_id);
567            let contract_start_len = all_ticks.len();
568
569            // Pagination loop for ticks (similar to Python _handle_timestamp_iteration)
570            let mut current_end_date = end_date_time;
571            let current_start_date = start_date_time;
572            let start_date_time_ns =
573                UnixNanos::from(u64::try_from(start_date_time.as_nanosecond()).unwrap_or_default());
574            let end_date_time_ns =
575                UnixNanos::from(u64::try_from(end_date_time.as_nanosecond()).unwrap_or_default());
576
577            match tick_type {
578                IbHistoricalTickType::Trades => {
579                    loop {
580                        // Make request for this batch
581                        let subscription = tokio::time::timeout(
582                            std::time::Duration::from_secs(timeout),
583                            self.ib_client
584                                .historical_ticks(&contract, 1000)
585                                .starting(jiff_to_ib_datetime(&current_start_date))
586                                .ending(jiff_to_ib_datetime(&current_end_date))
587                                .trading_hours(trading_hours)
588                                .trade(),
589                        )
590                        .await
591                        .context(format!(
592                            "Historical trades request timed out after {} seconds",
593                            timeout
594                        ))??;
595
596                        let mut subscription = subscription.filter_data();
597                        let mut batch_ticks = Vec::new();
598
599                        while let Some(tick_result) = subscription.next().await {
600                            let tick = match tick_result {
601                                Ok(tick) => tick,
602                                Err(e) => {
603                                    tracing::warn!("Historical trade ticks stream error: {e:?}");
604                                    continue;
605                                }
606                            };
607                            let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
608
609                            if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
610                                continue;
611                            }
612
613                            let ts_init = ts_event;
614
615                            let converted_price =
616                                apply_price_magnifier(tick.price, price_magnifier);
617                            let price = Price::new(converted_price, price_precision);
618                            let size = Quantity::new(tick.size as f64, size_precision);
619
620                            let trade_tick = TradeTick::new(
621                                instrument_id,
622                                price,
623                                size,
624                                AggressorSide::NoAggressor,
625                                crate::common::parse::generate_ib_trade_id(
626                                    ts_event,
627                                    converted_price,
628                                    tick.size as f64,
629                                ),
630                                ts_event,
631                                ts_init,
632                            );
633
634                            batch_ticks.push(Data::Trade(trade_tick));
635                        }
636
637                        if batch_ticks.is_empty() {
638                            break;
639                        }
640
641                        // Update current_end_date to the minimum ts_event from this batch for next iteration
642                        // This works backwards in time
643                        if let Some(min_tick) = batch_ticks.iter().min_by_key(|t| match t {
644                            Data::Trade(t) => t.ts_event,
645                            _ => UnixNanos::default(),
646                        }) {
647                            let min_ts_nanos = match min_tick {
648                                Data::Trade(t) => t.ts_event.as_u64(),
649                                _ => break,
650                            };
651
652                            if let Some(new_end) = retreat_end_datetime(min_ts_nanos) {
653                                current_end_date = new_end;
654                            } else {
655                                break;
656                            }
657                        }
658
659                        all_ticks.extend(batch_ticks);
660
661                        if let Some(limit) = limit
662                            && all_ticks.len() - contract_start_len >= limit
663                        {
664                            break;
665                        }
666
667                        // Check if we should continue - need current_end > current_start
668                        if !should_continue_backward_pagination(
669                            current_end_date,
670                            current_start_date,
671                        ) {
672                            break;
673                        }
674
675                        // Filter out ticks outside the requested range if needed
676                        all_ticks.retain(|t| match t {
677                            Data::Trade(t) => {
678                                t.ts_event >= start_date_time_ns && t.ts_event <= end_date_time_ns
679                            }
680                            Data::Quote(q) => {
681                                q.ts_event >= start_date_time_ns && q.ts_event <= end_date_time_ns
682                            }
683                            _ => true,
684                        });
685                    }
686                }
687                IbHistoricalTickType::BidAsk => {
688                    loop {
689                        // Make request for this batch
690                        let subscription = tokio::time::timeout(
691                            std::time::Duration::from_secs(timeout),
692                            self.ib_client
693                                .historical_ticks(&contract, 1000)
694                                .starting(jiff_to_ib_datetime(&current_start_date))
695                                .ending(jiff_to_ib_datetime(&current_end_date))
696                                .trading_hours(trading_hours)
697                                .bid_ask(IgnoreSize::No),
698                        )
699                        .await
700                        .context(format!(
701                            "Historical bid/ask ticks request timed out after {} seconds",
702                            timeout
703                        ))??;
704
705                        let mut subscription = subscription.filter_data();
706                        let mut batch_ticks = Vec::new();
707
708                        while let Some(tick_result) = subscription.next().await {
709                            let tick = match tick_result {
710                                Ok(tick) => tick,
711                                Err(e) => {
712                                    tracing::warn!("Historical bid/ask ticks stream error: {e:?}");
713                                    continue;
714                                }
715                            };
716                            let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
717
718                            if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
719                                continue;
720                            }
721
722                            let ts_init = ts_event;
723
724                            let bid_price = Price::new(
725                                apply_price_magnifier(tick.price_bid, price_magnifier),
726                                price_precision,
727                            );
728                            let bid_size = Quantity::new(tick.size_bid as f64, size_precision);
729                            let ask_price = Price::new(
730                                apply_price_magnifier(tick.price_ask, price_magnifier),
731                                price_precision,
732                            );
733                            let ask_size = Quantity::new(tick.size_ask as f64, size_precision);
734
735                            let quote_tick = QuoteTick::new(
736                                instrument_id,
737                                bid_price,
738                                ask_price,
739                                bid_size,
740                                ask_size,
741                                ts_event,
742                                ts_init,
743                            );
744
745                            batch_ticks.push(Data::Quote(quote_tick));
746                        }
747
748                        if batch_ticks.is_empty() {
749                            break;
750                        }
751
752                        // Update current_end_date to the minimum ts_event from this batch for next iteration
753                        if let Some(min_tick) = batch_ticks.iter().min_by_key(|t| match t {
754                            Data::Quote(q) => q.ts_event,
755                            _ => UnixNanos::default(),
756                        }) {
757                            let min_ts_nanos = match min_tick {
758                                Data::Quote(q) => q.ts_event.as_u64(),
759                                _ => break,
760                            };
761
762                            if let Some(new_end) = retreat_end_datetime(min_ts_nanos) {
763                                current_end_date = new_end;
764                            } else {
765                                break;
766                            }
767                        }
768
769                        all_ticks.extend(batch_ticks);
770
771                        if let Some(limit) = limit
772                            && all_ticks.len() - contract_start_len >= limit
773                        {
774                            break;
775                        }
776
777                        // Check if we should continue
778                        if !should_continue_backward_pagination(
779                            current_end_date,
780                            current_start_date,
781                        ) {
782                            break;
783                        }
784
785                        // Filter out ticks outside the requested range if needed
786                        all_ticks.retain(|t| match t {
787                            Data::Trade(t) => {
788                                t.ts_event >= start_date_time_ns && t.ts_event <= end_date_time_ns
789                            }
790                            Data::Quote(q) => {
791                                q.ts_event >= start_date_time_ns && q.ts_event <= end_date_time_ns
792                            }
793                            _ => true,
794                        });
795                    }
796                }
797            }
798
799            if let Some(limit) = limit {
800                let mut contract_ticks = all_ticks.split_off(contract_start_len);
801                contract_ticks.sort_by_key(|tick| match tick {
802                    Data::Trade(t) => t.ts_event,
803                    Data::Quote(q) => q.ts_event,
804                    _ => UnixNanos::default(),
805                });
806
807                if contract_ticks.len() > limit {
808                    contract_ticks = contract_ticks.split_off(contract_ticks.len() - limit);
809                }
810                all_ticks.extend(contract_ticks);
811            }
812        }
813
814        // Sort by timestamp
815        all_ticks.sort_by_key(|tick| match tick {
816            Data::Trade(t) => t.ts_event,
817            Data::Quote(q) => q.ts_event,
818            _ => UnixNanos::default(),
819        });
820
821        Ok(all_ticks)
822    }
823
824    /// Request instruments given instrument IDs or contracts.
825    ///
826    /// This method uses the instrument provider to load and return instruments.
827    ///
828    /// # Arguments
829    ///
830    /// * `instrument_ids` - Optional list of instrument IDs
831    /// * `contracts` - Optional list of IB contracts
832    ///
833    /// # Returns
834    ///
835    /// Returns a list of instruments.
836    ///
837    /// # Errors
838    ///
839    /// Returns an error if loading fails.
840    pub async fn request_instruments(
841        &self,
842        instrument_ids: Option<Vec<InstrumentId>>,
843        contracts: Option<Vec<Contract>>,
844    ) -> anyhow::Result<Vec<InstrumentAny>> {
845        let instrument_ids = instrument_ids.unwrap_or_default();
846        let contracts = contracts.unwrap_or_default();
847
848        if instrument_ids.is_empty() && contracts.is_empty() {
849            anyhow::bail!("Either instrument_ids or contracts must be provided");
850        }
851
852        let loaded_ids = self
853            .instrument_provider
854            .load_ids_with_return_async(&self.ib_client, instrument_ids, None)
855            .await?;
856        let mut loaded_instruments = self.instrument_provider.find_all(&loaded_ids);
857
858        // Load instruments from contracts (equivalent to Python's _fetch_instruments_if_not_cached)
859        for contract in contracts {
860            match self
861                .instrument_provider
862                .get_instrument(&self.ib_client, &contract)
863                .await
864            {
865                Ok(Some(instrument)) => {
866                    if !loaded_instruments.iter().any(|i| i.id() == instrument.id()) {
867                        loaded_instruments.push(instrument);
868                    }
869                    continue;
870                }
871                Ok(None) => {}
872                Err(e) => {
873                    tracing::warn!(
874                        "Failed to fetch contract details from original contract {:?}: {}",
875                        contract,
876                        e
877                    );
878                }
879            }
880
881            // Try to find instrument by contract ID first
882            let instrument_id = if let Some(cached_id) = self
883                .instrument_provider
884                .get_instrument_id_by_contract_id(contract.contract_id)
885            {
886                Some(cached_id)
887            } else {
888                // Convert contract to instrument ID using provider's venue determination
889                // This matches Python's logic: venue = instrument_provider.determine_venue_from_contract(contract)
890                let venue = self.instrument_provider.determine_venue(&contract, None);
891                match self.instrument_provider.symbology_method() {
892                    crate::config::SymbologyMethod::Simplified => {
893                        crate::common::parse::ib_contract_to_instrument_id_simplified(
894                            &contract,
895                            Some(venue),
896                        )
897                        .ok()
898                    }
899                    crate::config::SymbologyMethod::Raw => {
900                        crate::common::parse::ib_contract_to_instrument_id_raw(
901                            &contract,
902                            Some(venue),
903                        )
904                        .ok()
905                    }
906                }
907            };
908
909            if let Some(instrument_id) = instrument_id {
910                // Check if already loaded (skip if already in results)
911                if loaded_instruments.iter().any(|i| i.id() == instrument_id) {
912                    continue;
913                }
914
915                // Fetch if not cached (matching Python: if not self._client._cache.instrument(instrument_id))
916                if self.instrument_provider.find(&instrument_id).is_none() {
917                    tracing::debug!("Fetching Instrument for: {}", instrument_id);
918
919                    if let Err(e) = self
920                        .instrument_provider
921                        .fetch_contract_details(&self.ib_client, instrument_id, false, None)
922                        .await
923                    {
924                        tracing::warn!(
925                            "Failed to fetch contract details for {}: {}",
926                            instrument_id,
927                            e
928                        );
929                        continue;
930                    }
931                }
932
933                if let Some(instrument) = self.instrument_provider.find(&instrument_id) {
934                    loaded_instruments.push(instrument);
935                }
936            } else {
937                // Fallback: try using get_instrument which handles BAG contracts
938                if let Ok(Some(instrument)) = self
939                    .instrument_provider
940                    .get_instrument(&self.ib_client, &contract)
941                    .await
942                {
943                    if !loaded_instruments.iter().any(|i| i.id() == instrument.id()) {
944                        loaded_instruments.push(instrument);
945                    }
946                }
947            }
948        }
949
950        tracing::debug!("Loaded {} instruments", loaded_instruments.len());
951
952        Ok(loaded_instruments)
953    }
954
955    /// Calculate duration segments for a time range.
956    ///
957    /// This breaks down large date ranges into smaller segments that IB can handle.
958    ///
959    /// # Arguments
960    ///
961    /// * `start_date` - Optional start date
962    /// * `end_date` - End date
963    /// * `duration` - Optional duration string
964    ///
965    /// # Returns
966    ///
967    /// Returns a list of (end_date, duration) tuples.
968    fn calculate_duration_segments(
969        &self,
970        start_date: Option<Timestamp>,
971        end_date: Timestamp,
972        duration: Option<&str>,
973    ) -> Vec<(Timestamp, historical::Duration)> {
974        // If duration is specified, use it directly
975        if let Some(dur_str) = duration {
976            if let Ok(dur) = dur_str.parse::<historical::Duration>() {
977                return vec![(end_date, dur)];
978            } else {
979                tracing::warn!("Invalid duration format: {}, using default", dur_str);
980            }
981        }
982
983        // Calculate from start/end dates - matching Python's comprehensive breakdown
984        if let Some(start) = start_date {
985            let total_delta = end_date.duration_since(start);
986            let total_days = total_delta.as_secs() / (24 * 60 * 60);
987
988            let mut segments = Vec::new();
989
990            // Calculate full years in the time delta (matching Python: years = total_delta.days // 365)
991            let years = total_days / 365;
992            let minus_years_date = if years > 0 {
993                end_date - jiff::SignedDuration::from_hours(24 * (365 * years))
994            } else {
995                end_date
996            };
997
998            // Calculate remaining days after subtracting full years (matching Python logic)
999            let days = if years > 0 {
1000                let remaining_delta = minus_years_date.duration_since(start);
1001                remaining_delta.as_secs() / (24 * 60 * 60)
1002            } else {
1003                total_days
1004            };
1005
1006            let minus_days_date = if days > 0 {
1007                minus_years_date - jiff::SignedDuration::from_hours(24 * (days))
1008            } else {
1009                minus_years_date
1010            };
1011
1012            // Calculate remaining time in seconds after subtracting years and days
1013            // Matching Python: hours*3600 + minutes*60 + seconds + subsecond
1014            let remaining_delta = minus_days_date.duration_since(start);
1015            // Extract time components from the remaining delta
1016            let total_secs = remaining_delta.as_secs();
1017            let hours = total_secs / 3600;
1018            let minutes = (total_secs % 3600) / 60;
1019            let secs = total_secs % 60;
1020            // Check for subsecond precision (milliseconds, microseconds, nanoseconds)
1021            let subsecond = if remaining_delta.as_millis() % 1000 > 0
1022                || remaining_delta.as_micros() % 1000 > 0
1023                || remaining_delta.as_nanos() % 1000 > 0
1024            {
1025                1
1026            } else {
1027                0
1028            };
1029            let seconds = hours * 3600 + minutes * 60 + secs + subsecond;
1030
1031            // Build segments in order: years, days, seconds (matching Python order)
1032            if years > 0 {
1033                segments.push((end_date, historical::Duration::years(years as i32)));
1034            }
1035
1036            if days > 0 {
1037                segments.push((minus_years_date, historical::Duration::days(days as i32)));
1038            }
1039
1040            if seconds > 0 {
1041                segments.push((
1042                    minus_days_date,
1043                    historical::Duration::seconds(seconds as i32),
1044                ));
1045            }
1046
1047            if segments.is_empty() {
1048                // Default to 1 day if calculation results in nothing
1049                segments.push((end_date, historical::Duration::days(1)));
1050            }
1051
1052            segments
1053        } else {
1054            // Default to 1 day if no start date
1055            vec![(end_date, historical::Duration::days(1))]
1056        }
1057    }
1058
1059    async fn resolve_instrument_id(&self, contract: &Contract) -> anyhow::Result<InstrumentId> {
1060        if let Some(instrument_id) = self
1061            .instrument_provider
1062            .get_instrument_id_by_contract_id(contract.contract_id)
1063        {
1064            return Ok(instrument_id);
1065        }
1066
1067        let venue = self.instrument_provider.determine_venue(contract, None);
1068        let parsed = match self.instrument_provider.symbology_method() {
1069            crate::config::SymbologyMethod::Simplified => {
1070                crate::common::parse::ib_contract_to_instrument_id_simplified(contract, Some(venue))
1071                    .ok()
1072            }
1073            crate::config::SymbologyMethod::Raw => {
1074                crate::common::parse::ib_contract_to_instrument_id_raw(contract, Some(venue)).ok()
1075            }
1076        };
1077
1078        if let Some(instrument_id) = parsed {
1079            return Ok(instrument_id);
1080        }
1081
1082        if let Ok(Some(instrument)) = self
1083            .instrument_provider
1084            .get_instrument(&self.ib_client, contract)
1085            .await
1086        {
1087            return Ok(instrument.id());
1088        }
1089
1090        anyhow::bail!(
1091            "Failed to resolve instrument ID for contract {}:{}:{}",
1092            contract.symbol,
1093            contract.security_type,
1094            contract.exchange
1095        );
1096    }
1097}
1098
1099fn retreat_end_datetime(min_ts_nanos: u64) -> Option<Timestamp> {
1100    let new_end_nanos = min_ts_nanos.saturating_sub(1_000_000); // 1ms
1101    Timestamp::from_nanosecond(i128::from(new_end_nanos)).ok()
1102}
1103
1104fn should_continue_backward_pagination(
1105    current_end_date: Timestamp,
1106    current_start_date: Timestamp,
1107) -> bool {
1108    current_end_date > current_start_date
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use jiff::Timestamp;
1114    use rstest::rstest;
1115
1116    use super::{retreat_end_datetime, should_continue_backward_pagination};
1117
1118    #[rstest]
1119    fn test_retreat_end_datetime_subtracts_one_millisecond() {
1120        let ts_nanos = 1_700_000_000_123_456_789_u64;
1121        let result = retreat_end_datetime(ts_nanos).unwrap();
1122        assert_eq!(
1123            u64::try_from(result.as_nanosecond()).unwrap(),
1124            ts_nanos - 1_000_000
1125        );
1126    }
1127
1128    #[rstest]
1129    fn test_retreat_end_datetime_saturates_at_zero() {
1130        let result = retreat_end_datetime(500_000).unwrap();
1131        assert_eq!(result.as_nanosecond(), 0);
1132    }
1133
1134    #[rstest]
1135    fn test_should_continue_backward_pagination_true_when_end_after_start() {
1136        let start = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1137        let end = "2025-01-01T00:00:01Z".parse::<Timestamp>().unwrap();
1138        assert!(should_continue_backward_pagination(end, start));
1139    }
1140
1141    #[rstest]
1142    fn test_should_continue_backward_pagination_false_when_end_equal_start() {
1143        let start = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1144        assert!(!should_continue_backward_pagination(start, start));
1145    }
1146}