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_core::string::secret::SecretString;
19use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
20use nautilus_model::identifiers::AccountId;
21use nautilus_network::websocket::TransportBackend;
22use serde::{Deserialize, Serialize};
23
24use crate::common::{
25    credential::credential_env_vars,
26    enums::{
27        OKXContractType, OKXEnvironment, OKXInstrumentType, OKXMarginMode, OKXRegion, OKXVipLevel,
28    },
29    urls::{
30        get_http_base_url, get_ws_base_url_business, get_ws_base_url_private,
31        get_ws_base_url_public,
32    },
33};
34
35/// Configuration for the OKX data client.
36#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
37#[serde(default, deny_unknown_fields)]
38#[cfg_attr(
39    feature = "python",
40    pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
41)]
42#[cfg_attr(
43    feature = "python",
44    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
45)]
46pub struct OKXDataClientConfig {
47    /// Optional API key for authenticated endpoints.
48    pub api_key: Option<SecretString>,
49    /// Optional API secret for authenticated endpoints.
50    pub api_secret: Option<SecretString>,
51    /// Optional API passphrase for authenticated endpoints.
52    pub api_passphrase: Option<SecretString>,
53    /// Instrument types to load and subscribe to.
54    #[builder(default = vec![OKXInstrumentType::Spot])]
55    pub instrument_types: Vec<OKXInstrumentType>,
56    /// Contract type filter applied to loaded instruments.
57    pub contract_types: Option<Vec<OKXContractType>>,
58    /// Whether to load spread trading instruments from the separate spread endpoint.
59    #[builder(default)]
60    pub load_spreads: bool,
61    /// Instrument families to load (e.g., "BTC-USD", "ETH-USD").
62    /// Required for OPTIONS. Optional for FUTURES/SWAP. Not applicable for SPOT/MARGIN.
63    pub instrument_families: Option<Vec<String>>,
64    /// The API environment (live or demo).
65    #[builder(default)]
66    pub environment: OKXEnvironment,
67    /// The API region (global, EEA, or US).
68    #[builder(default)]
69    pub region: OKXRegion,
70    /// Optional override for the HTTP base URL.
71    pub base_url_http: Option<String>,
72    /// Optional override for the public WebSocket URL.
73    pub base_url_ws_public: Option<String>,
74    /// Optional override for the business WebSocket URL.
75    pub base_url_ws_business: Option<String>,
76    /// Optional proxy URL for HTTP and WebSocket transports.
77    pub proxy_url: Option<SecretString>,
78    /// HTTP timeout in seconds.
79    #[builder(default = 60)]
80    pub http_timeout_secs: u64,
81    /// Maximum retry attempts for requests.
82    #[builder(default = 3)]
83    pub max_retries: u32,
84    /// Initial retry delay in milliseconds.
85    #[builder(default = 1_000)]
86    pub retry_delay_initial_ms: u64,
87    /// Maximum retry delay in milliseconds.
88    #[builder(default = 10_000)]
89    pub retry_delay_max_ms: u64,
90    /// Interval for reconciling instruments from the REST API in minutes.
91    ///
92    /// Set to 0 to disable periodic reconciliation. WebSocket instrument
93    /// updates are always applied regardless of this interval.
94    #[builder(default = 60)]
95    pub update_instruments_interval_mins: u64,
96    /// Interval for checking order book feed staleness in seconds.
97    #[builder(default = 5)]
98    pub book_stale_check_interval_secs: u64,
99    /// Maximum time without order book updates before emitting a stale signal in seconds.
100    ///
101    /// Set to 0 to disable. Quiet markets can idle without book changes.
102    #[builder(default = 30)]
103    pub book_stale_threshold_secs: u64,
104    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
105    /// snapshot in seconds.
106    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
107    pub book_snapshot_timeout_secs: u64,
108    /// Optional VIP level that unlocks additional subscriptions.
109    pub vip_level: Option<OKXVipLevel>,
110    /// WebSocket transport backend (defaults to `Tungstenite`).
111    #[builder(default)]
112    pub transport_backend: TransportBackend,
113}
114
115#[cfg(feature = "python")]
116nautilus_core::impl_pyo3_config_getters!(OKXDataClientConfig {
117    instrument_types: Vec<OKXInstrumentType>,
118    instrument_families: Option<Vec<String>>,
119    environment: OKXEnvironment,
120    region: OKXRegion,
121    base_url_http: Option<String>,
122    base_url_ws_public: Option<String>,
123    base_url_ws_business: Option<String>,
124    http_timeout_secs: u64,
125    max_retries: u32,
126    retry_delay_initial_ms: u64,
127    retry_delay_max_ms: u64,
128    update_instruments_interval_mins: u64,
129    book_stale_check_interval_secs: u64,
130    book_stale_threshold_secs: u64,
131    book_snapshot_timeout_secs: u64,
132    vip_level: Option<OKXVipLevel>,
133    load_spreads: bool,
134    transport_backend: TransportBackend,
135});
136
137impl Default for OKXDataClientConfig {
138    fn default() -> Self {
139        Self::builder().build()
140    }
141}
142
143impl OKXDataClientConfig {
144    /// Creates a new configuration with default settings.
145    #[must_use]
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Returns `true` when all API credential fields are available (in config or env vars).
151    #[must_use]
152    pub fn has_api_credentials(&self) -> bool {
153        let (key_var, secret_var, passphrase_var) = credential_env_vars();
154        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
155        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
156        let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
157        has_key && has_secret && has_passphrase
158    }
159
160    /// Returns the HTTP base URL, falling back to the region default when unset.
161    #[must_use]
162    pub fn http_base_url(&self) -> String {
163        self.base_url_http
164            .clone()
165            .unwrap_or_else(|| get_http_base_url(self.region).to_string())
166    }
167
168    /// Returns the public WebSocket URL, respecting the region, environment, and overrides.
169    #[must_use]
170    pub fn ws_public_url(&self) -> String {
171        self.base_url_ws_public
172            .clone()
173            .unwrap_or_else(|| get_ws_base_url_public(self.region, self.environment).to_string())
174    }
175
176    /// Returns the business WebSocket URL, respecting the region, environment, and overrides.
177    #[must_use]
178    pub fn ws_business_url(&self) -> String {
179        self.base_url_ws_business
180            .clone()
181            .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
182    }
183
184    /// Returns `true` when the business WebSocket should be instantiated.
185    ///
186    /// The business WebSocket carries public candle data and does not
187    /// require authentication, so it is always needed.
188    #[must_use]
189    pub fn requires_business_ws(&self) -> bool {
190        true
191    }
192}
193
194/// Configuration for the OKX execution client.
195#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
196#[serde(default, deny_unknown_fields)]
197#[cfg_attr(
198    feature = "python",
199    pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
200)]
201#[cfg_attr(
202    feature = "python",
203    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
204)]
205pub struct OKXExecutionClientConfig {
206    /// The account ID for the client.
207    #[builder(default = AccountId::from("OKX-001"))]
208    pub account_id: AccountId,
209    /// Optional API key for authenticated endpoints.
210    pub api_key: Option<SecretString>,
211    /// Optional API secret for authenticated endpoints.
212    pub api_secret: Option<SecretString>,
213    /// Optional API passphrase for authenticated endpoints.
214    pub api_passphrase: Option<SecretString>,
215    /// Instrument types the execution client should support.
216    #[builder(default = vec![OKXInstrumentType::Spot])]
217    pub instrument_types: Vec<OKXInstrumentType>,
218    /// Contract type filter applied to operations.
219    pub contract_types: Option<Vec<OKXContractType>>,
220    /// Instrument families to load (e.g., "BTC-USD", "ETH-USD").
221    /// Required for OPTIONS. Optional for FUTURES/SWAP. Not applicable for SPOT/MARGIN.
222    pub instrument_families: Option<Vec<String>>,
223    /// The API environment (live or demo).
224    #[builder(default)]
225    pub environment: OKXEnvironment,
226    /// The API region (global, EEA, or US).
227    #[builder(default)]
228    pub region: OKXRegion,
229    /// Optional override for the HTTP base URL.
230    pub base_url_http: Option<String>,
231    /// Optional override for the private WebSocket URL.
232    pub base_url_ws_private: Option<String>,
233    /// Optional override for the business WebSocket URL.
234    pub base_url_ws_business: Option<String>,
235    /// Optional proxy URL for HTTP and WebSocket transports.
236    pub proxy_url: Option<SecretString>,
237    /// HTTP timeout in seconds.
238    #[builder(default = 60)]
239    pub http_timeout_secs: u64,
240    /// Whether to subscribe to spread order updates from the separate spread channel.
241    #[builder(default)]
242    pub load_spreads: bool,
243    /// Enables mass-cancel support when true.
244    #[builder(default)]
245    pub use_mm_mass_cancel: bool,
246    /// Maximum retry attempts for requests.
247    #[builder(default = 3)]
248    pub max_retries: u32,
249    /// Initial retry delay in milliseconds.
250    #[builder(default = 1_000)]
251    pub retry_delay_initial_ms: u64,
252    /// Maximum retry delay in milliseconds.
253    #[builder(default = 10_000)]
254    pub retry_delay_max_ms: u64,
255    /// Optional margin mode (CROSS or ISOLATED) for margin/derivative accounts.
256    pub margin_mode: Option<OKXMarginMode>,
257    /// Enables margin/leverage for SPOT trading when true.
258    #[builder(default)]
259    pub use_spot_margin: bool,
260    /// Optional SPOT `tradeQuoteCcy` override sent on order payloads.
261    ///
262    /// Unset omits the field so OKX uses the quote currency in `instId` (USDC
263    /// on `Crypto-USDC` instruments). Set to `"USD"` to keep trading in USD
264    /// after the USD-to-USDC spot migration. The value must appear in that
265    /// instrument's `tradeQuoteCcyList`.
266    pub spot_trade_quote_ccy: Option<String>,
267    /// Optional WebSocket authentication timeout (seconds), defaulting to
268    /// `AUTHENTICATION_TIMEOUT_SECS` when unset.
269    pub auth_timeout_secs: Option<u64>,
270    /// WebSocket transport backend (defaults to `Tungstenite`).
271    #[builder(default)]
272    pub transport_backend: TransportBackend,
273}
274
275#[cfg(feature = "python")]
276nautilus_core::impl_pyo3_config_getters!(OKXExecutionClientConfig {
277    account_id: AccountId,
278    instrument_types: Vec<OKXInstrumentType>,
279    environment: OKXEnvironment,
280    region: OKXRegion,
281    base_url_http: Option<String>,
282    base_url_ws_private: Option<String>,
283    base_url_ws_business: Option<String>,
284    http_timeout_secs: u64,
285    max_retries: u32,
286    retry_delay_initial_ms: u64,
287    retry_delay_max_ms: u64,
288    margin_mode: Option<OKXMarginMode>,
289    load_spreads: bool,
290    auth_timeout_secs: Option<u64>,
291    transport_backend: TransportBackend,
292    spot_trade_quote_ccy: Option<String>,
293});
294
295impl Default for OKXExecutionClientConfig {
296    fn default() -> Self {
297        Self::builder().build()
298    }
299}
300
301impl OKXExecutionClientConfig {
302    /// Creates a new configuration with default settings.
303    #[must_use]
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Returns `true` when all API credential fields are available (in config or env vars).
309    #[must_use]
310    pub fn has_api_credentials(&self) -> bool {
311        let (key_var, secret_var, passphrase_var) = credential_env_vars();
312        let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
313        let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
314        let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
315        has_key && has_secret && has_passphrase
316    }
317
318    /// Returns the HTTP base URL, falling back to the region default when unset.
319    #[must_use]
320    pub fn http_base_url(&self) -> String {
321        self.base_url_http
322            .clone()
323            .unwrap_or_else(|| get_http_base_url(self.region).to_string())
324    }
325
326    /// Returns the private WebSocket URL, respecting the region, environment, and overrides.
327    #[must_use]
328    pub fn ws_private_url(&self) -> String {
329        self.base_url_ws_private
330            .clone()
331            .unwrap_or_else(|| get_ws_base_url_private(self.region, self.environment).to_string())
332    }
333
334    /// Returns the business WebSocket URL, respecting the region, environment, and overrides.
335    #[must_use]
336    pub fn ws_business_url(&self) -> String {
337        self.base_url_ws_business
338            .clone()
339            .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use nautilus_core::string::secret::REDACTED;
346    use rstest::rstest;
347
348    use super::*;
349
350    const DATA_API_KEY: &str = "okx-data-api-key-sentinel";
351    const DATA_API_SECRET: &str = "okx-data-api-secret-sentinel";
352    const DATA_API_PASSPHRASE: &str = "okx-data-api-passphrase-sentinel";
353    const EXEC_API_KEY: &str = "okx-exec-api-key-sentinel";
354    const EXEC_API_SECRET: &str = "okx-exec-api-secret-sentinel";
355    const EXEC_API_PASSPHRASE: &str = "okx-exec-api-passphrase-sentinel";
356
357    #[rstest]
358    fn test_data_config_debug_redacts_credentials() {
359        let config = OKXDataClientConfig {
360            api_key: Some(DATA_API_KEY.into()),
361            api_secret: Some(DATA_API_SECRET.into()),
362            api_passphrase: Some(DATA_API_PASSPHRASE.into()),
363            environment: OKXEnvironment::Demo,
364            http_timeout_secs: 71,
365            ..Default::default()
366        };
367
368        let debug_output = format!("{config:?}");
369        let redacted = format!("Some({REDACTED})");
370
371        assert!(!debug_output.contains(DATA_API_KEY));
372        assert!(!debug_output.contains(DATA_API_SECRET));
373        assert!(!debug_output.contains(DATA_API_PASSPHRASE));
374        assert!(debug_output.contains(&format!("api_key: {redacted}")));
375        assert!(debug_output.contains(&format!("api_secret: {redacted}")));
376        assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
377        assert!(debug_output.contains("environment: Demo"));
378        assert!(debug_output.contains("http_timeout_secs: 71"));
379    }
380
381    #[rstest]
382    fn test_exec_config_debug_redacts_credentials() {
383        let config = OKXExecutionClientConfig {
384            account_id: AccountId::from("OKX-042"),
385            api_key: Some(EXEC_API_KEY.into()),
386            api_secret: Some(EXEC_API_SECRET.into()),
387            api_passphrase: Some(EXEC_API_PASSPHRASE.into()),
388            max_retries: 13,
389            ..Default::default()
390        };
391
392        let debug_output = format!("{config:?}");
393        let redacted = format!("Some({REDACTED})");
394
395        assert!(!debug_output.contains(EXEC_API_KEY));
396        assert!(!debug_output.contains(EXEC_API_SECRET));
397        assert!(!debug_output.contains(EXEC_API_PASSPHRASE));
398        assert!(debug_output.contains(&format!("api_key: {redacted}")));
399        assert!(debug_output.contains(&format!("api_secret: {redacted}")));
400        assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
401        assert!(debug_output.contains("OKX-042"));
402        assert!(debug_output.contains("max_retries: 13"));
403    }
404
405    #[rstest]
406    fn test_config_debug_handles_unset_and_partial_credentials() {
407        let data_debug = format!("{:?}", OKXDataClientConfig::default());
408        let exec_debug = format!(
409            "{:?}",
410            OKXExecutionClientConfig {
411                api_secret: Some(String::new().into()),
412                ..Default::default()
413            }
414        );
415
416        assert!(data_debug.contains("api_key: None"));
417        assert!(data_debug.contains("api_secret: None"));
418        assert!(data_debug.contains("api_passphrase: None"));
419        assert!(exec_debug.contains("api_key: None"));
420        assert!(exec_debug.contains(&format!("api_secret: Some({REDACTED})")));
421        assert!(exec_debug.contains("api_passphrase: None"));
422    }
423
424    #[rstest]
425    fn test_data_config_toml_minimal() {
426        let config: OKXDataClientConfig = toml::from_str(
427            r#"
428environment = "demo"
429instrument_types = ["SPOT", "SWAP"]
430http_timeout_secs = 90
431"#,
432        )
433        .unwrap();
434
435        assert_eq!(config.environment, OKXEnvironment::Demo);
436        assert_eq!(
437            config.instrument_types,
438            vec![OKXInstrumentType::Spot, OKXInstrumentType::Swap]
439        );
440        assert_eq!(config.http_timeout_secs, 90);
441        assert!(!config.load_spreads);
442        assert_eq!(config.book_stale_check_interval_secs, 5);
443        assert_eq!(config.book_stale_threshold_secs, 30);
444        assert_eq!(config.book_snapshot_timeout_secs, 10);
445    }
446
447    #[rstest]
448    fn test_data_config_toml_load_spreads() {
449        let config: OKXDataClientConfig = toml::from_str(
450            "
451load_spreads = true
452",
453        )
454        .unwrap();
455
456        assert!(config.load_spreads);
457    }
458
459    #[rstest]
460    fn test_data_config_toml_book_stale_settings() {
461        let config: OKXDataClientConfig = toml::from_str(
462            "
463book_stale_check_interval_secs = 2
464book_stale_threshold_secs = 7
465book_snapshot_timeout_secs = 4
466",
467        )
468        .unwrap();
469
470        assert_eq!(config.book_stale_check_interval_secs, 2);
471        assert_eq!(config.book_stale_threshold_secs, 7);
472        assert_eq!(config.book_snapshot_timeout_secs, 4);
473    }
474
475    #[rstest]
476    fn test_exec_config_toml_empty_uses_defaults() {
477        let config: OKXExecutionClientConfig = toml::from_str("").unwrap();
478        let expected = OKXExecutionClientConfig::default();
479        assert_eq!(config.account_id, expected.account_id);
480        assert_eq!(config.environment, expected.environment);
481        assert_eq!(config.instrument_types, expected.instrument_types);
482        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
483        assert_eq!(config.load_spreads, expected.load_spreads);
484        assert_eq!(config.use_mm_mass_cancel, expected.use_mm_mass_cancel);
485        assert_eq!(config.transport_backend, expected.transport_backend);
486    }
487
488    #[rstest]
489    fn test_exec_config_toml_rejects_removed_fills_channel_key() {
490        // use_fills_channel was removed: strict decoding must reject stale configs
491        let result: Result<OKXExecutionClientConfig, _> =
492            toml::from_str("use_fills_channel = true\n");
493        assert!(result.is_err());
494    }
495
496    #[rstest]
497    fn test_exec_config_toml_load_spreads() {
498        let config: OKXExecutionClientConfig = toml::from_str(
499            "
500load_spreads = true
501",
502        )
503        .unwrap();
504
505        assert!(config.load_spreads);
506    }
507
508    #[rstest]
509    fn test_data_config_default_region_is_global() {
510        let config = OKXDataClientConfig::default();
511
512        assert_eq!(config.region, OKXRegion::Global);
513        assert_eq!(config.http_base_url(), "https://www.okx.com");
514        assert_eq!(config.ws_public_url(), "wss://ws.okx.com:8443/ws/v5/public");
515    }
516
517    #[rstest]
518    fn test_data_config_eea_region_urls() {
519        let config = OKXDataClientConfig::builder()
520            .region(OKXRegion::Eea)
521            .build();
522
523        assert_eq!(config.http_base_url(), "https://eea.okx.com");
524        assert_eq!(
525            config.ws_public_url(),
526            "wss://wseea.okx.com:8443/ws/v5/public"
527        );
528        assert_eq!(
529            config.ws_business_url(),
530            "wss://wseea.okx.com:8443/ws/v5/business"
531        );
532    }
533
534    #[rstest]
535    fn test_exec_config_eea_region_urls() {
536        let config = OKXExecutionClientConfig::builder()
537            .region(OKXRegion::Eea)
538            .build();
539
540        assert_eq!(config.http_base_url(), "https://eea.okx.com");
541        assert_eq!(
542            config.ws_private_url(),
543            "wss://wseea.okx.com:8443/ws/v5/private"
544        );
545        assert_eq!(
546            config.ws_business_url(),
547            "wss://wseea.okx.com:8443/ws/v5/business"
548        );
549    }
550
551    #[rstest]
552    fn test_config_region_override_takes_precedence() {
553        let config = OKXDataClientConfig::builder()
554            .region(OKXRegion::Eea)
555            .base_url_http("https://custom.proxy".to_string())
556            .build();
557
558        assert_eq!(config.http_base_url(), "https://custom.proxy");
559    }
560
561    #[rstest]
562    fn test_data_config_toml_region() {
563        let config: OKXDataClientConfig = toml::from_str(
564            r#"
565region = "eea"
566"#,
567        )
568        .unwrap();
569
570        assert_eq!(config.region, OKXRegion::Eea);
571    }
572
573    #[rstest]
574    fn test_exec_config_auth_timeout_secs() {
575        assert_eq!(OKXExecutionClientConfig::default().auth_timeout_secs, None);
576
577        let exec = OKXExecutionClientConfig::builder()
578            .auth_timeout_secs(4)
579            .build();
580        assert_eq!(exec.auth_timeout_secs, Some(4));
581
582        let exec: OKXExecutionClientConfig = toml::from_str("auth_timeout_secs = 8\n").unwrap();
583        assert_eq!(exec.auth_timeout_secs, Some(8));
584    }
585}