Skip to main content

nautilus_interactive_brokers/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration types for the Interactive Brokers adapter.
17
18use 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/// Market data type for switching between real-time and frozen/delayed.
34#[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    /// Live market data
52    #[default]
53    Realtime = 1,
54    /// Frozen market data (for when market is closed)
55    Frozen = 2,
56    /// Delayed market data (usually 15-20 minutes)
57    Delayed = 3,
58    /// Delayed frozen market data
59    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/// Configuration for Interactive Brokers data client.
74#[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    /// Host for IB Gateway/TWS.
92    #[builder(default = DEFAULT_HOST.to_string())]
93    pub host: String,
94    /// Port for IB Gateway/TWS.
95    #[builder(default = DEFAULT_PORT)]
96    pub port: u16,
97    /// Client ID.
98    #[builder(default = DEFAULT_CLIENT_ID)]
99    pub client_id: i32,
100    /// Whether to use regular trading hours only (RTH filtering).
101    #[builder(default = true)]
102    pub use_regular_trading_hours: bool,
103    /// Market data type (realtime, delayed, frozen).
104    #[builder(default)]
105    pub market_data_type: MarketDataType,
106    /// Whether to ignore quote tick size updates (filters size-only updates).
107    #[builder(default)]
108    pub ignore_quote_tick_size_updates: bool,
109    /// Connection timeout in seconds.
110    #[builder(default = 300)]
111    pub connection_timeout: u64,
112    /// Request timeout in seconds. Applied to IB API requests (open orders, executions, positions,
113    /// account summary, order update stream, next order id). See execution/core.rs and
114    /// execution/account.rs for call sites.
115    #[builder(default = 60)]
116    pub request_timeout: u64,
117    /// Whether to publish every revision of an open bar.
118    ///
119    /// When `false`, a bar that IB keeps up to date is published once it completes: on IB's
120    /// update after the bar closes, 2 seconds after the close, or on the next bar's first update,
121    /// whichever comes first.
122    #[builder(default)]
123    pub handle_revised_bars: bool,
124    /// Whether to use batch quotes (reqMktData) by default instead of tick-by-tick.
125    #[builder(default = true)]
126    pub batch_quotes: bool,
127    /// Whether to include special-condition trades in tick-by-tick subscriptions.
128    #[builder(default = true)]
129    pub all_last_trades: bool,
130    /// Optional interval without incoming market data before emitting a subscription idle event.
131    pub subscription_idle_timeout_secs: Option<u64>,
132    /// Instrument provider configuration.
133    #[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/// Configuration for Interactive Brokers execution client.
164#[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    /// Host for IB Gateway/TWS.
182    #[builder(default = DEFAULT_HOST.to_string())]
183    pub host: String,
184    /// Port for IB Gateway/TWS.
185    #[builder(default = DEFAULT_PORT)]
186    pub port: u16,
187    /// Client ID.
188    #[builder(default = DEFAULT_CLIENT_ID)]
189    pub client_id: i32,
190    /// Raw IB account code, such as `DU123456`. The Nautilus account ID becomes
191    /// `{client name}-{code}`.
192    pub account_id: Option<String>,
193    /// Connection timeout in seconds.
194    #[builder(default = 300)]
195    pub connection_timeout: u64,
196    /// Request timeout in seconds for IB API requests (open orders, executions, positions, etc.).
197    #[builder(default = 60)]
198    pub request_timeout: u64,
199    /// Whether to fetch all open orders (reqAllOpenOrders vs reqOpenOrders).
200    #[builder(default)]
201    pub fetch_all_open_orders: bool,
202    /// Whether to track option exercise from position updates.
203    #[builder(default)]
204    pub track_option_exercise_from_position_update: bool,
205    /// Instrument provider configuration.
206    #[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/// Symbology method for converting between IB contracts and Nautilus instrument IDs.
217#[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    /// Simplified symbology: clean, readable symbols (e.g., "EUR/USD", "ESM23")
235    #[serde(rename = "simplified")]
236    #[default]
237    Simplified,
238    /// Raw symbology: preserves IB raw format with security type suffix (e.g., "EUR.USD=CASH", "AAPL=STK")
239    #[serde(rename = "raw")]
240    Raw,
241}
242
243/// Configuration for Interactive Brokers instrument provider.
244#[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    /// Symbology method to use for instrument ID conversion.
262    #[builder(default)]
263    pub symbology_method: SymbologyMethod,
264    /// Instrument IDs to load on startup.
265    #[builder(default)]
266    pub load_ids: HashSet<InstrumentId>,
267    /// IB contracts to load on startup.
268    #[builder(default)]
269    #[serde(
270        deserialize_with = "deserialize_configured_contracts",
271        serialize_with = "serialize_configured_contracts"
272    )]
273    pub load_contracts: Vec<ConfiguredContract>,
274    /// Minimum expiry days for options and futures chains.
275    pub min_expiry_days: Option<u32>,
276    /// Maximum expiry days for options and futures chains.
277    pub max_expiry_days: Option<u32>,
278    /// Whether to build full options chain.
279    pub build_options_chain: Option<bool>,
280    /// Whether to build full futures chain.
281    pub build_futures_chain: Option<bool>,
282    /// Cache validity in days (None means no caching).
283    pub cache_validity_days: Option<u32>,
284    /// Whether to convert IB exchanges to MIC venues.
285    #[builder(default)]
286    pub convert_exchange_to_mic_venue: bool,
287    /// Symbol to MIC venue mapping override.
288    #[builder(default)]
289    pub symbol_to_mic_venue: HashMap<String, String>,
290    /// Security types to filter out.
291    #[builder(default)]
292    pub filter_sec_types: HashSet<IbSecurityType>,
293    /// Fully-qualified Python callable path for custom instrument filtering.
294    ///
295    /// Configuring this without the Python feature enabled is an error.
296    pub filter_callable: Option<String>,
297    /// Path to cache file for persistent instrument caching (equivalent to pickle_path in Python).
298    /// If provided, instruments will be cached to disk and loaded from cache if still valid.
299    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/// Trading mode for Dockerized IB Gateway.
336#[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    /// Paper trading mode.
354    #[serde(rename = "paper")]
355    #[default]
356    Paper,
357    /// Live trading mode.
358    #[serde(rename = "live")]
359    Live,
360}
361
362/// Configuration for Dockerized IB Gateway.
363///
364/// This configuration is for managing containerized IB Gateway instances.
365/// It supports environment variable loading and sensitive data masking.
366#[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    /// Username for IB account (falls back to `TWS_USERNAME` env var via [`Default`]).
384    pub username: Option<SecretString>,
385    /// Password for IB account (falls back to `TWS_PASSWORD` env var via [`Default`]).
386    #[serde(skip_serializing)]
387    pub password: Option<SecretString>,
388    /// Trading mode (paper or live).
389    #[builder(default)]
390    pub trading_mode: TradingMode,
391    /// Whether to enable read-only API mode.
392    #[builder(default = true)]
393    pub read_only_api: bool,
394    /// Timeout in seconds for container startup.
395    #[builder(default = 300)]
396    pub timeout: u64,
397    /// Container image reference.
398    #[builder(default = "ghcr.io/gnzsnz/ib-gateway:stable".to_string())]
399    pub container_image: String,
400    /// VNC port for remote desktop access (None to disable).
401    pub vnc_port: Option<u16>,
402}
403
404impl DockerizedIBGatewayConfig {
405    /// Validate configuration.
406    ///
407    /// # Errors
408    ///
409    /// Returns an error if validation fails.
410    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}