1use 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#[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 pub api_key: Option<SecretString>,
50 pub api_secret: Option<SecretString>,
52 pub api_passphrase: Option<SecretString>,
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 #[builder(default)]
67 pub environment: OKXEnvironment,
68 #[builder(default)]
70 pub region: OKXRegion,
71 pub base_url_http: Option<String>,
73 pub base_url_ws_public: Option<String>,
75 pub base_url_ws_business: Option<String>,
77 pub proxy_url: Option<SecretString>,
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 = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
108 pub book_snapshot_timeout_secs: u64,
109 pub vip_level: Option<OKXVipLevel>,
111 #[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 #[must_use]
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 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 #[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 #[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 #[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 #[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 #[must_use]
209 pub fn requires_business_ws(&self) -> bool {
210 true
211 }
212}
213
214#[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 #[builder(default = AccountId::from("OKX-001"))]
228 pub account_id: AccountId,
229 pub oms_type: Option<OmsType>,
232 pub api_key: Option<SecretString>,
234 pub api_secret: Option<SecretString>,
236 pub api_passphrase: Option<SecretString>,
238 #[builder(default = vec![OKXInstrumentType::Spot])]
240 pub instrument_types: Vec<OKXInstrumentType>,
241 pub contract_types: Option<Vec<OKXContractType>>,
243 pub instrument_families: Option<Vec<String>>,
246 #[builder(default)]
248 pub environment: OKXEnvironment,
249 #[builder(default)]
251 pub region: OKXRegion,
252 pub base_url_http: Option<String>,
254 pub base_url_ws_private: Option<String>,
256 pub base_url_ws_business: Option<String>,
258 pub proxy_url: Option<SecretString>,
260 #[builder(default = 60)]
262 pub http_timeout_secs: u64,
263 #[builder(default)]
265 pub load_spreads: bool,
266 #[builder(default)]
268 pub use_mm_mass_cancel: bool,
269 #[builder(default = 3)]
271 pub max_retries: u32,
272 #[builder(default = 1_000)]
274 pub retry_delay_initial_ms: u64,
275 #[builder(default = 10_000)]
277 pub retry_delay_max_ms: u64,
278 pub margin_mode: Option<OKXMarginMode>,
280 #[builder(default)]
282 pub use_spot_margin: bool,
283 pub spot_trade_quote_ccy: Option<String>,
290 pub auth_timeout_secs: Option<u64>,
293 #[builder(default)]
295 pub transport_backend: TransportBackend,
296 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 #[must_use]
335 pub fn new() -> Self {
336 Self::default()
337 }
338
339 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 #[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 #[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 #[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 #[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 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}