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},
31 enums::{AggregationSource, BarAggregation, PriceType},
32 identifiers::InstrumentId,
33 instruments::{Instrument, any::InstrumentAny},
34 types::Quantity,
35};
36
37use crate::{
38 common::{
39 enums::IbHistoricalTickType,
40 shared_client::{self, SharedClientHandle},
41 symbology::is_crypto_contract,
42 },
43 config::{InteractiveBrokersDataClientConfig, MarketDataType},
44 data::{
45 convert::{
46 IbPriceConverter, bar_request_segments, bar_type_to_ib_bar_size,
47 calculate_duration_segments, extend_historical_tick_batch, ib_bar_to_nautilus_bar,
48 ib_timestamp_to_unix_nanos, jiff_to_ib_datetime,
49 price_type_to_ib_what_to_show_for_security, retain_historical_ticks_in_range,
50 },
51 parse::parse_trade_tick,
52 },
53 providers::instruments::InteractiveBrokersInstrumentProvider,
54};
55
56const HISTORICAL_TICK_DEFAULT_LIMIT: usize = 10_000;
57
58#[cfg_attr(
63 feature = "python",
64 pyo3::pyclass(
65 module = "nautilus_trader.adapters.interactive_brokers",
66 subclass,
67 from_py_object
68 )
69)]
70#[cfg_attr(
71 feature = "python",
72 pyo3_stub_gen::derive::gen_stub_pyclass(
73 module = "nautilus_trader.adapters.interactive_brokers"
74 )
75)]
76pub struct HistoricalInteractiveBrokersClient {
77 ib_client: Arc<Client>,
79 instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
81 _shared_client: Option<Arc<SharedClientHandle>>,
83}
84
85impl Clone for HistoricalInteractiveBrokersClient {
86 fn clone(&self) -> Self {
87 Self {
88 ib_client: Arc::clone(&self.ib_client),
89 instrument_provider: Arc::clone(&self.instrument_provider),
90 _shared_client: self._shared_client.clone(),
91 }
92 }
93}
94
95impl Debug for HistoricalInteractiveBrokersClient {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct(stringify!(HistoricalInteractiveBrokersClient))
98 .field("ib_client", &"<Client>")
99 .field("instrument_provider", &"<InstrumentProvider>")
100 .finish()
101 }
102}
103
104impl HistoricalInteractiveBrokersClient {
105 pub fn new(
107 ib_client: Arc<Client>,
108 instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
109 ) -> Self {
110 Self {
111 ib_client,
112 instrument_provider,
113 _shared_client: None,
114 }
115 }
116
117 pub async fn connect(config: InteractiveBrokersDataClientConfig) -> anyhow::Result<Self> {
126 config.validate()?;
127
128 let instrument_provider = Arc::new(InteractiveBrokersInstrumentProvider::new(
129 config.instrument_provider.clone(),
130 )?);
131 let shared_client = shared_client::get_or_connect(
132 &config.host,
133 config.port,
134 config.client_id,
135 config.connection_timeout,
136 config.max_reconnect_attempts,
137 )
138 .await?;
139 let client = shared_client.as_arc();
140
141 if config.market_data_type != MarketDataType::Realtime {
142 let market_data_type: ibapi::market_data::MarketDataType =
143 config.market_data_type.into();
144 client.switch_market_data_type(market_data_type).await?;
145 }
146
147 instrument_provider
148 .initialize_with_client(client.as_ref())
149 .await?;
150
151 Ok(Self::from_shared_client(shared_client, instrument_provider))
152 }
153
154 pub(crate) fn from_shared_client(
155 shared_client: SharedClientHandle,
156 instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
157 ) -> Self {
158 let ib_client = Arc::clone(shared_client.as_arc());
159
160 Self {
161 ib_client,
162 instrument_provider,
163 _shared_client: Some(Arc::new(shared_client)),
164 }
165 }
166
167 pub async fn connect_with_provider(
177 instrument_provider: InteractiveBrokersInstrumentProvider,
178 config: InteractiveBrokersDataClientConfig,
179 ) -> anyhow::Result<Self> {
180 config.validate()?;
181
182 let shared_client = shared_client::get_or_connect(
183 &config.host,
184 config.port,
185 config.client_id,
186 config.connection_timeout,
187 config.max_reconnect_attempts,
188 )
189 .await?;
190 let client = shared_client.as_arc();
191
192 if config.market_data_type != MarketDataType::Realtime {
193 let market_data_type: ibapi::market_data::MarketDataType =
194 config.market_data_type.into();
195 client.switch_market_data_type(market_data_type).await?;
196 }
197
198 instrument_provider
199 .initialize_with_client(client.as_ref())
200 .await?;
201
202 Ok(Self::from_shared_client(
203 shared_client,
204 Arc::new(instrument_provider),
205 ))
206 }
207
208 #[allow(clippy::too_many_arguments)]
223 pub async fn request_bars(
224 &self,
225 bar_specifications: Vec<&str>,
226 end_date_time: Timestamp,
227 start_date_time: Option<Timestamp>,
228 duration: Option<&str>,
229 contracts: Option<Vec<Contract>>,
230 instrument_ids: Option<Vec<InstrumentId>>,
231 use_rth: bool,
232 timeout: u64,
233 ) -> anyhow::Result<Vec<Bar>> {
234 if start_date_time.is_some() && duration.is_some() {
236 anyhow::bail!("Either start_date_time or duration should be provided, not both");
237 }
238
239 if let Some(start) = start_date_time
240 && start >= end_date_time
241 {
242 anyhow::bail!("Start date must be before end date");
243 }
244
245 let duration = duration
246 .map(str::parse::<historical::Duration>)
247 .transpose()
248 .with_context(|| {
249 format!(
250 "duration must be in format: 'int S|D|W|M|Y', was '{}'",
251 duration.unwrap_or_default()
252 )
253 })?;
254
255 let contracts = contracts.unwrap_or_default();
256 let instrument_ids = instrument_ids.unwrap_or_default();
257
258 if contracts.is_empty() && instrument_ids.is_empty() {
259 anyhow::bail!("Either contracts or instrument_ids must be provided");
260 }
261
262 let mut all_contracts = contracts;
264
265 for instrument_id in instrument_ids {
266 if self.instrument_provider.find(&instrument_id).is_none() {
268 if let Err(e) = self
270 .instrument_provider
271 .fetch_contract_details(&self.ib_client, instrument_id, false, None)
272 .await
273 {
274 tracing::warn!(
275 "Failed to auto-fetch contract details for {}: {}",
276 instrument_id,
277 e
278 );
279 }
280 }
281
282 if let Ok(contract) = self
284 .instrument_provider
285 .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
286 .await
287 {
288 all_contracts.push(contract);
289 } else {
290 tracing::warn!(
291 "Failed to convert instrument_id {} to IB contract, skipping",
292 instrument_id
293 );
294 }
295 }
296
297 for contract in &all_contracts {
299 if let Some(instrument_id) = self
300 .instrument_provider
301 .get_instrument_id_by_contract_id(contract.contract_id)
302 && self.instrument_provider.find(&instrument_id).is_none()
303 && let Err(e) = self
304 .instrument_provider
305 .fetch_contract_details(&self.ib_client, instrument_id, false, None)
306 .await
307 {
308 tracing::warn!(
309 "Failed to auto-fetch contract details for contract ID {}: {}",
310 contract.contract_id,
311 e
312 );
313 }
314 }
315
316 if all_contracts.is_empty() {
317 anyhow::bail!("No valid contracts found after conversion");
318 }
319
320 let trading_hours = if use_rth {
321 TradingHours::Regular
322 } else {
323 TradingHours::Extended
324 };
325
326 let mut all_bars = Vec::new();
327
328 for contract in all_contracts {
329 for bar_spec_str in &bar_specifications {
330 let parts: Vec<&str> = bar_spec_str.split('-').collect();
332 if parts.len() != 3 {
333 anyhow::bail!("Invalid bar specification format: {bar_spec_str}");
334 }
335
336 let step = parts[0].parse::<usize>()?;
337 let aggregation = parts[1].to_lowercase();
338 let price_type = parts[2].to_uppercase();
339 let price_type = PriceType::from_str(&price_type)
340 .with_context(|| format!("Invalid bar price type: {}", parts[2]))?;
341
342 let bar_spec = match aggregation.as_str() {
343 "second" => BarSpecification::new(step, BarAggregation::Second, price_type),
344 "minute" => BarSpecification::new(step, BarAggregation::Minute, price_type),
345 "hour" => BarSpecification::new(step, BarAggregation::Hour, price_type),
346 "day" => BarSpecification::new(step, BarAggregation::Day, price_type),
347 "week" => BarSpecification::new(step, BarAggregation::Week, price_type),
348 _ => anyhow::bail!("Unsupported aggregation: {aggregation}"),
349 };
350
351 let instrument_id = self.resolve_instrument_id(&contract).await?;
352 let bar_type_with_id =
353 BarType::new(instrument_id, bar_spec, AggregationSource::External);
354
355 let ib_bar_size = bar_type_to_ib_bar_size(&bar_type_with_id)?;
359 let is_crypto = is_crypto_contract(&contract);
360 let ib_what_to_show =
361 price_type_to_ib_what_to_show_for_security(bar_spec.price_type, is_crypto);
362
363 let is_continuous_future = contract.security_type == SecurityType::ContinuousFuture;
365 let segments = bar_request_segments(
366 calculate_duration_segments(start_date_time, Some(end_date_time), duration),
367 is_continuous_future,
368 );
369
370 for (segment_end, segment_duration) in segments {
371 tracing::debug!(
372 "Requesting historical bars ending on {:?} with duration {}",
373 segment_end,
374 segment_duration
375 );
376
377 let mut request = self
378 .ib_client
379 .historical_data(&contract, ib_bar_size)
380 .duration(segment_duration)
381 .what_to_show(ib_what_to_show)
382 .trading_hours(trading_hours);
383
384 if let Some(end) = segment_end {
385 request = request.ending(jiff_to_ib_datetime(&end));
386 }
387
388 let historical_data = tokio::time::timeout(
389 std::time::Duration::from_secs(timeout),
390 request.fetch(),
391 )
392 .await
393 .context(format!(
394 "Historical data request timed out after {timeout} seconds"
395 ))??;
396
397 let instrument =
398 self.instrument_provider
399 .find(&instrument_id)
400 .with_context(|| {
401 format!("Instrument {instrument_id} is missing from the provider")
402 })?;
403
404 let prices = IbPriceConverter::for_instrument(
405 &instrument,
406 self.instrument_provider.get_price_magnifier(&instrument_id),
407 );
408 let size_precision = instrument.size_precision();
409
410 for ib_bar in &historical_data.bars {
412 let nautilus_bar = ib_bar_to_nautilus_bar(
413 ib_bar,
414 bar_type_with_id,
415 prices,
416 size_precision,
417 )?;
418 all_bars.push(nautilus_bar);
419 }
420
421 tracing::debug!("Retrieved {} bars in batch", historical_data.bars.len());
422 }
423 }
424 }
425
426 all_bars.sort_by_key(|b| b.ts_event);
428
429 Ok(all_bars)
430 }
431
432 #[allow(clippy::too_many_arguments)]
438 pub async fn request_ticks(
439 &self,
440 tick_type: IbHistoricalTickType,
441 start_date_time: Timestamp,
442 end_date_time: Timestamp,
443 contracts: Option<Vec<Contract>>,
444 instrument_ids: Option<Vec<InstrumentId>>,
445 use_rth: bool,
446 timeout: u64,
447 limit: usize,
448 ) -> anyhow::Result<Vec<Data>> {
449 if start_date_time >= end_date_time {
450 anyhow::bail!("Start date must be before end date");
451 }
452
453 let limit = Some(if limit > 0 {
454 limit
455 } else {
456 HISTORICAL_TICK_DEFAULT_LIMIT
457 });
458
459 if end_date_time.duration_since(start_date_time) > jiff::SignedDuration::from_hours(24) {
460 tracing::warn!(
461 "Requesting tick data for more than 1 day may take a long time, particularly for liquid instruments"
462 );
463 }
464
465 let contracts = contracts.unwrap_or_default();
466 let instrument_ids = instrument_ids.unwrap_or_default();
467
468 if contracts.is_empty() && instrument_ids.is_empty() {
469 anyhow::bail!("Either contracts or instrument_ids must be provided");
470 }
471
472 let trading_hours = if use_rth {
473 TradingHours::Regular
474 } else {
475 TradingHours::Extended
476 };
477
478 let mut all_contracts = contracts;
480
481 for instrument_id in instrument_ids {
482 if self.instrument_provider.find(&instrument_id).is_none()
484 && let Err(e) = self
485 .instrument_provider
486 .fetch_contract_details(&self.ib_client, instrument_id, false, None)
487 .await
488 {
489 tracing::warn!(
490 "Failed to auto-fetch contract details for {}: {}",
491 instrument_id,
492 e
493 );
494 }
495
496 if let Ok(contract) = self
497 .instrument_provider
498 .resolve_contract_for_instrument_async(&self.ib_client, instrument_id)
499 .await
500 {
501 all_contracts.push(contract);
502 } else {
503 tracing::warn!(
504 "Failed to convert instrument_id {} to IB contract, skipping",
505 instrument_id
506 );
507 }
508 }
509
510 for contract in &all_contracts {
512 if let Some(instrument_id) = self
513 .instrument_provider
514 .get_instrument_id_by_contract_id(contract.contract_id)
515 && self.instrument_provider.find(&instrument_id).is_none()
516 && let Err(e) = self
517 .instrument_provider
518 .fetch_contract_details(&self.ib_client, instrument_id, false, None)
519 .await
520 {
521 tracing::warn!(
522 "Failed to auto-fetch contract details for contract ID {}: {}",
523 contract.contract_id,
524 e
525 );
526 }
527 }
528
529 if all_contracts.is_empty() {
530 anyhow::bail!("No valid contracts found after conversion");
531 }
532
533 let mut all_ticks = Vec::new();
534
535 for contract in all_contracts {
536 let instrument_id = self.resolve_instrument_id(&contract).await?;
537
538 let instrument = self
539 .instrument_provider
540 .find(&instrument_id)
541 .with_context(|| {
542 format!("Instrument {instrument_id} is missing from the provider")
543 })?;
544
545 let prices = IbPriceConverter::for_instrument(
546 &instrument,
547 self.instrument_provider.get_price_magnifier(&instrument_id),
548 );
549 let size_precision = instrument.size_precision();
550 let mut contract_ticks = Vec::new();
551
552 let mut current_end_date = Some(end_date_time);
554 let current_start_date = Some(start_date_time);
555 let start_date_time_ns = UnixNanos::from(
556 u64::try_from(start_date_time.as_nanosecond())
557 .context("Historical tick start date precedes the Unix epoch")?,
558 );
559 let end_date_time_ns = UnixNanos::from(
560 u64::try_from(end_date_time.as_nanosecond())
561 .context("Historical tick end date precedes the Unix epoch")?,
562 );
563
564 match tick_type {
565 IbHistoricalTickType::Trades => {
566 while let Some(request_end) = current_end_date {
567 let subscription = tokio::time::timeout(
569 std::time::Duration::from_secs(timeout),
570 self.ib_client
571 .historical_ticks(&contract, 1000)
572 .ending(jiff_to_ib_datetime(&request_end))
573 .trading_hours(trading_hours)
574 .trade(),
575 )
576 .await
577 .context(format!(
578 "Historical trades request timed out after {timeout} seconds"
579 ))??;
580
581 let mut subscription = subscription.filter_data();
582 let mut batch_ticks = Vec::new();
583
584 while let Some(tick_result) = subscription.next().await {
585 let tick = match tick_result {
586 Ok(tick) => tick,
587 Err(e) => {
588 tracing::warn!("Historical trade ticks stream error: {e:?}");
589 continue;
590 }
591 };
592
593 let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
594
595 if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
596 continue;
597 }
598
599 let ts_init = ts_event;
600
601 let Some(raw_size) = tick.size else {
602 tracing::warn!(
603 "Skipping historical trade tick with no size for {}",
604 instrument_id
605 );
606 continue;
607 };
608
609 if raw_size == 0.0 {
610 tracing::warn!(
611 "Skipping historical trade tick with zero size for {instrument_id}"
612 );
613 continue;
614 }
615
616 let trade_tick = parse_trade_tick(
617 instrument_id,
618 tick.price,
619 raw_size,
620 prices,
621 size_precision,
622 ts_event,
623 ts_init,
624 None,
625 )
626 .with_context(|| {
627 format!("Invalid historical trade tick for {instrument_id}")
628 })?;
629
630 batch_ticks.push(Data::Trade(trade_tick));
631 }
632
633 if !extend_historical_tick_batch(
634 &mut contract_ticks,
635 batch_ticks,
636 current_start_date,
637 &mut current_end_date,
638 Some(start_date_time_ns),
639 Some(end_date_time_ns),
640 limit,
641 data_ts_event,
642 ) {
643 break;
644 }
645 }
646 }
647 IbHistoricalTickType::BidAsk => {
648 while let Some(request_end) = current_end_date {
649 let subscription = tokio::time::timeout(
651 std::time::Duration::from_secs(timeout),
652 self.ib_client
653 .historical_ticks(&contract, 1000)
654 .ending(jiff_to_ib_datetime(&request_end))
655 .trading_hours(trading_hours)
656 .bid_ask(IgnoreSize::No),
657 )
658 .await
659 .context(format!(
660 "Historical bid/ask ticks request timed out after {timeout} seconds"
661 ))??;
662
663 let mut subscription = subscription.filter_data();
664 let mut batch_ticks = Vec::new();
665
666 while let Some(tick_result) = subscription.next().await {
667 let tick = match tick_result {
668 Ok(tick) => tick,
669 Err(e) => {
670 tracing::warn!("Historical bid/ask ticks stream error: {e:?}");
671 continue;
672 }
673 };
674
675 let ts_event = ib_timestamp_to_unix_nanos(&tick.timestamp);
676
677 if ts_event < start_date_time_ns || ts_event > end_date_time_ns {
678 continue;
679 }
680
681 let ts_init = ts_event;
682
683 let raw_bid_price = tick.price_bid;
684
685 let bid_price = prices
686 .bid_price(raw_bid_price)
687 .with_context(|| {
688 format!(
689 "Invalid historical bid price {raw_bid_price} for {instrument_id}"
690 )
691 })?;
692
693 let (Some(raw_bid_size), Some(raw_ask_size)) =
694 (tick.size_bid, tick.size_ask)
695 else {
696 tracing::warn!(
697 "Skipping historical quote tick with an absent size for {}",
698 instrument_id
699 );
700 continue;
701 };
702
703 let bid_size = Quantity::new_checked(raw_bid_size, size_precision)
704 .with_context(|| {
705 format!(
706 "Invalid historical bid size {raw_bid_size} for {instrument_id}"
707 )
708 })?;
709
710 let raw_ask_price = tick.price_ask;
711
712 let ask_price = prices
713 .ask_price(raw_ask_price)
714 .with_context(|| {
715 format!(
716 "Invalid historical ask price {raw_ask_price} for {instrument_id}"
717 )
718 })?;
719
720 let ask_size = Quantity::new_checked(raw_ask_size, size_precision)
721 .with_context(|| {
722 format!(
723 "Invalid historical ask size {raw_ask_size} for {instrument_id}"
724 )
725 })?;
726
727 let quote_tick = QuoteTick::new(
728 instrument_id,
729 bid_price,
730 ask_price,
731 bid_size,
732 ask_size,
733 ts_event,
734 ts_init,
735 );
736
737 batch_ticks.push(Data::Quote(quote_tick));
738 }
739
740 if !extend_historical_tick_batch(
741 &mut contract_ticks,
742 batch_ticks,
743 current_start_date,
744 &mut current_end_date,
745 Some(start_date_time_ns),
746 Some(end_date_time_ns),
747 limit,
748 data_ts_event,
749 ) {
750 break;
751 }
752 }
753 }
754 }
755
756 retain_historical_ticks_in_range(
757 &mut contract_ticks,
758 Some(start_date_time_ns),
759 Some(end_date_time_ns),
760 data_ts_event,
761 );
762 contract_ticks.sort_by_key(data_ts_event);
763 if let Some(limit) = limit
764 && contract_ticks.len() > limit
765 {
766 contract_ticks = contract_ticks.split_off(contract_ticks.len() - limit);
767 }
768
769 all_ticks.extend(contract_ticks);
770 }
771
772 all_ticks.sort_by_key(data_ts_event);
774
775 Ok(all_ticks)
776 }
777
778 pub async fn request_instruments(
790 &self,
791 instrument_ids: Option<Vec<InstrumentId>>,
792 contracts: Option<Vec<Contract>>,
793 ) -> anyhow::Result<Vec<InstrumentAny>> {
794 let instrument_ids = instrument_ids.unwrap_or_default();
795 let contracts = contracts.unwrap_or_default();
796
797 if instrument_ids.is_empty() && contracts.is_empty() {
798 anyhow::bail!("Either instrument_ids or contracts must be provided");
799 }
800
801 let loaded_ids = self
802 .instrument_provider
803 .load_ids_with_return_async(&self.ib_client, instrument_ids, None)
804 .await?;
805 let mut loaded_instruments = self.instrument_provider.find_all(&loaded_ids);
806
807 for contract in contracts {
809 match self
810 .instrument_provider
811 .get_instrument(&self.ib_client, &contract)
812 .await
813 {
814 Ok(Some(instrument)) => {
815 if !loaded_instruments.iter().any(|i| i.id() == instrument.id()) {
816 loaded_instruments.push(instrument);
817 }
818
819 continue;
820 }
821 Ok(None) => {}
822 Err(e) => {
823 tracing::warn!(
824 "Failed to fetch contract details from original contract {:?}: {}",
825 contract,
826 e
827 );
828 }
829 }
830
831 let instrument_id = if let Some(cached_id) = self
833 .instrument_provider
834 .get_instrument_id_by_contract_id(contract.contract_id)
835 {
836 Some(cached_id)
837 } else {
838 let venue = self.instrument_provider.determine_venue(&contract, None);
841 self.instrument_provider
842 .instrument_id_from_contract(&contract, venue)
843 .ok()
844 };
845
846 if let Some(instrument_id) = instrument_id {
847 if loaded_instruments.iter().any(|i| i.id() == instrument_id) {
849 continue;
850 }
851
852 if self.instrument_provider.find(&instrument_id).is_none() {
854 tracing::debug!("Fetching Instrument for: {}", instrument_id);
855
856 if let Err(e) = self
857 .instrument_provider
858 .fetch_contract_details(&self.ib_client, instrument_id, false, None)
859 .await
860 {
861 tracing::warn!(
862 "Failed to fetch contract details for {}: {}",
863 instrument_id,
864 e
865 );
866 continue;
867 }
868 }
869
870 if let Some(instrument) = self.instrument_provider.find(&instrument_id) {
871 loaded_instruments.push(instrument);
872 }
873 } else {
874 if let Ok(Some(instrument)) = self
876 .instrument_provider
877 .get_instrument(&self.ib_client, &contract)
878 .await
879 && !loaded_instruments.iter().any(|i| i.id() == instrument.id())
880 {
881 loaded_instruments.push(instrument);
882 }
883 }
884 }
885
886 tracing::debug!("Loaded {} instruments", loaded_instruments.len());
887
888 Ok(loaded_instruments)
889 }
890
891 async fn resolve_instrument_id(&self, contract: &Contract) -> anyhow::Result<InstrumentId> {
892 if let Some(instrument_id) = self
893 .instrument_provider
894 .get_instrument_id_by_contract_id(contract.contract_id)
895 {
896 return Ok(instrument_id);
897 }
898
899 let venue = self.instrument_provider.determine_venue(contract, None);
900 let parsed = self
901 .instrument_provider
902 .instrument_id_from_contract(contract, venue)
903 .ok();
904
905 if let Some(instrument_id) = parsed {
906 return Ok(instrument_id);
907 }
908
909 if let Ok(Some(instrument)) = self
910 .instrument_provider
911 .get_instrument(&self.ib_client, contract)
912 .await
913 {
914 return Ok(instrument.id());
915 }
916
917 anyhow::bail!(
918 "Failed to resolve instrument ID for contract {}:{}:{}",
919 contract.symbol,
920 contract.security_type,
921 contract.exchange
922 );
923 }
924}
925
926fn data_ts_event(data: &Data) -> UnixNanos {
927 match data {
928 Data::Trade(tick) => tick.ts_event,
929 Data::Quote(tick) => tick.ts_event,
930 _ => UnixNanos::default(),
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use nautilus_common::config::ConfigError;
937 use rstest::rstest;
938
939 use super::*;
940 use crate::config::InteractiveBrokersInstrumentProviderConfig;
941
942 #[rstest]
943 #[case::config(false)]
944 #[case::supplied_config(true)]
945 #[tokio::test]
946 async fn test_historical_connect_rejects_duplicates_before_connection(#[case] supplied: bool) {
947 let duplicate_provider: InteractiveBrokersInstrumentProviderConfig =
948 serde_json::from_value(serde_json::json!({
949 "load_contracts": [{"conId": 265598}, {"conId": 265598}]
950 }))
951 .unwrap();
952
953 let config = InteractiveBrokersDataClientConfig {
954 host: "127.0.0.1".to_string(),
955 port: 1,
956 connection_timeout: 1,
957 instrument_provider: duplicate_provider,
958 ..Default::default()
959 };
960
961 let result = if supplied {
962 let provider = InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap();
963
964 HistoricalInteractiveBrokersClient::connect_with_provider(provider, config).await
965 } else {
966 HistoricalInteractiveBrokersClient::connect(config).await
967 };
968
969 let error = result.expect_err("duplicate config must fail before gateway connection");
970 assert_eq!(
971 error.downcast_ref::<ConfigError>(),
972 Some(&ConfigError::duplicate("load_contracts", None))
973 );
974 }
975}