1use std::{
19 collections::{HashMap, HashSet},
20 fmt::Debug,
21};
22
23use nautilus_core::string::secret::SecretString;
24use nautilus_model::identifiers::InstrumentId;
25use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
26
27use crate::common::{
28 consts::{DEFAULT_CLIENT_ID, DEFAULT_HOST, DEFAULT_PORT},
29 contracts::{ConfiguredContract, parse_configured_contract_from_json},
30 enums::IbSecurityType,
31};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[cfg_attr(
36 feature = "python",
37 pyo3::pyclass(
38 module = "nautilus_trader.adapters.interactive_brokers",
39 from_py_object,
40 rename_all = "SCREAMING_SNAKE_CASE"
41 )
42)]
43#[cfg_attr(
44 feature = "python",
45 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
46 module = "nautilus_trader.adapters.interactive_brokers"
47 )
48)]
49#[derive(Default)]
50pub enum MarketDataType {
51 #[default]
53 Realtime = 1,
54 Frozen = 2,
56 Delayed = 3,
58 DelayedFrozen = 4,
60}
61
62impl From<MarketDataType> for ibapi::market_data::MarketDataType {
63 fn from(data_type: MarketDataType) -> Self {
64 match data_type {
65 MarketDataType::Realtime => Self::Realtime,
66 MarketDataType::Frozen => Self::Frozen,
67 MarketDataType::Delayed => Self::Delayed,
68 MarketDataType::DelayedFrozen => Self::DelayedFrozen,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
75#[serde(default)]
76#[cfg_attr(
77 feature = "python",
78 pyo3::pyclass(
79 module = "nautilus_trader.adapters.interactive_brokers",
80 subclass,
81 from_py_object
82 )
83)]
84#[cfg_attr(
85 feature = "python",
86 pyo3_stub_gen::derive::gen_stub_pyclass(
87 module = "nautilus_trader.adapters.interactive_brokers"
88 )
89)]
90pub struct InteractiveBrokersDataClientConfig {
91 #[builder(default = DEFAULT_HOST.to_string())]
93 pub host: String,
94 #[builder(default = DEFAULT_PORT)]
96 pub port: u16,
97 #[builder(default = DEFAULT_CLIENT_ID)]
99 pub client_id: i32,
100 #[builder(default = true)]
102 pub use_regular_trading_hours: bool,
103 #[builder(default)]
105 pub market_data_type: MarketDataType,
106 #[builder(default)]
108 pub ignore_quote_tick_size_updates: bool,
109 #[builder(default = 300)]
111 pub connection_timeout: u64,
112 #[builder(default = 60)]
116 pub request_timeout: u64,
117 #[builder(default)]
123 pub handle_revised_bars: bool,
124 #[builder(default = true)]
126 pub batch_quotes: bool,
127 #[builder(default = true)]
129 pub all_last_trades: bool,
130 pub subscription_idle_timeout_secs: Option<u64>,
132 #[builder(default)]
134 pub instrument_provider: InteractiveBrokersInstrumentProviderConfig,
135}
136
137impl InteractiveBrokersDataClientConfig {
138 pub(crate) fn validate(&self) -> anyhow::Result<()> {
139 anyhow::ensure!(
140 self.subscription_idle_timeout_secs != Some(0),
141 "subscription_idle_timeout_secs must be positive when set",
142 );
143
144 if let Some(timeout) = self.subscription_idle_timeout_secs {
145 anyhow::ensure!(
146 std::time::Instant::now()
147 .checked_add(std::time::Duration::from_secs(timeout))
148 .is_some(),
149 "subscription_idle_timeout_secs exceeds the supported clock range",
150 );
151 }
152
153 Ok(())
154 }
155}
156
157impl Default for InteractiveBrokersDataClientConfig {
158 fn default() -> Self {
159 Self::builder().build()
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
165#[serde(default)]
166#[cfg_attr(
167 feature = "python",
168 pyo3::pyclass(
169 module = "nautilus_trader.adapters.interactive_brokers",
170 subclass,
171 from_py_object
172 )
173)]
174#[cfg_attr(
175 feature = "python",
176 pyo3_stub_gen::derive::gen_stub_pyclass(
177 module = "nautilus_trader.adapters.interactive_brokers"
178 )
179)]
180pub struct InteractiveBrokersExecutionClientConfig {
181 #[builder(default = DEFAULT_HOST.to_string())]
183 pub host: String,
184 #[builder(default = DEFAULT_PORT)]
186 pub port: u16,
187 #[builder(default = DEFAULT_CLIENT_ID)]
189 pub client_id: i32,
190 pub account_id: Option<String>,
193 #[builder(default = 300)]
195 pub connection_timeout: u64,
196 #[builder(default = 60)]
198 pub request_timeout: u64,
199 #[builder(default)]
201 pub fetch_all_open_orders: bool,
202 #[builder(default)]
204 pub track_option_exercise_from_position_update: bool,
205 #[builder(default)]
207 pub instrument_provider: InteractiveBrokersInstrumentProviderConfig,
208}
209
210impl Default for InteractiveBrokersExecutionClientConfig {
211 fn default() -> Self {
212 Self::builder().build()
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(
219 feature = "python",
220 pyo3::pyclass(
221 module = "nautilus_trader.adapters.interactive_brokers",
222 from_py_object,
223 rename_all = "SCREAMING_SNAKE_CASE"
224 )
225)]
226#[cfg_attr(
227 feature = "python",
228 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
229 module = "nautilus_trader.adapters.interactive_brokers"
230 )
231)]
232#[derive(Default)]
233pub enum SymbologyMethod {
234 #[serde(rename = "simplified")]
236 #[default]
237 Simplified,
238 #[serde(rename = "raw")]
240 Raw,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
245#[serde(default)]
246#[cfg_attr(
247 feature = "python",
248 pyo3::pyclass(
249 module = "nautilus_trader.adapters.interactive_brokers",
250 subclass,
251 from_py_object
252 )
253)]
254#[cfg_attr(
255 feature = "python",
256 pyo3_stub_gen::derive::gen_stub_pyclass(
257 module = "nautilus_trader.adapters.interactive_brokers"
258 )
259)]
260pub struct InteractiveBrokersInstrumentProviderConfig {
261 #[builder(default)]
263 pub symbology_method: SymbologyMethod,
264 #[builder(default)]
266 pub load_ids: HashSet<InstrumentId>,
267 #[builder(default)]
269 #[serde(
270 deserialize_with = "deserialize_configured_contracts",
271 serialize_with = "serialize_configured_contracts"
272 )]
273 pub load_contracts: Vec<ConfiguredContract>,
274 pub min_expiry_days: Option<u32>,
276 pub max_expiry_days: Option<u32>,
278 pub build_options_chain: Option<bool>,
280 pub build_futures_chain: Option<bool>,
282 pub cache_validity_days: Option<u32>,
284 #[builder(default)]
286 pub convert_exchange_to_mic_venue: bool,
287 #[builder(default)]
289 pub symbol_to_mic_venue: HashMap<String, String>,
290 #[builder(default)]
292 pub filter_sec_types: HashSet<IbSecurityType>,
293 pub filter_callable: Option<String>,
297 pub cache_path: Option<String>,
300}
301
302impl Default for InteractiveBrokersInstrumentProviderConfig {
303 fn default() -> Self {
304 Self::builder().build()
305 }
306}
307
308fn deserialize_configured_contracts<'de, D>(
309 deserializer: D,
310) -> Result<Vec<ConfiguredContract>, D::Error>
311where
312 D: Deserializer<'de>,
313{
314 Vec::<serde_json::Value>::deserialize(deserializer)?
315 .iter()
316 .map(parse_configured_contract_from_json)
317 .collect::<anyhow::Result<Vec<_>>>()
318 .map_err(D::Error::custom)
319}
320
321fn serialize_configured_contracts<S>(
322 contracts: &[ConfiguredContract],
323 serializer: S,
324) -> Result<S::Ok, S::Error>
325where
326 S: Serializer,
327{
328 contracts
329 .iter()
330 .map(ConfiguredContract::to_json_value)
331 .collect::<Vec<_>>()
332 .serialize(serializer)
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[cfg_attr(
338 feature = "python",
339 pyo3::pyclass(
340 module = "nautilus_trader.adapters.interactive_brokers",
341 from_py_object,
342 rename_all = "SCREAMING_SNAKE_CASE"
343 )
344)]
345#[cfg_attr(
346 feature = "python",
347 pyo3_stub_gen::derive::gen_stub_pyclass_enum(
348 module = "nautilus_trader.adapters.interactive_brokers"
349 )
350)]
351#[derive(Default)]
352pub enum TradingMode {
353 #[serde(rename = "paper")]
355 #[default]
356 Paper,
357 #[serde(rename = "live")]
359 Live,
360}
361
362#[derive(Clone, Serialize, Deserialize, bon::Builder)]
367#[serde(default)]
368#[cfg_attr(
369 feature = "python",
370 pyo3::pyclass(
371 module = "nautilus_trader.adapters.interactive_brokers",
372 subclass,
373 from_py_object
374 )
375)]
376#[cfg_attr(
377 feature = "python",
378 pyo3_stub_gen::derive::gen_stub_pyclass(
379 module = "nautilus_trader.adapters.interactive_brokers"
380 )
381)]
382pub struct DockerizedIBGatewayConfig {
383 pub username: Option<SecretString>,
385 #[serde(skip_serializing)]
387 pub password: Option<SecretString>,
388 #[builder(default)]
390 pub trading_mode: TradingMode,
391 #[builder(default = true)]
393 pub read_only_api: bool,
394 #[builder(default = 300)]
396 pub timeout: u64,
397 #[builder(default = "ghcr.io/gnzsnz/ib-gateway:stable".to_string())]
399 pub container_image: String,
400 pub vnc_port: Option<u16>,
402}
403
404impl DockerizedIBGatewayConfig {
405 pub fn validate(&self) -> anyhow::Result<()> {
411 if self.timeout == 0 {
412 anyhow::bail!("Timeout must be greater than 0");
413 }
414
415 if self.timeout > 3600 {
416 anyhow::bail!("Timeout must be less than 3600 seconds");
417 }
418
419 if let Some(port) = self.vnc_port
420 && (!(5900..=5999).contains(&port))
421 {
422 anyhow::bail!("VNC port must be between 5900 and 5999");
423 }
424
425 Ok(())
426 }
427}
428
429impl Debug for DockerizedIBGatewayConfig {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.debug_struct(stringify!(DockerizedIBGatewayConfig))
432 .field("username", &self.username)
433 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
434 .field("trading_mode", &self.trading_mode)
435 .field("read_only_api", &self.read_only_api)
436 .field("timeout", &self.timeout)
437 .field("container_image", &self.container_image)
438 .field("vnc_port", &self.vnc_port)
439 .finish()
440 }
441}
442
443impl Default for DockerizedIBGatewayConfig {
444 fn default() -> Self {
445 Self::builder()
446 .maybe_username(std::env::var("TWS_USERNAME").ok().map(SecretString::from))
447 .maybe_password(std::env::var("TWS_PASSWORD").ok().map(SecretString::from))
448 .build()
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use rstest::rstest;
455
456 use super::{DockerizedIBGatewayConfig, InteractiveBrokersInstrumentProviderConfig};
457 use crate::common::enums::IbSecurityType;
458
459 #[rstest]
460 fn test_gateway_password_is_redacted_from_debug_and_serialization() {
461 let config = DockerizedIBGatewayConfig::builder()
462 .username(String::from("user").into())
463 .password(String::from("secret-password").into())
464 .build();
465
466 let debug = format!("{config:?}");
467 let json = serde_json::to_string(&config).unwrap();
468
469 assert!(!debug.contains("secret-password"));
470 assert!(debug.contains("<redacted>"));
471 assert!(!json.contains("secret-password"));
472 assert!(!json.contains("password"));
473 }
474
475 #[rstest]
476 fn test_instrument_provider_config_decodes_typed_contracts_and_filters() {
477 let config: InteractiveBrokersInstrumentProviderConfig =
478 serde_json::from_value(serde_json::json!({
479 "load_contracts": [{
480 "secType": "STK",
481 "symbol": "AAPL",
482 "exchange": "SMART",
483 "conId": 265598
484 }],
485 "filter_sec_types": ["opt"]
486 }))
487 .unwrap();
488
489 assert_eq!(config.load_contracts.len(), 1);
490 assert_eq!(config.load_contracts[0].contract.contract_id, 265598);
491 assert_eq!(
492 config.load_contracts[0].contract.security_type,
493 ibapi::contracts::SecurityType::Stock
494 );
495 assert!(config.filter_sec_types.contains(&IbSecurityType::Option));
496 }
497
498 #[rstest]
499 fn test_instrument_provider_config_keeps_per_contract_chain_settings() {
500 let config: InteractiveBrokersInstrumentProviderConfig =
501 serde_json::from_value(serde_json::json!({
502 "build_futures_chain": true,
503 "load_contracts": [
504 {
505 "secType": "STK",
506 "symbol": "SPY",
507 "exchange": "SMART",
508 "primaryExchange": "ARCA",
509 "build_options_chain": true,
510 "min_expiry_days": 7,
511 "max_expiry_days": 14
512 },
513 {
514 "secType": "FUT",
515 "exchange": "NYMEX",
516 "localSymbol": "CLZ6",
517 "build_futures_chain": false
518 }
519 ]
520 }))
521 .unwrap();
522
523 let spy = &config.load_contracts[0];
524 assert_eq!(spy.build_options_chain, Some(true));
525 assert_eq!(spy.build_futures_chain, None);
526 assert_eq!(spy.min_expiry_days, Some(7));
527 assert_eq!(spy.max_expiry_days, Some(14));
528 assert_eq!(spy.options_chain_exchange, None);
529 assert_eq!(
530 spy.chain_spec_json().unwrap(),
531 serde_json::json!({
532 "build_options_chain": true,
533 "min_expiry_days": 7,
534 "max_expiry_days": 14
535 })
536 );
537
538 let cl = &config.load_contracts[1];
539 assert_eq!(cl.build_futures_chain, Some(false));
540 assert_eq!(
541 cl.chain_spec_json().unwrap(),
542 serde_json::json!({"build_futures_chain": false})
543 );
544
545 let round_trip = serde_json::to_value(&config).unwrap();
546 assert_eq!(
547 round_trip["load_contracts"][0]["build_options_chain"],
548 serde_json::json!(true)
549 );
550 assert_eq!(
551 round_trip["load_contracts"][1]["build_futures_chain"],
552 serde_json::json!(false)
553 );
554 }
555
556 #[rstest]
557 fn test_instrument_provider_config_accepts_con_id_only_contract() {
558 let config: InteractiveBrokersInstrumentProviderConfig =
559 serde_json::from_value(serde_json::json!({
560 "load_contracts": [{"conId": 265598}]
561 }))
562 .unwrap();
563
564 assert_eq!(config.load_contracts[0].contract.contract_id, 265598);
565 assert_eq!(
566 config.load_contracts[0].contract.security_type,
567 ibapi::contracts::SecurityType::Stock
568 );
569 }
570
571 #[rstest]
572 #[case(
573 serde_json::json!({"load_contracts": [{"secType": "UNKNOWN"}]}),
574 "Unknown IB security type: UNKNOWN"
575 )]
576 #[case(
577 serde_json::json!({"load_contracts": [{"secType": "STK", "conId": "265598"}]}),
578 "Configured contract field 'conId' must be an integer"
579 )]
580 #[case(
581 serde_json::json!({"load_contracts": [{"symbol": "SPY"}]}),
582 "Configured contract requires 'secType' as a known IB security type string, or a positive 'conId'"
583 )]
584 #[case(
585 serde_json::json!({"load_contracts": [{"secType": "STK", "build_options_chain": "yes"}]}),
586 "Configured contract field 'build_options_chain' must be a boolean"
587 )]
588 #[case(
589 serde_json::json!({"load_contracts": [{"secType": "STK", "min_expiry_days": -1}]}),
590 "Configured contract field 'min_expiry_days' must be a non-negative integer"
591 )]
592 #[case(
593 serde_json::json!({"filter_sec_types": ["UNKNOWN"]}),
594 "Unknown IB security type: UNKNOWN"
595 )]
596 fn test_instrument_provider_config_rejects_invalid_typed_values(
597 #[case] value: serde_json::Value,
598 #[case] expected: &str,
599 ) {
600 let error = serde_json::from_value::<InteractiveBrokersInstrumentProviderConfig>(value)
601 .unwrap_err();
602
603 assert!(error.to_string().contains(expected), "{error}");
604 }
605}