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_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/// Market data type for switching between real-time and frozen/delayed.
35#[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    /// Live market data
53    #[default]
54    Realtime = 1,
55    /// Frozen market data (for when market is closed)
56    Frozen = 2,
57    /// Delayed market data (usually 15-20 minutes)
58    Delayed = 3,
59    /// Delayed frozen market data
60    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/// Configuration for Interactive Brokers data client.
75#[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    /// Host for IB Gateway/TWS.
93    #[builder(default = DEFAULT_HOST.to_string())]
94    pub host: String,
95    /// Port for IB Gateway/TWS.
96    #[builder(default = DEFAULT_PORT)]
97    pub port: u16,
98    /// Client ID.
99    #[builder(default = DEFAULT_CLIENT_ID)]
100    pub client_id: i32,
101    /// Whether to use regular trading hours only (RTH filtering).
102    #[builder(default = true)]
103    pub use_regular_trading_hours: bool,
104    /// Market data type (realtime, delayed, frozen).
105    #[builder(default)]
106    pub market_data_type: MarketDataType,
107    /// Whether to ignore quote tick size updates (filters size-only updates).
108    #[builder(default)]
109    pub ignore_quote_tick_size_updates: bool,
110    /// Connection timeout in seconds.
111    #[builder(default = 300)]
112    pub connection_timeout: u64,
113    /// Request timeout in seconds. Applied to IB API requests (open orders, executions, positions,
114    /// account summary, order update stream, next order id). See execution/core.rs and
115    /// execution/account.rs for call sites.
116    #[builder(default = 60)]
117    pub request_timeout: u64,
118    /// Whether to publish every revision of an open bar.
119    ///
120    /// When `false`, a bar that IB keeps up to date is published once it completes: on IB's
121    /// update after the bar closes, 2 seconds after the close, or on the next bar's first update,
122    /// whichever comes first.
123    #[builder(default)]
124    pub handle_revised_bars: bool,
125    /// Whether to use batch quotes (reqMktData) by default instead of tick-by-tick.
126    #[builder(default = true)]
127    pub batch_quotes: bool,
128    /// Whether to include special-condition trades in tick-by-tick subscriptions.
129    #[builder(default = true)]
130    pub all_last_trades: bool,
131    /// Optional interval without incoming market data before emitting a subscription idle event.
132    pub subscription_idle_timeout_secs: Option<u64>,
133    /// Bound on ibapi's automatic reconnect attempts for the shared client connection
134    /// (`ClientBuilder::max_reconnect_attempts`). `None` keeps ibapi's default. `Some(0)`
135    /// disables reconnection; `Some(u32::MAX)` retries for practical purposes forever.
136    pub max_reconnect_attempts: Option<u32>,
137    /// Instrument provider configuration.
138    #[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/// Configuration for Interactive Brokers execution client.
171#[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    /// Host for IB Gateway/TWS.
189    #[builder(default = DEFAULT_HOST.to_string())]
190    pub host: String,
191    /// Port for IB Gateway/TWS.
192    #[builder(default = DEFAULT_PORT)]
193    pub port: u16,
194    /// Client ID.
195    #[builder(default = DEFAULT_CLIENT_ID)]
196    pub client_id: i32,
197    /// Raw IB account code, such as `DU123456`. The Nautilus account ID becomes
198    /// `{client name}-{code}`.
199    pub account_id: Option<String>,
200    /// Base currency for the account, normally the currency IB reports the account summary in.
201    /// `None` keeps a multi-currency account with one balance per currency.
202    pub base_currency: Option<Currency>,
203    /// Connection timeout in seconds.
204    #[builder(default = 300)]
205    pub connection_timeout: u64,
206    /// Request timeout in seconds for IB API requests (open orders, executions, positions, etc.).
207    #[builder(default = 60)]
208    pub request_timeout: u64,
209    /// Whether to fetch all open orders (reqAllOpenOrders vs reqOpenOrders).
210    #[builder(default)]
211    pub fetch_all_open_orders: bool,
212    /// Whether to track option exercise from position updates.
213    #[builder(default)]
214    pub track_option_exercise_from_position_update: bool,
215    /// Bound on ibapi's automatic reconnect attempts, see the field of the same name on
216    /// [`InteractiveBrokersDataClientConfig`]. The shared-client registry connects once
217    /// per `(host, port, client_id)`, so when data and exec share a connection the first
218    /// creator's value wins.
219    pub max_reconnect_attempts: Option<u32>,
220    /// Instrument provider configuration.
221    #[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/// Symbology method for converting between IB contracts and Nautilus instrument IDs.
232#[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    /// Simplified symbology: clean, readable symbols (e.g., "EUR/USD", "ESM23")
250    #[serde(rename = "simplified")]
251    #[default]
252    Simplified,
253    /// Raw symbology: preserves IB raw format with security type suffix (e.g., "EUR.USD=CASH", "AAPL=STK")
254    #[serde(rename = "raw")]
255    Raw,
256}
257
258/// Configuration for Interactive Brokers instrument provider.
259#[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    /// Symbology method to use for instrument ID conversion.
277    #[builder(default)]
278    pub symbology_method: SymbologyMethod,
279    /// Instrument IDs to load on startup.
280    #[builder(default)]
281    pub load_ids: HashSet<InstrumentId>,
282    /// IB contracts to load on startup.
283    #[builder(default)]
284    #[serde(
285        deserialize_with = "deserialize_configured_contracts",
286        serialize_with = "serialize_configured_contracts"
287    )]
288    pub load_contracts: Vec<ConfiguredContract>,
289    /// Minimum expiry days for options and futures chains.
290    pub min_expiry_days: Option<u32>,
291    /// Maximum expiry days for options and futures chains.
292    pub max_expiry_days: Option<u32>,
293    /// Whether to build full options chain.
294    pub build_options_chain: Option<bool>,
295    /// Whether to build full futures chain.
296    pub build_futures_chain: Option<bool>,
297    /// Cache validity in days (None means no caching).
298    pub cache_validity_days: Option<u32>,
299    /// Whether to convert IB exchanges to MIC venues.
300    #[builder(default)]
301    pub convert_exchange_to_mic_venue: bool,
302    /// Symbol to MIC venue mapping override.
303    #[builder(default)]
304    pub symbol_to_mic_venue: HashMap<String, String>,
305    /// Security types to filter out.
306    #[builder(default)]
307    pub filter_sec_types: HashSet<IbSecurityType>,
308    /// Fully-qualified Python callable path for custom instrument filtering.
309    ///
310    /// Configuring this without the Python feature enabled is an error.
311    pub filter_callable: Option<String>,
312    /// Path to cache file for persistent instrument caching (equivalent to pickle_path in Python).
313    /// If provided, instruments will be cached to disk and loaded from cache if still valid.
314    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/// Trading mode for Dockerized IB Gateway.
351#[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    /// Paper trading mode.
369    #[serde(rename = "paper")]
370    #[default]
371    Paper,
372    /// Live trading mode.
373    #[serde(rename = "live")]
374    Live,
375}
376
377/// Configuration for Dockerized IB Gateway.
378///
379/// This configuration is for managing containerized IB Gateway instances.
380/// It supports environment variable loading and sensitive data masking.
381#[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    /// Username for IB account (falls back to `TWS_USERNAME` env var via [`Default`]).
399    pub username: Option<SecretString>,
400    /// Password for IB account (falls back to `TWS_PASSWORD` env var via [`Default`]).
401    #[serde(skip_serializing)]
402    pub password: Option<SecretString>,
403    /// Trading mode (paper or live).
404    #[builder(default)]
405    pub trading_mode: TradingMode,
406    /// Whether to enable read-only API mode.
407    #[builder(default = true)]
408    pub read_only_api: bool,
409    /// Timeout in seconds for container startup.
410    #[builder(default = 300)]
411    pub timeout: u64,
412    /// Container image reference.
413    #[builder(default = "ghcr.io/gnzsnz/ib-gateway:stable".to_string())]
414    pub container_image: String,
415    /// VNC port for remote desktop access (None to disable).
416    pub vnc_port: Option<u16>,
417}
418
419impl DockerizedIBGatewayConfig {
420    /// Validate configuration.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if validation fails.
425    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    /// Validates that configured selections contain no duplicates.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if a selection repeats a value.
473    pub fn validate(&self) -> ConfigResult<()> {
474        check_unique_values("load_contracts", &self.load_contracts)
475    }
476}
477
478impl InteractiveBrokersExecutionClientConfig {
479    /// Validates that configured selections contain no duplicates.
480    ///
481    /// # Errors
482    ///
483    /// Returns an error if a selection repeats a value.
484    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}