1use 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#[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_client: Arc<Client>,
72 instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
74 _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 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 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 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 #[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 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 let mut all_contracts = contracts;
261
262 for instrument_id in instrument_ids {
263 if self.instrument_provider.find(&instrument_id).is_none() {
265 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 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 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 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 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 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 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) };
420 let price_magnifier =
421 self.instrument_provider.get_price_magnifier(&instrument_id);
422
423 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 all_bars.sort_by_key(|b| b.ts_event);
442
443 Ok(all_bars)
444 }
445
446 #[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 let mut all_contracts = contracts;
501
502 for instrument_id in instrument_ids {
503 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 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 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) };
566 let price_magnifier = self.instrument_provider.get_price_magnifier(&instrument_id);
567 let contract_start_len = all_ticks.len();
568
569 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 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(¤t_start_date))
586 .ending(jiff_to_ib_datetime(¤t_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 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 if !should_continue_backward_pagination(
669 current_end_date,
670 current_start_date,
671 ) {
672 break;
673 }
674
675 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 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(¤t_start_date))
695 .ending(jiff_to_ib_datetime(¤t_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 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 if !should_continue_backward_pagination(
779 current_end_date,
780 current_start_date,
781 ) {
782 break;
783 }
784
785 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 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 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 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 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 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 if loaded_instruments.iter().any(|i| i.id() == instrument_id) {
912 continue;
913 }
914
915 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 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 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 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 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 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 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 let remaining_delta = minus_days_date.duration_since(start);
1015 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 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 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 segments.push((end_date, historical::Duration::days(1)));
1050 }
1051
1052 segments
1053 } else {
1054 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); 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}