Skip to main content

nautilus_okx/
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 structures for the OKX adapter.
17
18use nautilus_common::config::{ConfigResult, check_unique_values};
19use nautilus_core::string::secret::SecretString;
20use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
21use nautilus_model::{enums::OmsType, identifiers::AccountId};
22use nautilus_network::websocket::TransportBackend;
23use serde::{Deserialize, Serialize};
24
25use crate::common::{
26    credential::credential_env_vars,
27    enums::{
28        OKXContractType, OKXEnvironment, OKXInstrumentType, OKXMarginMode, OKXRegion, OKXVipLevel,
29    },
30    urls::{
31        get_http_base_url, get_ws_base_url_business, get_ws_base_url_private,
32        get_ws_base_url_public,
33    },
34};
35
36/// Configuration for the OKX data client.
37#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
38#[serde(default, deny_unknown_fields)]
39#[cfg_attr(
40    feature = "python",
41    pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
42)]
43#[cfg_attr(
44    feature = "python",
45    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
46)]
47pub struct OKXDataClientConfig {
48    /// Optional API key for authenticated endpoints.
49    pub api_key: Option<SecretString>,
50    /// Optional API secret for authenticated endpoints.
51    pub api_secret: Option<SecretString>,
52    /// Optional API passphrase for authenticated endpoints.
53    pub api_passphrase: Option<SecretString>,
54    /// Instrument types to load and subscribe to.
55    #[builder(default = vec![OKXInstrumentType::Spot])]
56    pub instrument_types: Vec<OKXInstrumentType>,
57    /// Contract type filter applied to loaded instruments.
58    pub contract_types: Option<Vec<OKXContractType>>,
59    /// Whether to load spread trading instruments from the separate spread endpoint.
60    #[builder(default)]
61    pub load_spreads: bool,
62    /// Instrument families to load (e.g., "BTC-USD", "ETH-USD").
63    /// Required for OPTIONS. Optional for FUTURES/SWAP. Not applicable for SPOT/MARGIN.
64    pub instrument_families: Option<Vec<String>>,
65    /// The API environment (live or demo).
66    #[builder(default)]
67    pub environment: OKXEnvironment,
68    /// The API region (global, EEA, or US).
69    #[builder(default)]
70    pub region: OKXRegion,
71    /// Optional override for the HTTP base URL.
72    pub base_url_http: Option<String>,
73    /// Optional override for the public WebSocket URL.
74    pub base_url_ws_public: Option<String>,
75    /// Optional override for the business WebSocket URL.
76    pub base_url_ws_business: Option<String>,
77    /// Optional proxy URL for HTTP and WebSocket transports.
78    pub proxy_url: Option<SecretString>,
79    /// HTTP timeout in seconds.
80    #[builder(default = 60)]
81    pub http_timeout_secs: u64,
82    /// Maximum retry attempts for requests.
83    #[builder(default = 3)]
84    pub max_retries: u32,
85    /// Initial retry delay in milliseconds.
86    #[builder(default = 1_000)]
87    pub retry_delay_initial_ms: u64,
88    /// Maximum retry delay in milliseconds.
89    #[builder(default = 10_000)]
90    pub retry_delay_max_ms: u64,
91    /// Interval for reconciling instruments from the REST API in minutes.
92    ///
93    /// Set to 0 to disable periodic reconciliation. WebSocket instrument
94    /// updates are always applied regardless of this interval.
95    #[builder(default = 60)]
96    pub update_instruments_interval_mins: u64,
97    /// Interval for checking order book feed staleness in seconds.
98    #[builder(default = 5)]
99    pub book_stale_check_interval_secs: u64,
100    /// Maximum time without order book updates before emitting a stale signal in seconds.
101    ///
102    /// Set to 0 to disable. Quiet markets can idle without book changes.
103    #[builder(default = 30)]
104    pub book_stale_threshold_secs: u64,
105    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
106    /// snapshot in seconds.
107    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
108    pub book_snapshot_timeout_secs: u64,
109    /// Optional VIP level that unlocks additional subscriptions.
110    pub vip_level: Option<OKXVipLevel>,
111    /// WebSocket transport backend (defaults to `Tungstenite`).
112    #[builder(default)]
113    pub transport_backend: TransportBackend,
114}
115
116#[cfg(feature = "python")]
117nautilus_core::impl_pyo3_config_getters!(OKXDataClientConfig {
118    instrument_types: Vec<OKXInstrumentType>,
119    instrument_families: Option<Vec<String>>,
120    environment: OKXEnvironment,
121    region: OKXRegion,
122    base_url_http: Option<String>,
123    base_url_ws_public: Option<String>,
124    base_url_ws_business: Option<String>,
125    http_timeout_secs: u64,
126    max_retries: u32,
127    retry_delay_initial_ms: u64,
128    retry_delay_max_ms: u64,
129    update_instruments_interval_mins: u64,
130    book_stale_check_interval_secs: u64,
131    book_stale_threshold_secs: u64,
132    book_snapshot_timeout_secs: u64,
133    vip_level: Option<OKXVipLevel>,
134    load_spreads: bool,
135    transport_backend: TransportBackend,
136});
137
138impl Default for OKXDataClientConfig {
139    fn default() -> Self {
140        Self::builder().build()
141    }
142}
143
144impl OKXDataClientConfig {
145    /// Creates a new configuration with default settings.
146    #[must_use]
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Validates that configured selections contain no duplicates.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if a selection repeats a value.
156    pub fn validate(&self) -> ConfigResult<()> {
157        check_unique_values("instrument_types", &self.instrument_types)?;
158
159        if let Some(values) = &self.contract_types {
160            check_unique_values("contract_types", values)?;
161        }
162
163        if let Some(values) = &self.instrument_families {
164            check_unique_values("instrument_families", values)?;
165        }
166
167        Ok(())
168    }
169
170    /// Returns `true` when all API credential fields are available (in config or env vars).
171    #[must_use]
172    pub fn has_api_credentials(&self) -> bool {
173        let (key_var, secret_var, passphrase_var) = credential_env_vars();
174        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
175        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
176        let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
177        has_key && has_secret && has_passphrase
178    }
179
180    /// Returns the HTTP base URL, falling back to the region default when unset.
181    #[must_use]
182    pub fn http_base_url(&self) -> String {
183        self.base_url_http
184            .clone()
185            .unwrap_or_else(|| get_http_base_url(self.region).to_string())
186    }
187
188    /// Returns the public WebSocket URL, respecting the region, environment, and overrides.
189    #[must_use]
190    pub fn ws_public_url(&self) -> String {
191        self.base_url_ws_public
192            .clone()
193            .unwrap_or_else(|| get_ws_base_url_public(self.region, self.environment).to_string())
194    }
195
196    /// Returns the business WebSocket URL, respecting the region, environment, and overrides.
197    #[must_use]
198    pub fn ws_business_url(&self) -> String {
199        self.base_url_ws_business
200            .clone()
201            .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
202    }
203
204    /// Returns `true` when the business WebSocket should be instantiated.
205    ///
206    /// The business WebSocket carries public candle data and does not
207    /// require authentication, so it is always needed.
208    #[must_use]
209    pub fn requires_business_ws(&self) -> bool {
210        true
211    }
212}
213
214/// Configuration for the OKX execution client.
215#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
216#[serde(default, deny_unknown_fields)]
217#[cfg_attr(
218    feature = "python",
219    pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
220)]
221#[cfg_attr(
222    feature = "python",
223    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
224)]
225pub struct OKXExecutionClientConfig {
226    /// The account ID for the client.
227    #[builder(default = AccountId::from("OKX-001"))]
228    pub account_id: AccountId,
229    /// Optional OMS override for the client.
230    /// Defaults to netting with any derivative instrument type, otherwise hedging.
231    pub oms_type: Option<OmsType>,
232    /// Optional API key for authenticated endpoints.
233    pub api_key: Option<SecretString>,
234    /// Optional API secret for authenticated endpoints.
235    pub api_secret: Option<SecretString>,
236    /// Optional API passphrase for authenticated endpoints.
237    pub api_passphrase: Option<SecretString>,
238    /// Instrument types the execution client should support.
239    #[builder(default = vec![OKXInstrumentType::Spot])]
240    pub instrument_types: Vec<OKXInstrumentType>,
241    /// Contract type filter applied to operations.
242    pub contract_types: Option<Vec<OKXContractType>>,
243    /// Instrument families to load (e.g., "BTC-USD", "ETH-USD").
244    /// Required for OPTIONS. Optional for FUTURES/SWAP. Not applicable for SPOT/MARGIN.
245    pub instrument_families: Option<Vec<String>>,
246    /// The API environment (live or demo).
247    #[builder(default)]
248    pub environment: OKXEnvironment,
249    /// The API region (global, EEA, or US).
250    #[builder(default)]
251    pub region: OKXRegion,
252    /// Optional override for the HTTP base URL.
253    pub base_url_http: Option<String>,
254    /// Optional override for the private WebSocket URL.
255    pub base_url_ws_private: Option<String>,
256    /// Optional override for the business WebSocket URL.
257    pub base_url_ws_business: Option<String>,
258    /// Optional proxy URL for HTTP and WebSocket transports.
259    pub proxy_url: Option<SecretString>,
260    /// HTTP timeout in seconds.
261    #[builder(default = 60)]
262    pub http_timeout_secs: u64,
263    /// Whether to subscribe to spread order updates from the separate spread channel.
264    #[builder(default)]
265    pub load_spreads: bool,
266    /// Enables mass-cancel support when true.
267    #[builder(default)]
268    pub use_mm_mass_cancel: bool,
269    /// Maximum retry attempts for requests.
270    #[builder(default = 3)]
271    pub max_retries: u32,
272    /// Initial retry delay in milliseconds.
273    #[builder(default = 1_000)]
274    pub retry_delay_initial_ms: u64,
275    /// Maximum retry delay in milliseconds.
276    #[builder(default = 10_000)]
277    pub retry_delay_max_ms: u64,
278    /// Optional margin mode (CROSS or ISOLATED) for margin/derivative accounts.
279    pub margin_mode: Option<OKXMarginMode>,
280    /// Enables margin/leverage for SPOT trading when true.
281    #[builder(default)]
282    pub use_spot_margin: bool,
283    /// Optional SPOT `tradeQuoteCcy` override sent on order payloads.
284    ///
285    /// Unset omits the field so OKX uses the quote currency in `instId` (USDC
286    /// on `Crypto-USDC` instruments). Set to `"USD"` to keep trading in USD
287    /// after the USD-to-USDC spot migration. The value must appear in that
288    /// instrument's `tradeQuoteCcyList`.
289    pub spot_trade_quote_ccy: Option<String>,
290    /// Optional WebSocket authentication timeout (seconds), defaulting to
291    /// `AUTHENTICATION_TIMEOUT_SECS` when unset.
292    pub auth_timeout_secs: Option<u64>,
293    /// WebSocket transport backend (defaults to `Tungstenite`).
294    #[builder(default)]
295    pub transport_backend: TransportBackend,
296    /// Maximum pages per startup reconciliation history query, capped at 50.
297    ///
298    /// `None` keeps the default cap; `Some(0)` skips history. Pending orders,
299    /// live algo orders, and positions are unaffected. Skipped or truncated
300    /// history marks reports incomplete and reduces recovery coverage.
301    pub max_reconciliation_history_pages: Option<u32>,
302}
303
304#[cfg(feature = "python")]
305nautilus_core::impl_pyo3_config_getters!(OKXExecutionClientConfig {
306    account_id: AccountId,
307    oms_type: Option<OmsType>,
308    instrument_types: Vec<OKXInstrumentType>,
309    environment: OKXEnvironment,
310    region: OKXRegion,
311    base_url_http: Option<String>,
312    base_url_ws_private: Option<String>,
313    base_url_ws_business: Option<String>,
314    http_timeout_secs: u64,
315    max_retries: u32,
316    retry_delay_initial_ms: u64,
317    retry_delay_max_ms: u64,
318    margin_mode: Option<OKXMarginMode>,
319    load_spreads: bool,
320    auth_timeout_secs: Option<u64>,
321    transport_backend: TransportBackend,
322    spot_trade_quote_ccy: Option<String>,
323    max_reconciliation_history_pages: Option<u32>,
324});
325
326impl Default for OKXExecutionClientConfig {
327    fn default() -> Self {
328        Self::builder().build()
329    }
330}
331
332impl OKXExecutionClientConfig {
333    /// Creates a new configuration with default settings.
334    #[must_use]
335    pub fn new() -> Self {
336        Self::default()
337    }
338
339    /// Validates that configured selections contain no duplicates.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if a selection repeats a value.
344    pub fn validate(&self) -> ConfigResult<()> {
345        check_unique_values("instrument_types", &self.instrument_types)?;
346
347        if let Some(values) = &self.contract_types {
348            check_unique_values("contract_types", values)?;
349        }
350
351        if let Some(values) = &self.instrument_families {
352            check_unique_values("instrument_families", values)?;
353        }
354
355        Ok(())
356    }
357
358    /// Returns `true` when all API credential fields are available (in config or env vars).
359    #[must_use]
360    pub fn has_api_credentials(&self) -> bool {
361        let (key_var, secret_var, passphrase_var) = credential_env_vars();
362        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
363        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
364        let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
365        has_key && has_secret && has_passphrase
366    }
367
368    /// Returns the HTTP base URL, falling back to the region default when unset.
369    #[must_use]
370    pub fn http_base_url(&self) -> String {
371        self.base_url_http
372            .clone()
373            .unwrap_or_else(|| get_http_base_url(self.region).to_string())
374    }
375
376    /// Returns the private WebSocket URL, respecting the region, environment, and overrides.
377    #[must_use]
378    pub fn ws_private_url(&self) -> String {
379        self.base_url_ws_private
380            .clone()
381            .unwrap_or_else(|| get_ws_base_url_private(self.region, self.environment).to_string())
382    }
383
384    /// Returns the business WebSocket URL, respecting the region, environment, and overrides.
385    #[must_use]
386    pub fn ws_business_url(&self) -> String {
387        self.base_url_ws_business
388            .clone()
389            .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::{cell::RefCell, rc::Rc};
396
397    use nautilus_common::{cache::Cache, config::ConfigError};
398    use nautilus_core::string::secret::REDACTED;
399    use nautilus_live::ExecutionClientCore;
400    use nautilus_model::{
401        enums::{AccountType, OmsType},
402        identifiers::{ClientId, TraderId, Venue},
403    };
404    use rstest::rstest;
405
406    use super::*;
407    use crate::execution::OKXExecutionClient;
408
409    #[rstest]
410    #[case::empty(vec![], false)]
411    #[case::distinct(vec![OKXInstrumentType::Spot, OKXInstrumentType::Swap], false)]
412    #[case::adjacent(vec![OKXInstrumentType::Swap, OKXInstrumentType::Swap], true)]
413    #[case::separated(vec![OKXInstrumentType::Swap, OKXInstrumentType::Spot, OKXInstrumentType::Swap], true)]
414    fn test_config_duplicate_types(
415        #[case] values: Vec<OKXInstrumentType>,
416        #[case] duplicate: bool,
417    ) {
418        let data = OKXDataClientConfig {
419            instrument_types: values.clone(),
420            ..Default::default()
421        };
422
423        let execution = OKXExecutionClientConfig {
424            instrument_types: values.clone(),
425            ..Default::default()
426        };
427
428        let expected = if duplicate {
429            Err(ConfigError::duplicate("instrument_types", None))
430        } else {
431            Ok(())
432        };
433
434        let data_result = data.validate();
435        let execution_result = execution.validate();
436
437        assert_eq!(data_result, expected);
438        assert_eq!(execution_result, expected);
439        assert_eq!(data.instrument_types, values);
440        assert_eq!(execution.instrument_types, values);
441    }
442
443    const DATA_API_KEY: &str = "okx-data-api-key-sentinel";
444    const DATA_API_SECRET: &str = "okx-data-api-secret-sentinel";
445    const DATA_API_PASSPHRASE: &str = "okx-data-api-passphrase-sentinel";
446    const EXEC_API_KEY: &str = "okx-exec-api-key-sentinel";
447    const EXEC_API_SECRET: &str = "okx-exec-api-secret-sentinel";
448    const EXEC_API_PASSPHRASE: &str = "okx-exec-api-passphrase-sentinel";
449
450    #[rstest]
451    fn test_data_config_debug_redacts_credentials() {
452        let config = OKXDataClientConfig {
453            api_key: Some(DATA_API_KEY.into()),
454            api_secret: Some(DATA_API_SECRET.into()),
455            api_passphrase: Some(DATA_API_PASSPHRASE.into()),
456            environment: OKXEnvironment::Demo,
457            http_timeout_secs: 71,
458            ..Default::default()
459        };
460
461        let debug_output = format!("{config:?}");
462        let redacted = format!("Some({REDACTED})");
463
464        assert!(!debug_output.contains(DATA_API_KEY));
465        assert!(!debug_output.contains(DATA_API_SECRET));
466        assert!(!debug_output.contains(DATA_API_PASSPHRASE));
467        assert!(debug_output.contains(&format!("api_key: {redacted}")));
468        assert!(debug_output.contains(&format!("api_secret: {redacted}")));
469        assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
470        assert!(debug_output.contains("environment: Demo"));
471        assert!(debug_output.contains("http_timeout_secs: 71"));
472    }
473
474    #[rstest]
475    fn test_exec_config_debug_redacts_credentials() {
476        let config = OKXExecutionClientConfig {
477            account_id: AccountId::from("OKX-042"),
478            api_key: Some(EXEC_API_KEY.into()),
479            api_secret: Some(EXEC_API_SECRET.into()),
480            api_passphrase: Some(EXEC_API_PASSPHRASE.into()),
481            max_retries: 13,
482            ..Default::default()
483        };
484
485        let debug_output = format!("{config:?}");
486        let redacted = format!("Some({REDACTED})");
487
488        assert!(!debug_output.contains(EXEC_API_KEY));
489        assert!(!debug_output.contains(EXEC_API_SECRET));
490        assert!(!debug_output.contains(EXEC_API_PASSPHRASE));
491        assert!(debug_output.contains(&format!("api_key: {redacted}")));
492        assert!(debug_output.contains(&format!("api_secret: {redacted}")));
493        assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
494        assert!(debug_output.contains("OKX-042"));
495        assert!(debug_output.contains("max_retries: 13"));
496    }
497
498    #[rstest]
499    fn test_config_debug_handles_unset_and_partial_credentials() {
500        let data_debug = format!("{:?}", OKXDataClientConfig::default());
501        let exec_debug = format!(
502            "{:?}",
503            OKXExecutionClientConfig {
504                api_secret: Some(String::new().into()),
505                ..Default::default()
506            }
507        );
508
509        assert!(data_debug.contains("api_key: None"));
510        assert!(data_debug.contains("api_secret: None"));
511        assert!(data_debug.contains("api_passphrase: None"));
512        assert!(exec_debug.contains("api_key: None"));
513        assert!(exec_debug.contains(&format!("api_secret: Some({REDACTED})")));
514        assert!(exec_debug.contains("api_passphrase: None"));
515    }
516
517    #[rstest]
518    fn test_data_config_toml_minimal() {
519        let config: OKXDataClientConfig = toml::from_str(
520            r#"
521environment = "demo"
522instrument_types = ["SPOT", "SWAP"]
523http_timeout_secs = 90
524"#,
525        )
526        .unwrap();
527
528        assert_eq!(config.environment, OKXEnvironment::Demo);
529        assert_eq!(
530            config.instrument_types,
531            vec![OKXInstrumentType::Spot, OKXInstrumentType::Swap]
532        );
533        assert_eq!(config.http_timeout_secs, 90);
534        assert!(!config.load_spreads);
535        assert_eq!(config.book_stale_check_interval_secs, 5);
536        assert_eq!(config.book_stale_threshold_secs, 30);
537        assert_eq!(config.book_snapshot_timeout_secs, 10);
538    }
539
540    #[rstest]
541    fn test_data_config_toml_load_spreads() {
542        let config: OKXDataClientConfig = toml::from_str(
543            "
544load_spreads = true
545",
546        )
547        .unwrap();
548
549        assert!(config.load_spreads);
550    }
551
552    #[rstest]
553    fn test_data_config_toml_book_stale_settings() {
554        let config: OKXDataClientConfig = toml::from_str(
555            "
556book_stale_check_interval_secs = 2
557book_stale_threshold_secs = 7
558book_snapshot_timeout_secs = 4
559",
560        )
561        .unwrap();
562
563        assert_eq!(config.book_stale_check_interval_secs, 2);
564        assert_eq!(config.book_stale_threshold_secs, 7);
565        assert_eq!(config.book_snapshot_timeout_secs, 4);
566    }
567
568    #[rstest]
569    fn test_exec_config_toml_empty_uses_defaults() {
570        let config: OKXExecutionClientConfig = toml::from_str("").unwrap();
571        let expected = OKXExecutionClientConfig::default();
572        assert_eq!(config.account_id, expected.account_id);
573        assert_eq!(config.environment, expected.environment);
574        assert_eq!(config.instrument_types, expected.instrument_types);
575        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
576        assert_eq!(config.load_spreads, expected.load_spreads);
577        assert_eq!(config.use_mm_mass_cancel, expected.use_mm_mass_cancel);
578        assert_eq!(config.max_reconciliation_history_pages, None);
579        assert_eq!(config.transport_backend, expected.transport_backend);
580    }
581
582    #[rstest]
583    #[case(0)]
584    #[case(3)]
585    #[case(51)]
586    #[case(u32::MAX)]
587    fn test_exec_config_toml_history_pages(#[case] pages: u32) {
588        let config: OKXExecutionClientConfig =
589            toml::from_str(&format!("max_reconciliation_history_pages = {pages}\n")).unwrap();
590        let serialized = toml::Value::try_from(&config).unwrap();
591        let roundtrip: OKXExecutionClientConfig = serialized.try_into().unwrap();
592
593        assert_eq!(config.max_reconciliation_history_pages, Some(pages));
594        assert_eq!(roundtrip.max_reconciliation_history_pages, Some(pages));
595        assert_eq!(
596            OKXExecutionClientConfig::default().max_reconciliation_history_pages,
597            None
598        );
599    }
600
601    #[rstest]
602    fn test_exec_config_toml_rejects_removed_fills_channel_key() {
603        // use_fills_channel was removed: strict decoding must reject stale configs
604        let result: Result<OKXExecutionClientConfig, _> =
605            toml::from_str("use_fills_channel = true\n");
606        assert!(result.is_err());
607    }
608
609    #[rstest]
610    fn test_exec_config_toml_load_spreads() {
611        let config: OKXExecutionClientConfig = toml::from_str(
612            "
613load_spreads = true
614",
615        )
616        .unwrap();
617
618        assert!(config.load_spreads);
619    }
620
621    #[rstest]
622    fn test_data_config_default_region_is_global() {
623        let config = OKXDataClientConfig::default();
624
625        assert_eq!(config.region, OKXRegion::Global);
626        assert_eq!(config.http_base_url(), "https://www.okx.com");
627        assert_eq!(config.ws_public_url(), "wss://ws.okx.com/ws/v5/public");
628    }
629
630    #[rstest]
631    fn test_data_config_eea_region_urls() {
632        let config = OKXDataClientConfig::builder()
633            .region(OKXRegion::Eea)
634            .build();
635
636        assert_eq!(config.http_base_url(), "https://eea.okx.com");
637        assert_eq!(config.ws_public_url(), "wss://wseea.okx.com/ws/v5/public");
638        assert_eq!(
639            config.ws_business_url(),
640            "wss://wseea.okx.com/ws/v5/business"
641        );
642    }
643
644    #[rstest]
645    fn test_exec_config_eea_region_urls() {
646        let config = OKXExecutionClientConfig::builder()
647            .region(OKXRegion::Eea)
648            .build();
649
650        assert_eq!(config.http_base_url(), "https://eea.okx.com");
651        assert_eq!(config.ws_private_url(), "wss://wseea.okx.com/ws/v5/private");
652        assert_eq!(
653            config.ws_business_url(),
654            "wss://wseea.okx.com/ws/v5/business"
655        );
656    }
657
658    #[rstest]
659    fn test_config_region_override_takes_precedence() {
660        let config = OKXDataClientConfig::builder()
661            .region(OKXRegion::Eea)
662            .environment(OKXEnvironment::Demo)
663            .base_url_http("https://custom.proxy".to_string())
664            .base_url_ws_public("wss://public.proxy:8443/ws/v5/public".to_string())
665            .base_url_ws_business("wss://data.proxy:9443/ws/v5/business".to_string())
666            .build();
667
668        assert_eq!(config.http_base_url(), "https://custom.proxy");
669        assert_eq!(
670            config.ws_public_url(),
671            "wss://public.proxy:8443/ws/v5/public"
672        );
673        assert_eq!(
674            config.ws_business_url(),
675            "wss://data.proxy:9443/ws/v5/business"
676        );
677    }
678
679    #[rstest]
680    fn test_exec_config_region_override_takes_precedence() {
681        let config = OKXExecutionClientConfig::builder()
682            .region(OKXRegion::Us)
683            .environment(OKXEnvironment::Demo)
684            .base_url_ws_private("wss://private.proxy:8443/ws/v5/private".to_string())
685            .base_url_ws_business("wss://execution.proxy:9443/ws/v5/business".to_string())
686            .build();
687
688        assert_eq!(
689            config.ws_private_url(),
690            "wss://private.proxy:8443/ws/v5/private"
691        );
692        assert_eq!(
693            config.ws_business_url(),
694            "wss://execution.proxy:9443/ws/v5/business"
695        );
696    }
697
698    #[rstest]
699    fn test_data_config_toml_region() {
700        let config: OKXDataClientConfig = toml::from_str(
701            r#"
702region = "eea"
703"#,
704        )
705        .unwrap();
706
707        assert_eq!(config.region, OKXRegion::Eea);
708    }
709
710    #[rstest]
711    fn test_exec_config_auth_timeout_secs() {
712        assert_eq!(OKXExecutionClientConfig::default().auth_timeout_secs, None);
713
714        let exec = OKXExecutionClientConfig::builder()
715            .auth_timeout_secs(4)
716            .build();
717        assert_eq!(exec.auth_timeout_secs, Some(4));
718
719        let exec: OKXExecutionClientConfig = toml::from_str("auth_timeout_secs = 8\n").unwrap();
720        assert_eq!(exec.auth_timeout_secs, Some(8));
721    }
722
723    #[rstest]
724    #[case("contract_types")]
725    #[case("instrument_families")]
726    fn test_optional_selection_duplicates(#[case] field: &str) {
727        let contract_types = (field == "contract_types").then(|| {
728            vec![
729                OKXContractType::Linear,
730                OKXContractType::Inverse,
731                OKXContractType::Linear,
732            ]
733        });
734
735        let instrument_families = (field == "instrument_families").then(|| {
736            vec![
737                "BTC-USDT".to_string(),
738                "ETH-USDT".to_string(),
739                "BTC-USDT".to_string(),
740            ]
741        });
742
743        let data = OKXDataClientConfig {
744            contract_types: contract_types.clone(),
745            instrument_families: instrument_families.clone(),
746            ..Default::default()
747        };
748
749        let execution = OKXExecutionClientConfig {
750            contract_types,
751            instrument_families,
752            ..Default::default()
753        };
754
755        let expected = Err(ConfigError::duplicate(field, None));
756
757        assert_eq!(data.validate(), expected);
758        assert_eq!(execution.validate(), expected);
759    }
760
761    #[rstest]
762    fn test_execution_client_rejects_duplicate_types() {
763        let core = ExecutionClientCore::new(
764            TraderId::from("TESTER-001"),
765            ClientId::from("OKX"),
766            Venue::from("OKX"),
767            OmsType::Netting,
768            AccountId::from("OKX-001"),
769            AccountType::Margin,
770            None,
771            Rc::new(RefCell::new(Cache::default())),
772        );
773
774        let config = OKXExecutionClientConfig {
775            instrument_types: vec![OKXInstrumentType::Swap, OKXInstrumentType::Swap],
776            ..Default::default()
777        };
778
779        let error = OKXExecutionClient::new(core, config)
780            .expect_err("duplicate config must fail before credentials or client setup");
781
782        assert_eq!(
783            error.downcast_ref::<ConfigError>(),
784            Some(&ConfigError::duplicate("instrument_types", None))
785        );
786    }
787}