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