1use std::fmt::Debug;
19
20use nautilus_core::string::secret::REDACTED;
21use nautilus_model::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#[derive(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 pub api_key: Option<String>,
50 pub api_secret: Option<String>,
52 pub api_passphrase: Option<String>,
54 #[builder(default = vec![OKXInstrumentType::Spot])]
56 pub instrument_types: Vec<OKXInstrumentType>,
57 pub contract_types: Option<Vec<OKXContractType>>,
59 #[builder(default)]
61 pub load_spreads: bool,
62 pub instrument_families: Option<Vec<String>>,
65 pub base_url_http: Option<String>,
67 pub base_url_ws_public: Option<String>,
69 pub base_url_ws_business: Option<String>,
71 pub proxy_url: Option<String>,
73 #[builder(default)]
75 pub environment: OKXEnvironment,
76 #[builder(default)]
78 pub region: OKXRegion,
79 #[builder(default = 60)]
81 pub http_timeout_secs: u64,
82 #[builder(default = 3)]
84 pub max_retries: u32,
85 #[builder(default = 1_000)]
87 pub retry_delay_initial_ms: u64,
88 #[builder(default = 10_000)]
90 pub retry_delay_max_ms: u64,
91 #[builder(default = 60)]
96 pub update_instruments_interval_mins: u64,
97 #[builder(default = 5)]
99 pub book_stale_check_interval_secs: u64,
100 #[builder(default = 30)]
104 pub book_stale_threshold_secs: u64,
105 #[builder(default = 3)]
107 pub book_snapshot_timeout_secs: u64,
108 pub vip_level: Option<OKXVipLevel>,
110 #[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 Debug for OKXDataClientConfig {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct(stringify!(OKXDataClientConfig))
146 .field("api_key", &self.api_key.as_ref().map(|_| REDACTED))
147 .field("api_secret", &self.api_secret.as_ref().map(|_| REDACTED))
148 .field(
149 "api_passphrase",
150 &self.api_passphrase.as_ref().map(|_| REDACTED),
151 )
152 .field("instrument_types", &self.instrument_types)
153 .field("contract_types", &self.contract_types)
154 .field("load_spreads", &self.load_spreads)
155 .field("instrument_families", &self.instrument_families)
156 .field("base_url_http", &self.base_url_http)
157 .field("base_url_ws_public", &self.base_url_ws_public)
158 .field("base_url_ws_business", &self.base_url_ws_business)
159 .field("proxy_url", &self.proxy_url)
160 .field("environment", &self.environment)
161 .field("region", &self.region)
162 .field("http_timeout_secs", &self.http_timeout_secs)
163 .field("max_retries", &self.max_retries)
164 .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
165 .field("retry_delay_max_ms", &self.retry_delay_max_ms)
166 .field(
167 "update_instruments_interval_mins",
168 &self.update_instruments_interval_mins,
169 )
170 .field(
171 "book_stale_check_interval_secs",
172 &self.book_stale_check_interval_secs,
173 )
174 .field("book_stale_threshold_secs", &self.book_stale_threshold_secs)
175 .field(
176 "book_snapshot_timeout_secs",
177 &self.book_snapshot_timeout_secs,
178 )
179 .field("vip_level", &self.vip_level)
180 .field("transport_backend", &self.transport_backend)
181 .finish()
182 }
183}
184
185impl OKXDataClientConfig {
186 #[must_use]
188 pub fn new() -> Self {
189 Self::default()
190 }
191
192 #[must_use]
194 pub fn has_api_credentials(&self) -> bool {
195 let (key_var, secret_var, passphrase_var) = credential_env_vars();
196 let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
197 let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
198 let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
199 has_key && has_secret && has_passphrase
200 }
201
202 #[must_use]
204 pub fn http_base_url(&self) -> String {
205 self.base_url_http
206 .clone()
207 .unwrap_or_else(|| get_http_base_url(self.region).to_string())
208 }
209
210 #[must_use]
212 pub fn ws_public_url(&self) -> String {
213 self.base_url_ws_public
214 .clone()
215 .unwrap_or_else(|| get_ws_base_url_public(self.region, self.environment).to_string())
216 }
217
218 #[must_use]
220 pub fn ws_business_url(&self) -> String {
221 self.base_url_ws_business
222 .clone()
223 .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
224 }
225
226 #[must_use]
231 pub fn requires_business_ws(&self) -> bool {
232 true
233 }
234}
235
236#[derive(Clone, Serialize, Deserialize, bon::Builder)]
238#[serde(default, deny_unknown_fields)]
239#[cfg_attr(
240 feature = "python",
241 pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
242)]
243#[cfg_attr(
244 feature = "python",
245 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
246)]
247pub struct OKXExecutionClientConfig {
248 #[builder(default = AccountId::from("OKX-001"))]
250 pub account_id: AccountId,
251 pub api_key: Option<String>,
253 pub api_secret: Option<String>,
255 pub api_passphrase: Option<String>,
257 #[builder(default = vec![OKXInstrumentType::Spot])]
259 pub instrument_types: Vec<OKXInstrumentType>,
260 pub contract_types: Option<Vec<OKXContractType>>,
262 pub instrument_families: Option<Vec<String>>,
265 pub base_url_http: Option<String>,
267 pub base_url_ws_private: Option<String>,
269 pub base_url_ws_business: Option<String>,
271 pub proxy_url: Option<String>,
273 #[builder(default)]
275 pub environment: OKXEnvironment,
276 #[builder(default)]
278 pub region: OKXRegion,
279 #[builder(default = 60)]
281 pub http_timeout_secs: u64,
282 #[builder(default)]
284 pub load_spreads: bool,
285 #[builder(default)]
287 pub use_mm_mass_cancel: bool,
288 #[builder(default = 3)]
290 pub max_retries: u32,
291 #[builder(default = 1_000)]
293 pub retry_delay_initial_ms: u64,
294 #[builder(default = 10_000)]
296 pub retry_delay_max_ms: u64,
297 pub margin_mode: Option<OKXMarginMode>,
299 #[builder(default)]
301 pub use_spot_margin: bool,
302 pub auth_timeout_secs: Option<u64>,
305 #[builder(default)]
307 pub transport_backend: TransportBackend,
308}
309
310#[cfg(feature = "python")]
311nautilus_core::impl_pyo3_config_getters!(OKXExecutionClientConfig {
312 account_id: AccountId,
313 instrument_types: Vec<OKXInstrumentType>,
314 environment: OKXEnvironment,
315 region: OKXRegion,
316 base_url_http: Option<String>,
317 base_url_ws_private: Option<String>,
318 base_url_ws_business: Option<String>,
319 http_timeout_secs: u64,
320 max_retries: u32,
321 retry_delay_initial_ms: u64,
322 retry_delay_max_ms: u64,
323 margin_mode: Option<OKXMarginMode>,
324 load_spreads: bool,
325 auth_timeout_secs: Option<u64>,
326 transport_backend: TransportBackend,
327});
328
329impl Default for OKXExecutionClientConfig {
330 fn default() -> Self {
331 Self::builder().build()
332 }
333}
334
335impl Debug for OKXExecutionClientConfig {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.debug_struct(stringify!(OKXExecutionClientConfig))
338 .field("account_id", &self.account_id)
339 .field("api_key", &self.api_key.as_ref().map(|_| REDACTED))
340 .field("api_secret", &self.api_secret.as_ref().map(|_| REDACTED))
341 .field(
342 "api_passphrase",
343 &self.api_passphrase.as_ref().map(|_| REDACTED),
344 )
345 .field("instrument_types", &self.instrument_types)
346 .field("contract_types", &self.contract_types)
347 .field("instrument_families", &self.instrument_families)
348 .field("base_url_http", &self.base_url_http)
349 .field("base_url_ws_private", &self.base_url_ws_private)
350 .field("base_url_ws_business", &self.base_url_ws_business)
351 .field("proxy_url", &self.proxy_url)
352 .field("environment", &self.environment)
353 .field("region", &self.region)
354 .field("http_timeout_secs", &self.http_timeout_secs)
355 .field("load_spreads", &self.load_spreads)
356 .field("use_mm_mass_cancel", &self.use_mm_mass_cancel)
357 .field("max_retries", &self.max_retries)
358 .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
359 .field("retry_delay_max_ms", &self.retry_delay_max_ms)
360 .field("margin_mode", &self.margin_mode)
361 .field("use_spot_margin", &self.use_spot_margin)
362 .field("auth_timeout_secs", &self.auth_timeout_secs)
363 .field("transport_backend", &self.transport_backend)
364 .finish()
365 }
366}
367
368impl OKXExecutionClientConfig {
369 #[must_use]
371 pub fn new() -> Self {
372 Self::default()
373 }
374
375 #[must_use]
377 pub fn has_api_credentials(&self) -> bool {
378 let (key_var, secret_var, passphrase_var) = credential_env_vars();
379 let has_key = self.api_key.is_some() || std::env::var(key_var).is_ok();
380 let has_secret = self.api_secret.is_some() || std::env::var(secret_var).is_ok();
381 let has_passphrase = self.api_passphrase.is_some() || std::env::var(passphrase_var).is_ok();
382 has_key && has_secret && has_passphrase
383 }
384
385 #[must_use]
387 pub fn http_base_url(&self) -> String {
388 self.base_url_http
389 .clone()
390 .unwrap_or_else(|| get_http_base_url(self.region).to_string())
391 }
392
393 #[must_use]
395 pub fn ws_private_url(&self) -> String {
396 self.base_url_ws_private
397 .clone()
398 .unwrap_or_else(|| get_ws_base_url_private(self.region, self.environment).to_string())
399 }
400
401 #[must_use]
403 pub fn ws_business_url(&self) -> String {
404 self.base_url_ws_business
405 .clone()
406 .unwrap_or_else(|| get_ws_base_url_business(self.region, self.environment).to_string())
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use rstest::rstest;
413
414 use super::*;
415
416 const DATA_API_KEY: &str = "okx-data-api-key-sentinel";
417 const DATA_API_SECRET: &str = "okx-data-api-secret-sentinel";
418 const DATA_API_PASSPHRASE: &str = "okx-data-api-passphrase-sentinel";
419 const EXEC_API_KEY: &str = "okx-exec-api-key-sentinel";
420 const EXEC_API_SECRET: &str = "okx-exec-api-secret-sentinel";
421 const EXEC_API_PASSPHRASE: &str = "okx-exec-api-passphrase-sentinel";
422
423 #[rstest]
424 fn test_data_config_debug_redacts_credentials() {
425 let config = OKXDataClientConfig {
426 api_key: Some(DATA_API_KEY.to_string()),
427 api_secret: Some(DATA_API_SECRET.to_string()),
428 api_passphrase: Some(DATA_API_PASSPHRASE.to_string()),
429 environment: OKXEnvironment::Demo,
430 http_timeout_secs: 71,
431 ..Default::default()
432 };
433
434 let debug_output = format!("{config:?}");
435 let redacted = format!("Some({REDACTED:?})");
436
437 assert!(!debug_output.contains(DATA_API_KEY));
438 assert!(!debug_output.contains(DATA_API_SECRET));
439 assert!(!debug_output.contains(DATA_API_PASSPHRASE));
440 assert!(debug_output.contains(&format!("api_key: {redacted}")));
441 assert!(debug_output.contains(&format!("api_secret: {redacted}")));
442 assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
443 assert!(debug_output.contains("environment: Demo"));
444 assert!(debug_output.contains("http_timeout_secs: 71"));
445 }
446
447 #[rstest]
448 fn test_exec_config_debug_redacts_credentials() {
449 let config = OKXExecutionClientConfig {
450 account_id: AccountId::from("OKX-042"),
451 api_key: Some(EXEC_API_KEY.to_string()),
452 api_secret: Some(EXEC_API_SECRET.to_string()),
453 api_passphrase: Some(EXEC_API_PASSPHRASE.to_string()),
454 max_retries: 13,
455 ..Default::default()
456 };
457
458 let debug_output = format!("{config:?}");
459 let redacted = format!("Some({REDACTED:?})");
460
461 assert!(!debug_output.contains(EXEC_API_KEY));
462 assert!(!debug_output.contains(EXEC_API_SECRET));
463 assert!(!debug_output.contains(EXEC_API_PASSPHRASE));
464 assert!(debug_output.contains(&format!("api_key: {redacted}")));
465 assert!(debug_output.contains(&format!("api_secret: {redacted}")));
466 assert!(debug_output.contains(&format!("api_passphrase: {redacted}")));
467 assert!(debug_output.contains("OKX-042"));
468 assert!(debug_output.contains("max_retries: 13"));
469 }
470
471 #[rstest]
472 fn test_config_debug_handles_unset_and_partial_credentials() {
473 let data_debug = format!("{:?}", OKXDataClientConfig::default());
474 let exec_debug = format!(
475 "{:?}",
476 OKXExecutionClientConfig {
477 api_secret: Some(String::new()),
478 ..Default::default()
479 }
480 );
481
482 assert!(data_debug.contains("api_key: None"));
483 assert!(data_debug.contains("api_secret: None"));
484 assert!(data_debug.contains("api_passphrase: None"));
485 assert!(exec_debug.contains("api_key: None"));
486 assert!(exec_debug.contains(&format!("api_secret: Some({REDACTED:?})")));
487 assert!(exec_debug.contains("api_passphrase: None"));
488 }
489
490 #[rstest]
491 fn test_data_config_toml_minimal() {
492 let config: OKXDataClientConfig = toml::from_str(
493 r#"
494environment = "demo"
495instrument_types = ["SPOT", "SWAP"]
496http_timeout_secs = 90
497"#,
498 )
499 .unwrap();
500
501 assert_eq!(config.environment, OKXEnvironment::Demo);
502 assert_eq!(
503 config.instrument_types,
504 vec![OKXInstrumentType::Spot, OKXInstrumentType::Swap]
505 );
506 assert_eq!(config.http_timeout_secs, 90);
507 assert!(!config.load_spreads);
508 assert_eq!(config.book_stale_check_interval_secs, 5);
509 assert_eq!(config.book_stale_threshold_secs, 30);
510 assert_eq!(config.book_snapshot_timeout_secs, 3);
511 }
512
513 #[rstest]
514 fn test_data_config_toml_load_spreads() {
515 let config: OKXDataClientConfig = toml::from_str(
516 "
517load_spreads = true
518",
519 )
520 .unwrap();
521
522 assert!(config.load_spreads);
523 }
524
525 #[rstest]
526 fn test_data_config_toml_book_stale_settings() {
527 let config: OKXDataClientConfig = toml::from_str(
528 "
529book_stale_check_interval_secs = 2
530book_stale_threshold_secs = 7
531book_snapshot_timeout_secs = 4
532",
533 )
534 .unwrap();
535
536 assert_eq!(config.book_stale_check_interval_secs, 2);
537 assert_eq!(config.book_stale_threshold_secs, 7);
538 assert_eq!(config.book_snapshot_timeout_secs, 4);
539 }
540
541 #[rstest]
542 fn test_exec_config_toml_empty_uses_defaults() {
543 let config: OKXExecutionClientConfig = toml::from_str("").unwrap();
544 let expected = OKXExecutionClientConfig::default();
545 assert_eq!(config.account_id, expected.account_id);
546 assert_eq!(config.environment, expected.environment);
547 assert_eq!(config.instrument_types, expected.instrument_types);
548 assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
549 assert_eq!(config.load_spreads, expected.load_spreads);
550 assert_eq!(config.use_mm_mass_cancel, expected.use_mm_mass_cancel);
551 assert_eq!(config.transport_backend, expected.transport_backend);
552 }
553
554 #[rstest]
555 fn test_exec_config_toml_rejects_removed_fills_channel_key() {
556 let result: Result<OKXExecutionClientConfig, _> =
558 toml::from_str("use_fills_channel = true\n");
559 assert!(result.is_err());
560 }
561
562 #[rstest]
563 fn test_exec_config_toml_load_spreads() {
564 let config: OKXExecutionClientConfig = toml::from_str(
565 "
566load_spreads = true
567",
568 )
569 .unwrap();
570
571 assert!(config.load_spreads);
572 }
573
574 #[rstest]
575 fn test_data_config_default_region_is_global() {
576 let config = OKXDataClientConfig::default();
577
578 assert_eq!(config.region, OKXRegion::Global);
579 assert_eq!(config.http_base_url(), "https://www.okx.com");
580 assert_eq!(config.ws_public_url(), "wss://ws.okx.com:8443/ws/v5/public");
581 }
582
583 #[rstest]
584 fn test_data_config_eea_region_urls() {
585 let config = OKXDataClientConfig::builder()
586 .region(OKXRegion::Eea)
587 .build();
588
589 assert_eq!(config.http_base_url(), "https://eea.okx.com");
590 assert_eq!(
591 config.ws_public_url(),
592 "wss://wseea.okx.com:8443/ws/v5/public"
593 );
594 assert_eq!(
595 config.ws_business_url(),
596 "wss://wseea.okx.com:8443/ws/v5/business"
597 );
598 }
599
600 #[rstest]
601 fn test_exec_config_eea_region_urls() {
602 let config = OKXExecutionClientConfig::builder()
603 .region(OKXRegion::Eea)
604 .build();
605
606 assert_eq!(config.http_base_url(), "https://eea.okx.com");
607 assert_eq!(
608 config.ws_private_url(),
609 "wss://wseea.okx.com:8443/ws/v5/private"
610 );
611 assert_eq!(
612 config.ws_business_url(),
613 "wss://wseea.okx.com:8443/ws/v5/business"
614 );
615 }
616
617 #[rstest]
618 fn test_config_region_override_takes_precedence() {
619 let config = OKXDataClientConfig::builder()
620 .region(OKXRegion::Eea)
621 .base_url_http("https://custom.proxy".to_string())
622 .build();
623
624 assert_eq!(config.http_base_url(), "https://custom.proxy");
625 }
626
627 #[rstest]
628 fn test_data_config_toml_region() {
629 let config: OKXDataClientConfig = toml::from_str(
630 r#"
631region = "eea"
632"#,
633 )
634 .unwrap();
635
636 assert_eq!(config.region, OKXRegion::Eea);
637 }
638
639 #[rstest]
640 fn test_exec_config_auth_timeout_secs() {
641 assert_eq!(OKXExecutionClientConfig::default().auth_timeout_secs, None);
642
643 let exec = OKXExecutionClientConfig::builder()
644 .auth_timeout_secs(4)
645 .build();
646 assert_eq!(exec.auth_timeout_secs, Some(4));
647
648 let exec: OKXExecutionClientConfig = toml::from_str("auth_timeout_secs = 8\n").unwrap();
649 assert_eq!(exec.auth_timeout_secs, Some(8));
650 }
651}