1use std::{any::Any, collections::HashMap, fmt::Debug, str::FromStr, time::Duration};
19
20use nautilus_common::{config::check_unique_values, factories::ClientConfig};
21#[cfg(test)]
22use nautilus_core::string::secret::REDACTED;
23use nautilus_core::string::secret::SecretString;
24use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
25use nautilus_model::{
26 enums::OmsType,
27 identifiers::{AccountId, InstrumentId},
28 types::Currency,
29};
30use nautilus_network::{
31 backoff::ExponentialBackoff, retry::RetryConfig, websocket::TransportBackend,
32};
33use rust_decimal::Decimal;
34use serde::{Deserialize, Serialize};
35
36use crate::common::{
37 enums::{BinanceEnvironment, BinanceMarginType, BinanceProductType},
38 instruments::filter_values,
39};
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
43#[serde(default, deny_unknown_fields)]
44#[cfg_attr(
45 feature = "python",
46 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
47)]
48#[cfg_attr(
49 feature = "python",
50 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
51)]
52pub struct BinanceInstrumentProviderConfig {
53 #[builder(default = true)]
55 pub load_all: bool,
56 pub load_ids: Option<Vec<String>>,
58 #[builder(default)]
63 pub filters: HashMap<String, serde_json::Value>,
64 pub filter_callable: Option<String>,
69 #[builder(default = true)]
74 pub log_warnings: bool,
75 #[builder(default)]
77 pub query_commission_rates: bool,
78}
79
80impl Default for BinanceInstrumentProviderConfig {
81 fn default() -> Self {
82 Self::builder().build()
83 }
84}
85
86impl BinanceInstrumentProviderConfig {
87 pub fn validate(&self, product_type: BinanceProductType) -> anyhow::Result<()> {
96 if let Some(filter_callable) = self
97 .filter_callable
98 .as_deref()
99 .map(str::trim)
100 .filter(|value| !value.is_empty())
101 {
102 anyhow::bail!(
103 "Binance v2 does not support instrument filter_callable {filter_callable:?}; \
104 the legacy Binance provider never applied callable filters"
105 );
106 }
107
108 if let Some(load_ids) = &self.load_ids {
109 for raw in load_ids {
110 let instrument_id = InstrumentId::from_str(raw)
111 .map_err(|e| anyhow::anyhow!("invalid Binance load_ids value {raw:?}: {e}"))?;
112 anyhow::ensure!(
113 instrument_id.venue.as_str() == "BINANCE",
114 "Binance load_ids value {raw:?} must use venue BINANCE"
115 );
116 }
117 }
118
119 for (key, value) in &self.filters {
120 let supported = matches!(key.as_str(), "symbols" | "bases" | "quotes")
121 || key == "contract_types"
122 && matches!(
123 product_type,
124 BinanceProductType::UsdM | BinanceProductType::CoinM
125 );
126 anyhow::ensure!(
127 supported,
128 "unsupported Binance instrument filter {key:?} for {product_type:?}"
129 );
130 validate_filter_strings(key, value)?;
131 }
132
133 self.validate_selections()?;
134 Ok(())
135 }
136
137 pub(crate) fn validate_selections(&self) -> anyhow::Result<()> {
138 if let Some(values) = &self.load_ids {
139 check_unique_values("instrument_provider.load_ids", values)?;
140 }
141
142 for (name, value) in &self.filters {
143 if value.is_array() {
144 filter_values(self, name)?;
145 }
146 }
147
148 Ok(())
149 }
150
151 pub(crate) fn excludes(&self, instrument_id: InstrumentId) -> bool {
152 !self.load_all
153 && self.load_ids.as_ref().is_some_and(|load_ids| {
154 load_ids
155 .iter()
156 .all(|raw_id| InstrumentId::from(raw_id.as_str()) != instrument_id)
157 })
158 }
159}
160
161fn validate_filter_strings(name: &str, value: &serde_json::Value) -> anyhow::Result<()> {
162 let valid = match value {
163 serde_json::Value::String(value) => !value.trim().is_empty(),
164 serde_json::Value::Array(values) => {
165 !values.is_empty()
166 && values
167 .iter()
168 .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))
169 }
170 _ => false,
171 };
172
173 anyhow::ensure!(
174 valid,
175 "Binance instrument filter {name:?} must be a non-empty string or array of strings"
176 );
177
178 Ok(())
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(
184 feature = "python",
185 pyo3::pyclass(module = "nautilus_trader.adapters.binance", eq, from_py_object)
186)]
187#[cfg_attr(
188 feature = "python",
189 pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.binance")
190)]
191pub enum BinanceSpotMarketDataMode {
192 #[default]
193 Sbe,
195 Json,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
203#[serde(default, deny_unknown_fields)]
204#[cfg_attr(
205 feature = "python",
206 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
207)]
208#[cfg_attr(
209 feature = "python",
210 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
211)]
212pub struct BinanceDataClientConfig {
213 #[builder(default = BinanceProductType::Spot)]
215 pub product_type: BinanceProductType,
216 #[builder(default = BinanceEnvironment::Live)]
218 pub environment: BinanceEnvironment,
219 pub api_key: Option<SecretString>,
221 pub api_secret: Option<SecretString>,
223 pub base_url_http: Option<String>,
225 pub base_url_ws: Option<String>,
230 pub proxy_url: Option<SecretString>,
232 #[builder(default)]
237 pub spot_market_data_mode: BinanceSpotMarketDataMode,
238 #[builder(default)]
240 pub instrument_provider: BinanceInstrumentProviderConfig,
241 #[builder(default = 3600)]
245 pub instrument_refresh_interval_secs: u64,
246 #[builder(default = 3600)]
249 pub instrument_status_poll_secs: u64,
250 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
255 pub book_snapshot_timeout_secs: u64,
256 #[builder(default = 5_000)]
258 pub recv_window_ms: u64,
259 #[builder(default = RetryConfig::default().max_retries)]
261 pub max_retries: u32,
262 #[builder(default = RetryConfig::default().initial_delay_ms)]
264 pub retry_delay_initial_ms: u64,
265 #[builder(default = RetryConfig::default().max_delay_ms)]
267 pub retry_delay_max_ms: u64,
268 #[builder(default)]
270 pub us: bool,
271 #[builder(default)]
273 pub transport_backend: TransportBackend,
274}
275
276#[cfg(feature = "python")]
277nautilus_core::impl_pyo3_config_getters!(BinanceDataClientConfig {
278 product_type: BinanceProductType,
279 environment: BinanceEnvironment,
280 base_url_http: Option<String>,
281 base_url_ws: Option<String>,
282 spot_market_data_mode: BinanceSpotMarketDataMode,
283 instrument_provider: BinanceInstrumentProviderConfig,
284 instrument_refresh_interval_secs: u64,
285 instrument_status_poll_secs: u64,
286 book_snapshot_timeout_secs: u64,
287 recv_window_ms: u64,
288 max_retries: u32,
289 retry_delay_initial_ms: u64,
290 retry_delay_max_ms: u64,
291 us: bool,
292 transport_backend: TransportBackend,
293});
294
295impl Default for BinanceDataClientConfig {
296 fn default() -> Self {
297 Self::builder().build()
298 }
299}
300
301impl BinanceDataClientConfig {
302 pub fn validate(&self) -> anyhow::Result<()> {
308 validate_recv_window(self.recv_window_ms)?;
309 validate_retry_config(&self.retry_config())?;
310 self.instrument_provider.validate(self.product_type)?;
311
312 if self.us {
313 anyhow::ensure!(
314 self.product_type == BinanceProductType::Spot,
315 "Binance US supports Spot clients only"
316 );
317 anyhow::ensure!(
318 self.environment == BinanceEnvironment::Live,
319 "Binance US supports the Live environment only"
320 );
321 anyhow::ensure!(
322 self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
323 "Binance US market data requires spot_market_data_mode=Json"
324 );
325 }
326
327 Ok(())
328 }
329
330 pub(crate) fn retry_config(&self) -> RetryConfig {
331 RetryConfig {
332 max_retries: self.max_retries,
333 initial_delay_ms: self.retry_delay_initial_ms,
334 max_delay_ms: self.retry_delay_max_ms,
335 ..crate::common::http::retry_config()
336 }
337 }
338}
339
340impl ClientConfig for BinanceDataClientConfig {
341 fn as_any(&self) -> &dyn Any {
342 self
343 }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
351#[serde(default, deny_unknown_fields)]
352#[cfg_attr(
353 feature = "python",
354 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
355)]
356#[cfg_attr(
357 feature = "python",
358 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
359)]
360pub struct BinanceExecutionClientConfig {
361 #[builder(default = AccountId::from("BINANCE-001"))]
363 pub account_id: AccountId,
364 #[builder(default = BinanceProductType::Spot)]
366 pub product_type: BinanceProductType,
367 #[builder(default = BinanceEnvironment::Live)]
369 pub environment: BinanceEnvironment,
370 pub api_key: Option<SecretString>,
372 pub api_secret: Option<SecretString>,
374 pub base_url_http: Option<String>,
376 pub base_url_ws: Option<String>,
380 pub base_url_ws_trading: Option<String>,
382 pub proxy_url: Option<SecretString>,
384 #[builder(default = true)]
386 pub use_ws_trading: bool,
387 #[builder(default = 10_000)]
389 pub ws_trading_setup_timeout_ms: u64,
390 #[builder(default)]
392 pub instrument_provider: BinanceInstrumentProviderConfig,
393 #[builder(default = 3600)]
397 pub instrument_refresh_interval_secs: u64,
398 #[builder(default = true)]
403 pub use_gtd: bool,
404 #[builder(default = true)]
411 pub use_position_ids: bool,
412 pub oms_type: Option<OmsType>,
417 #[builder(default = Decimal::new(4, 4))]
423 pub default_taker_fee: Decimal,
424 #[builder(default = 5_000)]
426 pub recv_window_ms: u64,
427 #[builder(default = RetryConfig::default().max_retries)]
429 pub max_retries: u32,
430 #[builder(default = RetryConfig::default().initial_delay_ms)]
432 pub retry_delay_initial_ms: u64,
433 #[builder(default = RetryConfig::default().max_delay_ms)]
435 pub retry_delay_max_ms: u64,
436 #[builder(default)]
438 pub us: bool,
439 pub futures_leverages: Option<HashMap<String, u32>>,
441 pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
443 #[builder(default = Currency::USDT())]
445 pub bnfcr_currency: Currency,
446 #[builder(default = false)]
451 pub treat_expired_as_canceled: bool,
452 #[builder(default = false)]
456 pub use_trade_lite: bool,
457 #[builder(default)]
459 pub transport_backend: TransportBackend,
460}
461
462#[cfg(feature = "python")]
463nautilus_core::impl_pyo3_config_getters!(BinanceExecutionClientConfig {
464 account_id: AccountId,
465 product_type: BinanceProductType,
466 environment: BinanceEnvironment,
467 base_url_http: Option<String>,
468 base_url_ws: Option<String>,
469 base_url_ws_trading: Option<String>,
470 use_ws_trading: bool,
471 ws_trading_setup_timeout_ms: u64,
472 instrument_provider: BinanceInstrumentProviderConfig,
473 instrument_refresh_interval_secs: u64,
474 use_gtd: bool,
475 use_position_ids: bool,
476 oms_type: Option<OmsType>,
477 default_taker_fee: Decimal,
478 recv_window_ms: u64,
479 max_retries: u32,
480 retry_delay_initial_ms: u64,
481 retry_delay_max_ms: u64,
482 us: bool,
483 futures_leverages: Option<HashMap<String, u32>>,
484 futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
485 treat_expired_as_canceled: bool,
486 use_trade_lite: bool,
487 bnfcr_currency: Currency,
488 transport_backend: TransportBackend,
489});
490
491impl Default for BinanceExecutionClientConfig {
492 fn default() -> Self {
493 Self::builder().build()
494 }
495}
496
497impl BinanceExecutionClientConfig {
498 pub fn validate(&self) -> anyhow::Result<()> {
505 validate_recv_window(self.recv_window_ms)?;
506 validate_retry_config(&self.retry_config())?;
507 anyhow::ensure!(
508 self.ws_trading_setup_timeout_ms > 0,
509 "ws_trading_setup_timeout_ms must be greater than 0, was {}",
510 self.ws_trading_setup_timeout_ms
511 );
512 self.instrument_provider.validate(self.product_type)?;
513
514 if self.us {
515 anyhow::ensure!(
516 self.product_type == BinanceProductType::Spot,
517 "Binance US supports Spot clients only"
518 );
519 anyhow::ensure!(
520 self.environment == BinanceEnvironment::Live,
521 "Binance US supports the Live environment only"
522 );
523 }
524
525 Ok(())
526 }
527
528 pub(crate) fn retry_config(&self) -> RetryConfig {
529 RetryConfig {
530 max_retries: self.max_retries,
531 initial_delay_ms: self.retry_delay_initial_ms,
532 max_delay_ms: self.retry_delay_max_ms,
533 ..crate::common::http::retry_config()
534 }
535 }
536}
537
538fn validate_retry_config(config: &RetryConfig) -> anyhow::Result<()> {
539 ExponentialBackoff::new(
540 Duration::from_millis(config.initial_delay_ms),
541 Duration::from_millis(config.max_delay_ms),
542 config.backoff_factor,
543 config.jitter_ms,
544 config.immediate_first,
545 )?;
546 Ok(())
547}
548
549fn validate_recv_window(recv_window_ms: u64) -> anyhow::Result<()> {
550 anyhow::ensure!(
551 (1..=60_000).contains(&recv_window_ms),
552 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
553 );
554 Ok(())
555}
556
557impl ClientConfig for BinanceExecutionClientConfig {
558 fn as_any(&self) -> &dyn Any {
559 self
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use nautilus_common::config::ConfigError;
566 use rstest::rstest;
567
568 use super::*;
569
570 #[rstest]
571 fn test_config_debug_redacts_credentials() {
572 let data = BinanceDataClientConfig {
573 api_key: Some("data-api-key".into()),
574 api_secret: Some("data-api-secret".into()),
575 proxy_url: Some("http://user:data-proxy@localhost".into()),
576 ..Default::default()
577 };
578 let execution = BinanceExecutionClientConfig {
579 api_key: Some("exec-api-key".into()),
580 api_secret: Some("exec-api-secret".into()),
581 proxy_url: Some("http://user:exec-proxy@localhost".into()),
582 ..Default::default()
583 };
584
585 let formatted = format!("{data:?} {execution:?}");
586
587 assert_eq!(formatted.matches(REDACTED).count(), 6);
588
589 for secret in [
590 "data-api-key",
591 "data-api-secret",
592 "data-proxy",
593 "exec-api-key",
594 "exec-api-secret",
595 "exec-proxy",
596 ] {
597 assert!(!formatted.contains(secret));
598 }
599 }
600
601 #[rstest]
602 fn test_data_config_toml_minimal() {
603 let config: BinanceDataClientConfig = toml::from_str(
604 r#"
605environment = "Testnet"
606product_type = "USD_M"
607instrument_status_poll_secs = 600
608"#,
609 )
610 .unwrap();
611
612 assert_eq!(config.environment, BinanceEnvironment::Testnet);
613 assert_eq!(config.product_type, BinanceProductType::UsdM);
614 assert_eq!(config.spot_market_data_mode, BinanceSpotMarketDataMode::Sbe);
615 assert_eq!(config.instrument_status_poll_secs, 600);
616 assert_eq!(config.book_snapshot_timeout_secs, 10);
617 }
618
619 #[rstest]
620 fn test_data_config_toml_spot_market_data_mode_override() {
621 let config: BinanceDataClientConfig = toml::from_str(
622 r#"
623spot_market_data_mode = "Json"
624"#,
625 )
626 .unwrap();
627
628 assert_eq!(
629 config.spot_market_data_mode,
630 BinanceSpotMarketDataMode::Json
631 );
632 }
633
634 #[rstest]
635 fn test_data_config_toml_rejects_plural_product_types() {
636 let result = toml::from_str::<BinanceDataClientConfig>(
637 r#"
638product_types = ["SPOT", "USD_M"]
639"#,
640 );
641
642 let message = result.unwrap_err().to_string();
643 assert!(message.contains("unknown field `product_types`"));
644 }
645
646 #[rstest]
647 fn test_exec_config_toml_empty_uses_defaults() {
648 let config: BinanceExecutionClientConfig = toml::from_str("").unwrap();
649 let expected = BinanceExecutionClientConfig::default();
650
651 assert_eq!(config.environment, expected.environment);
652 assert_eq!(config.product_type, expected.product_type);
653 assert_eq!(config.use_ws_trading, expected.use_ws_trading);
654 assert_eq!(config.ws_trading_setup_timeout_ms, 10_000);
655 assert_eq!(config.instrument_provider, expected.instrument_provider);
656 assert_eq!(
657 config.instrument_refresh_interval_secs,
658 expected.instrument_refresh_interval_secs
659 );
660 assert_eq!(config.use_gtd, expected.use_gtd);
661 assert_eq!(config.use_position_ids, expected.use_position_ids);
662 assert_eq!(config.oms_type, expected.oms_type);
663 assert_eq!(config.default_taker_fee, expected.default_taker_fee);
664 assert_eq!(config.proxy_url, expected.proxy_url);
665 assert_eq!(config.recv_window_ms, expected.recv_window_ms);
666 assert_eq!(config.us, expected.us);
667 assert_eq!(
668 config.treat_expired_as_canceled,
669 expected.treat_expired_as_canceled,
670 );
671 assert_eq!(config.use_trade_lite, expected.use_trade_lite);
672 assert_eq!(config.transport_backend, expected.transport_backend);
673 }
674
675 #[rstest]
676 fn test_exec_config_toml_oms_type_override() {
677 let config: BinanceExecutionClientConfig = toml::from_str(
678 r#"
679oms_type = "Hedging"
680"#,
681 )
682 .unwrap();
683
684 assert_eq!(config.oms_type, Some(OmsType::Hedging));
685 }
686
687 #[rstest]
688 fn test_exec_config_toml_use_gtd_override() {
689 let config: BinanceExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
690
691 assert!(!config.use_gtd);
692 }
693
694 #[rstest]
695 fn test_exec_config_toml_ws_trading_setup_timeout_override() {
696 let config: BinanceExecutionClientConfig =
697 toml::from_str("ws_trading_setup_timeout_ms = 250").unwrap();
698
699 assert_eq!(config.ws_trading_setup_timeout_ms, 250);
700 }
701
702 #[rstest]
703 #[case(0)]
704 #[case(60_001)]
705 fn test_data_config_rejects_recv_window_out_of_bounds(#[case] recv_window_ms: u64) {
706 let config = BinanceDataClientConfig {
707 recv_window_ms,
708 ..Default::default()
709 };
710
711 let message = config.validate().unwrap_err().to_string();
712
713 assert_eq!(
714 message,
715 format!(
716 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
717 )
718 );
719 }
720
721 #[rstest]
722 fn test_exec_config_rejects_zero_ws_trading_setup_timeout() {
723 let config = BinanceExecutionClientConfig {
724 ws_trading_setup_timeout_ms: 0,
725 ..Default::default()
726 };
727
728 let message = config.validate().unwrap_err().to_string();
729
730 assert_eq!(
731 message,
732 "ws_trading_setup_timeout_ms must be greater than 0, was 0"
733 );
734 }
735
736 #[rstest]
737 #[case(1)]
738 #[case(60_000)]
739 fn test_exec_config_accepts_recv_window_bounds(#[case] recv_window_ms: u64) {
740 let config = BinanceExecutionClientConfig {
741 recv_window_ms,
742 ..Default::default()
743 };
744
745 assert!(config.validate().is_ok());
746 }
747
748 #[rstest]
749 #[case(
750 BinanceProductType::UsdM,
751 BinanceEnvironment::Live,
752 BinanceSpotMarketDataMode::Json,
753 "Binance US supports Spot clients only"
754 )]
755 #[case(
756 BinanceProductType::Spot,
757 BinanceEnvironment::Testnet,
758 BinanceSpotMarketDataMode::Json,
759 "Binance US supports the Live environment only"
760 )]
761 #[case(
762 BinanceProductType::Spot,
763 BinanceEnvironment::Live,
764 BinanceSpotMarketDataMode::Sbe,
765 "Binance US market data requires spot_market_data_mode=Json"
766 )]
767 fn test_data_config_rejects_unsupported_binance_us_combinations(
768 #[case] product_type: BinanceProductType,
769 #[case] environment: BinanceEnvironment,
770 #[case] spot_market_data_mode: BinanceSpotMarketDataMode,
771 #[case] expected: &str,
772 ) {
773 let config = BinanceDataClientConfig {
774 product_type,
775 environment,
776 spot_market_data_mode,
777 us: true,
778 ..Default::default()
779 };
780
781 assert_eq!(config.validate().unwrap_err().to_string(), expected);
782 }
783
784 #[rstest]
785 #[case(
786 BinanceProductType::CoinM,
787 BinanceEnvironment::Live,
788 "Binance US supports Spot clients only"
789 )]
790 #[case(
791 BinanceProductType::Spot,
792 BinanceEnvironment::Demo,
793 "Binance US supports the Live environment only"
794 )]
795 fn test_exec_config_rejects_unsupported_binance_us_combinations(
796 #[case] product_type: BinanceProductType,
797 #[case] environment: BinanceEnvironment,
798 #[case] expected: &str,
799 ) {
800 let config = BinanceExecutionClientConfig {
801 product_type,
802 environment,
803 us: true,
804 ..Default::default()
805 };
806
807 assert_eq!(config.validate().unwrap_err().to_string(), expected);
808 }
809
810 #[rstest]
811 fn test_instrument_provider_rejects_callable_and_spot_contract_filter() {
812 let callable = BinanceInstrumentProviderConfig {
813 filter_callable: Some("package.module:predicate".to_string()),
814 ..Default::default()
815 };
816 let contract_filter = BinanceInstrumentProviderConfig {
817 filters: HashMap::from([(
818 "contract_types".to_string(),
819 serde_json::json!("PERPETUAL"),
820 )]),
821 ..Default::default()
822 };
823
824 assert_eq!(
825 callable
826 .validate(BinanceProductType::Spot)
827 .unwrap_err()
828 .to_string(),
829 "Binance v2 does not support instrument filter_callable \"package.module:predicate\"; the legacy Binance provider never applied callable filters"
830 );
831 assert_eq!(
832 contract_filter
833 .validate(BinanceProductType::Spot)
834 .unwrap_err()
835 .to_string(),
836 "unsupported Binance instrument filter \"contract_types\" for Spot"
837 );
838 }
839
840 #[rstest]
841 fn test_instrument_provider_rejects_empty_and_non_string_filter_values() {
842 for value in [serde_json::json!([]), serde_json::json!(["BTC", 7])] {
843 let config = BinanceInstrumentProviderConfig {
844 filters: HashMap::from([("bases".to_string(), value)]),
845 ..Default::default()
846 };
847
848 assert_eq!(
849 config
850 .validate(BinanceProductType::UsdM)
851 .unwrap_err()
852 .to_string(),
853 "Binance instrument filter \"bases\" must be a non-empty string or array of strings"
854 );
855 }
856 }
857
858 #[rstest]
859 #[case("load_ids", serde_json::json!(["BTCUSDT.BINANCE", "ETHUSDT.BINANCE"]))]
860 fn test_instrument_provider_selection_uniqueness(
861 #[case] field: &str,
862 #[case] mut values: serde_json::Value,
863 #[values(false, true)] duplicate: bool,
864 ) {
865 if duplicate {
866 let entries = values.as_array_mut().unwrap();
867 entries.push(entries[0].clone());
868 }
869
870 let mut input = serde_json::to_value(BinanceInstrumentProviderConfig::default()).unwrap();
871 input[field] = values;
872 let config: BinanceInstrumentProviderConfig = serde_json::from_value(input).unwrap();
873 let original = serde_json::to_value(&config).unwrap();
874
875 let expected = if duplicate {
876 Err(ConfigError::duplicate(
877 format!("instrument_provider.{field}"),
878 None,
879 ))
880 } else {
881 Ok(())
882 };
883
884 let result = config
885 .validate(BinanceProductType::Spot)
886 .map_err(|e| e.downcast::<ConfigError>().unwrap());
887
888 assert_eq!(result, expected);
889 assert_eq!(serde_json::to_value(&config).unwrap(), original);
890 }
891
892 #[rstest]
893 #[case("symbols")]
894 #[case("bases")]
895 #[case("quotes")]
896 #[case("contract_types")]
897 fn test_filter_arrays_reject_duplicates(#[case] field: &str) {
898 let mut config = BinanceInstrumentProviderConfig::default();
899 config.filters.insert(
900 field.to_string(),
901 serde_json::json!(["first", "second", "first"]),
902 );
903 let error = config.validate(BinanceProductType::UsdM).unwrap_err();
904
905 assert_eq!(
906 error.downcast_ref::<ConfigError>(),
907 Some(&ConfigError::duplicate(
908 format!("instrument_provider.filters.{field}"),
909 None
910 ))
911 );
912 }
913
914 #[rstest]
915 #[case("symbols", serde_json::json!(["BTCUSDT", " btcusdt "]))]
916 #[case("bases", serde_json::json!(["BTC", " btc "]))]
917 #[case("quotes", serde_json::json!(["USDT", " usdt "]))]
918 #[case("contract_types", serde_json::json!(["PERPETUAL", " perpetual "]))]
919 fn test_filter_arrays_reject_normalized_duplicates(
920 #[case] field: &str,
921 #[case] values: serde_json::Value,
922 ) {
923 let mut config = BinanceInstrumentProviderConfig::default();
924 config.filters.insert(field.to_string(), values.clone());
925 let error = config.validate(BinanceProductType::UsdM).unwrap_err();
926 assert_eq!(
927 error.downcast_ref::<ConfigError>(),
928 Some(&ConfigError::duplicate(
929 format!("instrument_provider.filters.{field}"),
930 None
931 ))
932 );
933 assert_eq!(config.filters.get(field), Some(&values));
934 }
935}