1use 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#[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_client: Arc<Client>,
65 instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
67 _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 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 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 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 #[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 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 let mut all_contracts = contracts;
245
246 for instrument_id in instrument_ids {
247 if self.instrument_provider.find(&instrument_id).is_none() {
249 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 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 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 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 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 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 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) };
392 let price_magnifier =
393 self.instrument_provider.get_price_magnifier(&instrument_id);
394
395 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 all_bars.sort_by_key(|b| b.ts_event);
414
415 Ok(all_bars)
416 }
417
418 #[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 let mut all_contracts = contracts;
473
474 for instrument_id in instrument_ids {
475 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 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 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) };
538 let price_magnifier = self.instrument_provider.get_price_magnifier(&instrument_id);
539 let contract_start_len = all_ticks.len();
540
541 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 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(¤t_start_date))
566 .ending(chrono_to_ib_datetime(¤t_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 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 if !should_continue_backward_pagination(
641 current_end_date,
642 current_start_date,
643 ) {
644 break;
645 }
646
647 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 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(¤t_start_date))
667 .ending(chrono_to_ib_datetime(¤t_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 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 if !should_continue_backward_pagination(
743 current_end_date,
744 current_start_date,
745 ) {
746 break;
747 }
748
749 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 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 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 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 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 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 if loaded_instruments.iter().any(|i| i.id() == instrument_id) {
876 continue;
877 }
878
879 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 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 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 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 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 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 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 let remaining_delta = minus_days_date.signed_duration_since(start);
979 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 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 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 segments.push((end_date, historical::Duration::days(1)));
1014 }
1015
1016 segments
1017 } else {
1018 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); 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}