1use std::{
19 collections::{HashMap, HashSet},
20 fmt::Debug,
21};
22
23use nautilus_common::config::{ConfigResult, check_unique_values};
24use nautilus_core::string::secret::SecretString;
25use nautilus_model::{identifiers::InstrumentId, types::Currency};
26use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
27
28use crate::common::{
29 consts::{DEFAULT_CLIENT_ID, DEFAULT_HOST, DEFAULT_PORT},
30 contracts::{ConfiguredContract, parse_configured_contract_from_json},
31 enums::IbSecurityType,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(
37 feature = "python",
38 pyo3::pyclass(
39 module = "nautilus_trader.adapters.interactive_brokers",
40 from_py_object,
41 rename_all = "SCREAMING_SNAKE_CASE"
42 )
43)]
44#[cfg_attr(
45 feature = "python",
46 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
47 module = "nautilus_trader.adapters.interactive_brokers"
48 )
49)]
50#[derive(Default)]
51pub enum MarketDataType {
52 #[default]
54 Realtime = 1,
55 Frozen = 2,
57 Delayed = 3,
59 DelayedFrozen = 4,
61}
62
63impl From<MarketDataType> for ibapi::market_data::MarketDataType {
64 fn from(data_type: MarketDataType) -> Self {
65 match data_type {
66 MarketDataType::Realtime => Self::Realtime,
67 MarketDataType::Frozen => Self::Frozen,
68 MarketDataType::Delayed => Self::Delayed,
69 MarketDataType::DelayedFrozen => Self::DelayedFrozen,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
76#[serde(default)]
77#[cfg_attr(
78 feature = "python",
79 pyo3::pyclass(
80 module = "nautilus_trader.adapters.interactive_brokers",
81 subclass,
82 from_py_object
83 )
84)]
85#[cfg_attr(
86 feature = "python",
87 pyo3_stub_gen::derive::gen_stub_pyclass(
88 module = "nautilus_trader.adapters.interactive_brokers"
89 )
90)]
91pub struct InteractiveBrokersDataClientConfig {
92 #[builder(default = DEFAULT_HOST.to_string())]
94 pub host: String,
95 #[builder(default = DEFAULT_PORT)]
97 pub port: u16,
98 #[builder(default = DEFAULT_CLIENT_ID)]
100 pub client_id: i32,
101 #[builder(default = true)]
103 pub use_regular_trading_hours: bool,
104 #[builder(default)]
106 pub market_data_type: MarketDataType,
107 #[builder(default)]
109 pub ignore_quote_tick_size_updates: bool,
110 #[builder(default = 300)]
112 pub connection_timeout: u64,
113 #[builder(default = 60)]
117 pub request_timeout: u64,
118 #[builder(default)]
124 pub handle_revised_bars: bool,
125 #[builder(default = true)]
127 pub batch_quotes: bool,
128 #[builder(default = true)]
130 pub all_last_trades: bool,
131 pub subscription_idle_timeout_secs: Option<u64>,
133 pub max_reconnect_attempts: Option<u32>,
137 #[builder(default)]
139 pub instrument_provider: InteractiveBrokersInstrumentProviderConfig,
140}
141
142impl InteractiveBrokersDataClientConfig {
143 pub(crate) fn validate(&self) -> anyhow::Result<()> {
144 self.instrument_provider.validate()?;
145
146 anyhow::ensure!(
147 self.subscription_idle_timeout_secs != Some(0),
148 "subscription_idle_timeout_secs must be positive when set",
149 );
150
151 if let Some(timeout) = self.subscription_idle_timeout_secs {
152 anyhow::ensure!(
153 std::time::Instant::now()
154 .checked_add(std::time::Duration::from_secs(timeout))
155 .is_some(),
156 "subscription_idle_timeout_secs exceeds the supported clock range",
157 );
158 }
159
160 Ok(())
161 }
162}
163
164impl Default for InteractiveBrokersDataClientConfig {
165 fn default() -> Self {
166 Self::builder().build()
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
172#[serde(default)]
173#[cfg_attr(
174 feature = "python",
175 pyo3::pyclass(
176 module = "nautilus_trader.adapters.interactive_brokers",
177 subclass,
178 from_py_object
179 )
180)]
181#[cfg_attr(
182 feature = "python",
183 pyo3_stub_gen::derive::gen_stub_pyclass(
184 module = "nautilus_trader.adapters.interactive_brokers"
185 )
186)]
187pub struct InteractiveBrokersExecutionClientConfig {
188 #[builder(default = DEFAULT_HOST.to_string())]
190 pub host: String,
191 #[builder(default = DEFAULT_PORT)]
193 pub port: u16,
194 #[builder(default = DEFAULT_CLIENT_ID)]
196 pub client_id: i32,
197 pub account_id: Option<String>,
200 pub base_currency: Option<Currency>,
203 #[builder(default = 300)]
205 pub connection_timeout: u64,
206 #[builder(default = 60)]
208 pub request_timeout: u64,
209 #[builder(default)]
211 pub fetch_all_open_orders: bool,
212 #[builder(default)]
214 pub track_option_exercise_from_position_update: bool,
215 pub max_reconnect_attempts: Option<u32>,
220 #[builder(default)]
222 pub instrument_provider: InteractiveBrokersInstrumentProviderConfig,
223}
224
225impl Default for InteractiveBrokersExecutionClientConfig {
226 fn default() -> Self {
227 Self::builder().build()
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(
234 feature = "python",
235 pyo3::pyclass(
236 module = "nautilus_trader.adapters.interactive_brokers",
237 from_py_object,
238 rename_all = "SCREAMING_SNAKE_CASE"
239 )
240)]
241#[cfg_attr(
242 feature = "python",
243 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
244 module = "nautilus_trader.adapters.interactive_brokers"
245 )
246)]
247#[derive(Default)]
248pub enum SymbologyMethod {
249 #[serde(rename = "simplified")]
251 #[default]
252 Simplified,
253 #[serde(rename = "raw")]
255 Raw,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
260#[serde(default)]
261#[cfg_attr(
262 feature = "python",
263 pyo3::pyclass(
264 module = "nautilus_trader.adapters.interactive_brokers",
265 subclass,
266 from_py_object
267 )
268)]
269#[cfg_attr(
270 feature = "python",
271 pyo3_stub_gen::derive::gen_stub_pyclass(
272 module = "nautilus_trader.adapters.interactive_brokers"
273 )
274)]
275pub struct InteractiveBrokersInstrumentProviderConfig {
276 #[builder(default)]
278 pub symbology_method: SymbologyMethod,
279 #[builder(default)]
281 pub load_ids: HashSet<InstrumentId>,
282 #[builder(default)]
284 #[serde(
285 deserialize_with = "deserialize_configured_contracts",
286 serialize_with = "serialize_configured_contracts"
287 )]
288 pub load_contracts: Vec<ConfiguredContract>,
289 pub min_expiry_days: Option<u32>,
291 pub max_expiry_days: Option<u32>,
293 pub build_options_chain: Option<bool>,
295 pub build_futures_chain: Option<bool>,
297 pub cache_validity_days: Option<u32>,
299 #[builder(default)]
301 pub convert_exchange_to_mic_venue: bool,
302 #[builder(default)]
304 pub symbol_to_mic_venue: HashMap<String, String>,
305 #[builder(default)]
307 pub filter_sec_types: HashSet<IbSecurityType>,
308 pub filter_callable: Option<String>,
312 pub cache_path: Option<String>,
315}
316
317impl Default for InteractiveBrokersInstrumentProviderConfig {
318 fn default() -> Self {
319 Self::builder().build()
320 }
321}
322
323fn deserialize_configured_contracts<'de, D>(
324 deserializer: D,
325) -> Result<Vec<ConfiguredContract>, D::Error>
326where
327 D: Deserializer<'de>,
328{
329 Vec::<serde_json::Value>::deserialize(deserializer)?
330 .iter()
331 .map(parse_configured_contract_from_json)
332 .collect::<anyhow::Result<Vec<_>>>()
333 .map_err(D::Error::custom)
334}
335
336fn serialize_configured_contracts<S>(
337 contracts: &[ConfiguredContract],
338 serializer: S,
339) -> Result<S::Ok, S::Error>
340where
341 S: Serializer,
342{
343 contracts
344 .iter()
345 .map(ConfiguredContract::to_json_value)
346 .collect::<Vec<_>>()
347 .serialize(serializer)
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(
353 feature = "python",
354 pyo3::pyclass(
355 module = "nautilus_trader.adapters.interactive_brokers",
356 from_py_object,
357 rename_all = "SCREAMING_SNAKE_CASE"
358 )
359)]
360#[cfg_attr(
361 feature = "python",
362 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
363 module = "nautilus_trader.adapters.interactive_brokers"
364 )
365)]
366#[derive(Default)]
367pub enum TradingMode {
368 #[serde(rename = "paper")]
370 #[default]
371 Paper,
372 #[serde(rename = "live")]
374 Live,
375}
376
377#[derive(Clone, Serialize, Deserialize, bon::Builder)]
382#[serde(default)]
383#[cfg_attr(
384 feature = "python",
385 pyo3::pyclass(
386 module = "nautilus_trader.adapters.interactive_brokers",
387 subclass,
388 from_py_object
389 )
390)]
391#[cfg_attr(
392 feature = "python",
393 pyo3_stub_gen::derive::gen_stub_pyclass(
394 module = "nautilus_trader.adapters.interactive_brokers"
395 )
396)]
397pub struct DockerizedIBGatewayConfig {
398 pub username: Option<SecretString>,
400 #[serde(skip_serializing)]
402 pub password: Option<SecretString>,
403 #[builder(default)]
405 pub trading_mode: TradingMode,
406 #[builder(default = true)]
408 pub read_only_api: bool,
409 #[builder(default = 300)]
411 pub timeout: u64,
412 #[builder(default = "ghcr.io/gnzsnz/ib-gateway:stable".to_string())]
414 pub container_image: String,
415 pub vnc_port: Option<u16>,
417}
418
419impl DockerizedIBGatewayConfig {
420 pub fn validate(&self) -> anyhow::Result<()> {
426 if self.timeout == 0 {
427 anyhow::bail!("Timeout must be greater than 0");
428 }
429
430 if self.timeout > 3600 {
431 anyhow::bail!("Timeout must be less than 3600 seconds");
432 }
433
434 if let Some(port) = self.vnc_port
435 && (!(5900..=5999).contains(&port))
436 {
437 anyhow::bail!("VNC port must be between 5900 and 5999");
438 }
439
440 Ok(())
441 }
442}
443
444impl Debug for DockerizedIBGatewayConfig {
445 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446 f.debug_struct(stringify!(DockerizedIBGatewayConfig))
447 .field("username", &self.username)
448 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
449 .field("trading_mode", &self.trading_mode)
450 .field("read_only_api", &self.read_only_api)
451 .field("timeout", &self.timeout)
452 .field("container_image", &self.container_image)
453 .field("vnc_port", &self.vnc_port)
454 .finish()
455 }
456}
457
458impl Default for DockerizedIBGatewayConfig {
459 fn default() -> Self {
460 Self::builder()
461 .maybe_username(std::env::var("TWS_USERNAME").ok().map(SecretString::from))
462 .maybe_password(std::env::var("TWS_PASSWORD").ok().map(SecretString::from))
463 .build()
464 }
465}
466
467impl InteractiveBrokersInstrumentProviderConfig {
468 pub fn validate(&self) -> ConfigResult<()> {
474 check_unique_values("load_contracts", &self.load_contracts)
475 }
476}
477
478impl InteractiveBrokersExecutionClientConfig {
479 pub fn validate(&self) -> ConfigResult<()> {
485 self.instrument_provider.validate()
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use nautilus_common::config::ConfigError;
492 use nautilus_model::types::Currency;
493 use rstest::rstest;
494
495 use super::{
496 DockerizedIBGatewayConfig, InteractiveBrokersExecutionClientConfig,
497 InteractiveBrokersInstrumentProviderConfig,
498 };
499 use crate::common::enums::IbSecurityType;
500
501 #[rstest]
502 fn test_gateway_password_is_redacted_from_debug_and_serialization() {
503 let config = DockerizedIBGatewayConfig::builder()
504 .username(String::from("user").into())
505 .password(String::from("secret-password").into())
506 .build();
507
508 let debug = format!("{config:?}");
509 let json = serde_json::to_string(&config).unwrap();
510
511 assert!(!debug.contains("secret-password"));
512 assert!(debug.contains("<redacted>"));
513 assert!(!json.contains("secret-password"));
514 assert!(!json.contains("password"));
515 }
516
517 #[rstest]
518 fn test_execution_client_config_base_currency() {
519 let config: InteractiveBrokersExecutionClientConfig =
520 serde_json::from_value(serde_json::json!({"base_currency": "EUR"})).unwrap();
521
522 assert_eq!(config.base_currency, Some(Currency::EUR()));
523 assert_eq!(
524 InteractiveBrokersExecutionClientConfig::default().base_currency,
525 None
526 );
527 }
528
529 #[rstest]
530 fn test_instrument_provider_config_decodes_typed_contracts_and_filters() {
531 let config: InteractiveBrokersInstrumentProviderConfig =
532 serde_json::from_value(serde_json::json!({
533 "load_contracts": [{
534 "secType": "STK",
535 "symbol": "AAPL",
536 "exchange": "SMART",
537 "conId": 265598
538 }],
539 "filter_sec_types": ["opt"]
540 }))
541 .unwrap();
542
543 assert_eq!(config.load_contracts.len(), 1);
544 assert_eq!(config.load_contracts[0].contract.contract_id, 265598);
545 assert_eq!(
546 config.load_contracts[0].contract.security_type,
547 ibapi::contracts::SecurityType::Stock
548 );
549 assert!(config.filter_sec_types.contains(&IbSecurityType::Option));
550 }
551
552 #[rstest]
553 fn test_instrument_provider_config_keeps_per_contract_chain_settings() {
554 let config: InteractiveBrokersInstrumentProviderConfig =
555 serde_json::from_value(serde_json::json!({
556 "build_futures_chain": true,
557 "load_contracts": [
558 {
559 "secType": "STK",
560 "symbol": "SPY",
561 "exchange": "SMART",
562 "primaryExchange": "ARCA",
563 "build_options_chain": true,
564 "min_expiry_days": 7,
565 "max_expiry_days": 14
566 },
567 {
568 "secType": "FUT",
569 "exchange": "NYMEX",
570 "localSymbol": "CLZ6",
571 "build_futures_chain": false
572 }
573 ]
574 }))
575 .unwrap();
576
577 let spy = &config.load_contracts[0];
578 assert_eq!(spy.build_options_chain, Some(true));
579 assert_eq!(spy.build_futures_chain, None);
580 assert_eq!(spy.min_expiry_days, Some(7));
581 assert_eq!(spy.max_expiry_days, Some(14));
582 assert_eq!(spy.options_chain_exchange, None);
583 assert_eq!(
584 spy.chain_spec_json().unwrap(),
585 serde_json::json!({
586 "build_options_chain": true,
587 "min_expiry_days": 7,
588 "max_expiry_days": 14
589 })
590 );
591
592 let cl = &config.load_contracts[1];
593 assert_eq!(cl.build_futures_chain, Some(false));
594 assert_eq!(
595 cl.chain_spec_json().unwrap(),
596 serde_json::json!({"build_futures_chain": false})
597 );
598
599 let round_trip = serde_json::to_value(&config).unwrap();
600 assert_eq!(
601 round_trip["load_contracts"][0]["build_options_chain"],
602 serde_json::json!(true)
603 );
604 assert_eq!(
605 round_trip["load_contracts"][1]["build_futures_chain"],
606 serde_json::json!(false)
607 );
608 }
609
610 #[rstest]
611 fn test_instrument_provider_config_accepts_con_id_only_contract() {
612 let config: InteractiveBrokersInstrumentProviderConfig =
613 serde_json::from_value(serde_json::json!({
614 "load_contracts": [{"conId": 265598}]
615 }))
616 .unwrap();
617
618 assert_eq!(config.load_contracts[0].contract.contract_id, 265598);
619 assert_eq!(
620 config.load_contracts[0].contract.security_type,
621 ibapi::contracts::SecurityType::Stock
622 );
623 }
624
625 #[rstest]
626 #[case(
627 serde_json::json!({"load_contracts": [{"secType": "UNKNOWN"}]}),
628 "Unknown IB security type: UNKNOWN"
629 )]
630 #[case(
631 serde_json::json!({"load_contracts": [{"secType": "STK", "conId": "265598"}]}),
632 "Configured contract field 'conId' must be an integer"
633 )]
634 #[case(
635 serde_json::json!({"load_contracts": [{"symbol": "SPY"}]}),
636 "Configured contract requires 'secType' as a known IB security type string, or a positive 'conId'"
637 )]
638 #[case(
639 serde_json::json!({"load_contracts": [{"secType": "STK", "build_options_chain": "yes"}]}),
640 "Configured contract field 'build_options_chain' must be a boolean"
641 )]
642 #[case(
643 serde_json::json!({"load_contracts": [{"secType": "STK", "min_expiry_days": -1}]}),
644 "Configured contract field 'min_expiry_days' must be a non-negative integer"
645 )]
646 #[case(
647 serde_json::json!({"filter_sec_types": ["UNKNOWN"]}),
648 "Unknown IB security type: UNKNOWN"
649 )]
650 fn test_instrument_provider_config_rejects_invalid_typed_values(
651 #[case] value: serde_json::Value,
652 #[case] expected: &str,
653 ) {
654 let error = serde_json::from_value::<InteractiveBrokersInstrumentProviderConfig>(value)
655 .unwrap_err();
656
657 assert!(error.to_string().contains(expected), "{error}");
658 }
659
660 #[rstest]
661 #[case("load_contracts", serde_json::json!([{"secType": "STK", "conId": 265598}, {"secType": "STK", "conId": 756733}]))]
662 fn test_instrument_provider_selection_uniqueness(
663 #[case] field: &str,
664 #[case] mut values: serde_json::Value,
665 #[values(false, true)] duplicate: bool,
666 ) {
667 if duplicate {
668 let entries = values.as_array_mut().unwrap();
669 entries.push(entries[0].clone());
670 }
671
672 let mut input =
673 serde_json::to_value(InteractiveBrokersInstrumentProviderConfig::default()).unwrap();
674 input[field] = values;
675 let config: InteractiveBrokersInstrumentProviderConfig =
676 serde_json::from_value(input).unwrap();
677 let original = serde_json::to_value(&config).unwrap();
678
679 let expected = if duplicate {
680 Err(ConfigError::duplicate(field, None))
681 } else {
682 Ok(())
683 };
684
685 let result = config.validate();
686
687 assert_eq!(result, expected);
688 assert_eq!(serde_json::to_value(&config).unwrap(), original);
689 }
690
691 #[rstest]
692 fn test_client_configs_reject_duplicate_provider_selections() {
693 let provider: InteractiveBrokersInstrumentProviderConfig = serde_json::from_value(serde_json::json!({
694 "load_contracts": [{"secType": "STK", "conId": 265598}, {"secType": "STK", "conId": 265598}]
695 })).unwrap();
696
697 let data = super::InteractiveBrokersDataClientConfig {
698 instrument_provider: provider.clone(),
699 ..Default::default()
700 };
701
702 let execution = InteractiveBrokersExecutionClientConfig {
703 instrument_provider: provider,
704 ..Default::default()
705 };
706
707 let data_result = data
708 .validate()
709 .map_err(|e| e.downcast::<ConfigError>().unwrap());
710 let execution_result = execution.validate();
711 let expected = Err(ConfigError::duplicate("load_contracts", None));
712
713 assert_eq!(data_result, expected);
714 assert_eq!(execution_result, expected);
715 }
716
717 #[rstest]
718 #[case::data(false)]
719 #[case::execution(true)]
720 fn test_client_constructor_rejects_duplicate_config_before_setup(#[case] execution: bool) {
721 use std::{cell::RefCell, rc::Rc, sync::Arc};
722
723 use nautilus_common::cache::Cache;
724 use nautilus_live::ExecutionClientCore;
725 use nautilus_model::{
726 enums::{AccountType, OmsType},
727 identifiers::{AccountId, ClientId, TraderId, Venue},
728 };
729
730 use crate::{
731 data::InteractiveBrokersDataClient, execution::InteractiveBrokersExecutionClient,
732 providers::instruments::InteractiveBrokersInstrumentProvider,
733 };
734
735 let duplicate_config: InteractiveBrokersInstrumentProviderConfig =
736 serde_json::from_value(serde_json::json!({
737 "load_contracts": [{"conId": 265598}, {"conId": 265598}]
738 }))
739 .unwrap();
740 let provider =
741 Arc::new(InteractiveBrokersInstrumentProvider::new(Default::default()).unwrap());
742
743 let error = if execution {
744 let core = ExecutionClientCore::new(
745 TraderId::from("TESTER-001"),
746 ClientId::from("IB"),
747 Venue::from("IB"),
748 OmsType::Netting,
749 AccountId::from("IB-001"),
750 AccountType::Margin,
751 None,
752 Rc::new(RefCell::new(Cache::default())),
753 );
754
755 let config = InteractiveBrokersExecutionClientConfig {
756 client_id: 1,
757 instrument_provider: duplicate_config,
758 ..Default::default()
759 };
760
761 InteractiveBrokersExecutionClient::new(core, config, provider).err()
762 } else {
763 InteractiveBrokersDataClient::new(
764 ClientId::from("IB"),
765 super::InteractiveBrokersDataClientConfig {
766 instrument_provider: duplicate_config,
767 ..Default::default()
768 },
769 provider,
770 )
771 .err()
772 }
773 .expect("duplicate config must fail before client setup or connection");
774
775 assert_eq!(
776 error.downcast_ref::<ConfigError>(),
777 Some(&ConfigError::duplicate("load_contracts", None))
778 );
779 }
780
781 #[rstest]
782 fn test_provider_constructor_rejects_duplicate_config() {
783 let config = serde_json::from_value(serde_json::json!({
784 "load_contracts": [{"conId": 265598}, {"conId": 265598}]
785 }))
786 .unwrap();
787
788 let error =
789 crate::providers::instruments::InteractiveBrokersInstrumentProvider::new(config)
790 .unwrap_err();
791
792 assert_eq!(error, ConfigError::duplicate("load_contracts", None));
793 }
794}