1use std::{any::Any, collections::HashMap, fmt::Debug, str::FromStr, time::Duration};
19
20use nautilus_common::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::enums::{BinanceEnvironment, BinanceMarginType, BinanceProductType};
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
40#[serde(default, deny_unknown_fields)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
48)]
49pub struct BinanceInstrumentProviderConfig {
50 #[builder(default = true)]
52 pub load_all: bool,
53 pub load_ids: Option<Vec<String>>,
55 #[builder(default)]
60 pub filters: HashMap<String, serde_json::Value>,
61 pub filter_callable: Option<String>,
66 #[builder(default = true)]
71 pub log_warnings: bool,
72 #[builder(default)]
74 pub query_commission_rates: bool,
75}
76
77impl Default for BinanceInstrumentProviderConfig {
78 fn default() -> Self {
79 Self::builder().build()
80 }
81}
82
83impl BinanceInstrumentProviderConfig {
84 pub fn validate(&self, product_type: BinanceProductType) -> anyhow::Result<()> {
91 if let Some(filter_callable) = self
92 .filter_callable
93 .as_deref()
94 .map(str::trim)
95 .filter(|value| !value.is_empty())
96 {
97 anyhow::bail!(
98 "Binance v2 does not support instrument filter_callable {filter_callable:?}; \
99 the legacy Binance provider never applied callable filters"
100 );
101 }
102
103 if let Some(load_ids) = &self.load_ids {
104 for raw in load_ids {
105 let instrument_id = InstrumentId::from_str(raw)
106 .map_err(|e| anyhow::anyhow!("invalid Binance load_ids value {raw:?}: {e}"))?;
107 anyhow::ensure!(
108 instrument_id.venue.as_str() == "BINANCE",
109 "Binance load_ids value {raw:?} must use venue BINANCE"
110 );
111 }
112 }
113
114 for (key, value) in &self.filters {
115 let supported = matches!(key.as_str(), "symbols" | "bases" | "quotes")
116 || key == "contract_types"
117 && matches!(
118 product_type,
119 BinanceProductType::UsdM | BinanceProductType::CoinM
120 );
121 anyhow::ensure!(
122 supported,
123 "unsupported Binance instrument filter {key:?} for {product_type:?}"
124 );
125 validate_filter_strings(key, value)?;
126 }
127
128 Ok(())
129 }
130
131 pub(crate) fn excludes(&self, instrument_id: InstrumentId) -> bool {
132 !self.load_all
133 && self.load_ids.as_ref().is_some_and(|load_ids| {
134 load_ids
135 .iter()
136 .all(|raw_id| InstrumentId::from(raw_id.as_str()) != instrument_id)
137 })
138 }
139}
140
141fn validate_filter_strings(name: &str, value: &serde_json::Value) -> anyhow::Result<()> {
142 let valid = match value {
143 serde_json::Value::String(value) => !value.trim().is_empty(),
144 serde_json::Value::Array(values) => {
145 !values.is_empty()
146 && values
147 .iter()
148 .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))
149 }
150 _ => false,
151 };
152
153 anyhow::ensure!(
154 valid,
155 "Binance instrument filter {name:?} must be a non-empty string or array of strings"
156 );
157 Ok(())
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(
163 feature = "python",
164 pyo3::pyclass(module = "nautilus_trader.adapters.binance", eq, from_py_object)
165)]
166#[cfg_attr(
167 feature = "python",
168 pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.binance")
169)]
170pub enum BinanceSpotMarketDataMode {
171 #[default]
172 Sbe,
174 Json,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
182#[serde(default, deny_unknown_fields)]
183#[cfg_attr(
184 feature = "python",
185 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
186)]
187#[cfg_attr(
188 feature = "python",
189 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
190)]
191pub struct BinanceDataClientConfig {
192 #[builder(default = BinanceProductType::Spot)]
194 pub product_type: BinanceProductType,
195 #[builder(default = BinanceEnvironment::Live)]
197 pub environment: BinanceEnvironment,
198 pub api_key: Option<SecretString>,
200 pub api_secret: Option<SecretString>,
202 pub base_url_http: Option<String>,
204 pub base_url_ws: Option<String>,
209 pub proxy_url: Option<SecretString>,
211 #[builder(default)]
216 pub spot_market_data_mode: BinanceSpotMarketDataMode,
217 #[builder(default)]
219 pub instrument_provider: BinanceInstrumentProviderConfig,
220 #[builder(default = 3600)]
224 pub instrument_refresh_interval_secs: u64,
225 #[builder(default = 3600)]
228 pub instrument_status_poll_secs: u64,
229 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
234 pub book_snapshot_timeout_secs: u64,
235 #[builder(default = 5_000)]
237 pub recv_window_ms: u64,
238 #[builder(default = RetryConfig::default().max_retries)]
240 pub max_retries: u32,
241 #[builder(default = RetryConfig::default().initial_delay_ms)]
243 pub retry_delay_initial_ms: u64,
244 #[builder(default = RetryConfig::default().max_delay_ms)]
246 pub retry_delay_max_ms: u64,
247 #[builder(default)]
249 pub us: bool,
250 #[builder(default)]
252 pub transport_backend: TransportBackend,
253}
254
255#[cfg(feature = "python")]
256nautilus_core::impl_pyo3_config_getters!(BinanceDataClientConfig {
257 product_type: BinanceProductType,
258 environment: BinanceEnvironment,
259 base_url_http: Option<String>,
260 base_url_ws: Option<String>,
261 spot_market_data_mode: BinanceSpotMarketDataMode,
262 instrument_provider: BinanceInstrumentProviderConfig,
263 instrument_refresh_interval_secs: u64,
264 instrument_status_poll_secs: u64,
265 book_snapshot_timeout_secs: u64,
266 recv_window_ms: u64,
267 max_retries: u32,
268 retry_delay_initial_ms: u64,
269 retry_delay_max_ms: u64,
270 us: bool,
271 transport_backend: TransportBackend,
272});
273
274impl Default for BinanceDataClientConfig {
275 fn default() -> Self {
276 Self::builder().build()
277 }
278}
279
280impl BinanceDataClientConfig {
281 pub fn validate(&self) -> anyhow::Result<()> {
287 validate_recv_window(self.recv_window_ms)?;
288 validate_retry_config(&self.retry_config())?;
289 self.instrument_provider.validate(self.product_type)?;
290
291 if self.us {
292 anyhow::ensure!(
293 self.product_type == BinanceProductType::Spot,
294 "Binance US supports Spot clients only"
295 );
296 anyhow::ensure!(
297 self.environment == BinanceEnvironment::Live,
298 "Binance US supports the Live environment only"
299 );
300 anyhow::ensure!(
301 self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
302 "Binance US market data requires spot_market_data_mode=Json"
303 );
304 }
305
306 Ok(())
307 }
308
309 pub(crate) fn retry_config(&self) -> RetryConfig {
310 RetryConfig {
311 max_retries: self.max_retries,
312 initial_delay_ms: self.retry_delay_initial_ms,
313 max_delay_ms: self.retry_delay_max_ms,
314 ..crate::common::http::retry_config()
315 }
316 }
317}
318
319impl ClientConfig for BinanceDataClientConfig {
320 fn as_any(&self) -> &dyn Any {
321 self
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
330#[serde(default, deny_unknown_fields)]
331#[cfg_attr(
332 feature = "python",
333 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
334)]
335#[cfg_attr(
336 feature = "python",
337 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
338)]
339pub struct BinanceExecutionClientConfig {
340 #[builder(default = AccountId::from("BINANCE-001"))]
342 pub account_id: AccountId,
343 #[builder(default = BinanceProductType::Spot)]
345 pub product_type: BinanceProductType,
346 #[builder(default = BinanceEnvironment::Live)]
348 pub environment: BinanceEnvironment,
349 pub api_key: Option<SecretString>,
351 pub api_secret: Option<SecretString>,
353 pub base_url_http: Option<String>,
355 pub base_url_ws: Option<String>,
359 pub base_url_ws_trading: Option<String>,
361 pub proxy_url: Option<SecretString>,
363 #[builder(default = true)]
365 pub use_ws_trading: bool,
366 #[builder(default = 10_000)]
368 pub ws_trading_setup_timeout_ms: u64,
369 #[builder(default)]
371 pub instrument_provider: BinanceInstrumentProviderConfig,
372 #[builder(default = 3600)]
376 pub instrument_refresh_interval_secs: u64,
377 #[builder(default = true)]
382 pub use_gtd: bool,
383 #[builder(default = true)]
390 pub use_position_ids: bool,
391 pub oms_type: Option<OmsType>,
396 #[builder(default = Decimal::new(4, 4))]
402 pub default_taker_fee: Decimal,
403 #[builder(default = 5_000)]
405 pub recv_window_ms: u64,
406 #[builder(default = RetryConfig::default().max_retries)]
408 pub max_retries: u32,
409 #[builder(default = RetryConfig::default().initial_delay_ms)]
411 pub retry_delay_initial_ms: u64,
412 #[builder(default = RetryConfig::default().max_delay_ms)]
414 pub retry_delay_max_ms: u64,
415 #[builder(default)]
417 pub us: bool,
418 pub futures_leverages: Option<HashMap<String, u32>>,
420 pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
422 #[builder(default = Currency::USDT())]
424 pub bnfcr_currency: Currency,
425 #[builder(default = false)]
430 pub treat_expired_as_canceled: bool,
431 #[builder(default = false)]
435 pub use_trade_lite: bool,
436 #[builder(default)]
438 pub transport_backend: TransportBackend,
439}
440
441#[cfg(feature = "python")]
442nautilus_core::impl_pyo3_config_getters!(BinanceExecutionClientConfig {
443 account_id: AccountId,
444 product_type: BinanceProductType,
445 environment: BinanceEnvironment,
446 base_url_http: Option<String>,
447 base_url_ws: Option<String>,
448 base_url_ws_trading: Option<String>,
449 use_ws_trading: bool,
450 ws_trading_setup_timeout_ms: u64,
451 instrument_provider: BinanceInstrumentProviderConfig,
452 instrument_refresh_interval_secs: u64,
453 use_gtd: bool,
454 use_position_ids: bool,
455 oms_type: Option<OmsType>,
456 default_taker_fee: Decimal,
457 recv_window_ms: u64,
458 max_retries: u32,
459 retry_delay_initial_ms: u64,
460 retry_delay_max_ms: u64,
461 us: bool,
462 futures_leverages: Option<HashMap<String, u32>>,
463 futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
464 treat_expired_as_canceled: bool,
465 use_trade_lite: bool,
466 bnfcr_currency: Currency,
467 transport_backend: TransportBackend,
468});
469
470impl Default for BinanceExecutionClientConfig {
471 fn default() -> Self {
472 Self::builder().build()
473 }
474}
475
476impl BinanceExecutionClientConfig {
477 pub fn validate(&self) -> anyhow::Result<()> {
484 validate_recv_window(self.recv_window_ms)?;
485 validate_retry_config(&self.retry_config())?;
486 anyhow::ensure!(
487 self.ws_trading_setup_timeout_ms > 0,
488 "ws_trading_setup_timeout_ms must be greater than 0, was {}",
489 self.ws_trading_setup_timeout_ms
490 );
491 self.instrument_provider.validate(self.product_type)?;
492
493 if self.us {
494 anyhow::ensure!(
495 self.product_type == BinanceProductType::Spot,
496 "Binance US supports Spot clients only"
497 );
498 anyhow::ensure!(
499 self.environment == BinanceEnvironment::Live,
500 "Binance US supports the Live environment only"
501 );
502 }
503
504 Ok(())
505 }
506
507 pub(crate) fn retry_config(&self) -> RetryConfig {
508 RetryConfig {
509 max_retries: self.max_retries,
510 initial_delay_ms: self.retry_delay_initial_ms,
511 max_delay_ms: self.retry_delay_max_ms,
512 ..crate::common::http::retry_config()
513 }
514 }
515}
516
517fn validate_retry_config(config: &RetryConfig) -> anyhow::Result<()> {
518 ExponentialBackoff::new(
519 Duration::from_millis(config.initial_delay_ms),
520 Duration::from_millis(config.max_delay_ms),
521 config.backoff_factor,
522 config.jitter_ms,
523 config.immediate_first,
524 )?;
525 Ok(())
526}
527
528fn validate_recv_window(recv_window_ms: u64) -> anyhow::Result<()> {
529 anyhow::ensure!(
530 (1..=60_000).contains(&recv_window_ms),
531 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
532 );
533 Ok(())
534}
535
536impl ClientConfig for BinanceExecutionClientConfig {
537 fn as_any(&self) -> &dyn Any {
538 self
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use rstest::rstest;
545
546 use super::*;
547
548 #[rstest]
549 fn test_config_debug_redacts_credentials() {
550 let data = BinanceDataClientConfig {
551 api_key: Some("data-api-key".into()),
552 api_secret: Some("data-api-secret".into()),
553 proxy_url: Some("http://user:data-proxy@localhost".into()),
554 ..Default::default()
555 };
556 let execution = BinanceExecutionClientConfig {
557 api_key: Some("exec-api-key".into()),
558 api_secret: Some("exec-api-secret".into()),
559 proxy_url: Some("http://user:exec-proxy@localhost".into()),
560 ..Default::default()
561 };
562
563 let formatted = format!("{data:?} {execution:?}");
564
565 assert_eq!(formatted.matches(REDACTED).count(), 6);
566
567 for secret in [
568 "data-api-key",
569 "data-api-secret",
570 "data-proxy",
571 "exec-api-key",
572 "exec-api-secret",
573 "exec-proxy",
574 ] {
575 assert!(!formatted.contains(secret));
576 }
577 }
578
579 #[rstest]
580 fn test_data_config_toml_minimal() {
581 let config: BinanceDataClientConfig = toml::from_str(
582 r#"
583environment = "Testnet"
584product_type = "USD_M"
585instrument_status_poll_secs = 600
586"#,
587 )
588 .unwrap();
589
590 assert_eq!(config.environment, BinanceEnvironment::Testnet);
591 assert_eq!(config.product_type, BinanceProductType::UsdM);
592 assert_eq!(config.spot_market_data_mode, BinanceSpotMarketDataMode::Sbe);
593 assert_eq!(config.instrument_status_poll_secs, 600);
594 assert_eq!(config.book_snapshot_timeout_secs, 10);
595 }
596
597 #[rstest]
598 fn test_data_config_toml_spot_market_data_mode_override() {
599 let config: BinanceDataClientConfig = toml::from_str(
600 r#"
601spot_market_data_mode = "Json"
602"#,
603 )
604 .unwrap();
605
606 assert_eq!(
607 config.spot_market_data_mode,
608 BinanceSpotMarketDataMode::Json
609 );
610 }
611
612 #[rstest]
613 fn test_data_config_toml_rejects_plural_product_types() {
614 let result = toml::from_str::<BinanceDataClientConfig>(
615 r#"
616product_types = ["SPOT", "USD_M"]
617"#,
618 );
619
620 let message = result.unwrap_err().to_string();
621 assert!(message.contains("unknown field `product_types`"));
622 }
623
624 #[rstest]
625 fn test_exec_config_toml_empty_uses_defaults() {
626 let config: BinanceExecutionClientConfig = toml::from_str("").unwrap();
627 let expected = BinanceExecutionClientConfig::default();
628
629 assert_eq!(config.environment, expected.environment);
630 assert_eq!(config.product_type, expected.product_type);
631 assert_eq!(config.use_ws_trading, expected.use_ws_trading);
632 assert_eq!(config.ws_trading_setup_timeout_ms, 10_000);
633 assert_eq!(config.instrument_provider, expected.instrument_provider);
634 assert_eq!(
635 config.instrument_refresh_interval_secs,
636 expected.instrument_refresh_interval_secs
637 );
638 assert_eq!(config.use_gtd, expected.use_gtd);
639 assert_eq!(config.use_position_ids, expected.use_position_ids);
640 assert_eq!(config.oms_type, expected.oms_type);
641 assert_eq!(config.default_taker_fee, expected.default_taker_fee);
642 assert_eq!(config.proxy_url, expected.proxy_url);
643 assert_eq!(config.recv_window_ms, expected.recv_window_ms);
644 assert_eq!(config.us, expected.us);
645 assert_eq!(
646 config.treat_expired_as_canceled,
647 expected.treat_expired_as_canceled,
648 );
649 assert_eq!(config.use_trade_lite, expected.use_trade_lite);
650 assert_eq!(config.transport_backend, expected.transport_backend);
651 }
652
653 #[rstest]
654 fn test_exec_config_toml_oms_type_override() {
655 let config: BinanceExecutionClientConfig = toml::from_str(
656 r#"
657oms_type = "Hedging"
658"#,
659 )
660 .unwrap();
661
662 assert_eq!(config.oms_type, Some(OmsType::Hedging));
663 }
664
665 #[rstest]
666 fn test_exec_config_toml_use_gtd_override() {
667 let config: BinanceExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
668
669 assert!(!config.use_gtd);
670 }
671
672 #[rstest]
673 fn test_exec_config_toml_ws_trading_setup_timeout_override() {
674 let config: BinanceExecutionClientConfig =
675 toml::from_str("ws_trading_setup_timeout_ms = 250").unwrap();
676
677 assert_eq!(config.ws_trading_setup_timeout_ms, 250);
678 }
679
680 #[rstest]
681 #[case(0)]
682 #[case(60_001)]
683 fn test_data_config_rejects_recv_window_out_of_bounds(#[case] recv_window_ms: u64) {
684 let config = BinanceDataClientConfig {
685 recv_window_ms,
686 ..Default::default()
687 };
688
689 let message = config.validate().unwrap_err().to_string();
690
691 assert_eq!(
692 message,
693 format!(
694 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
695 )
696 );
697 }
698
699 #[rstest]
700 fn test_exec_config_rejects_zero_ws_trading_setup_timeout() {
701 let config = BinanceExecutionClientConfig {
702 ws_trading_setup_timeout_ms: 0,
703 ..Default::default()
704 };
705
706 let message = config.validate().unwrap_err().to_string();
707
708 assert_eq!(
709 message,
710 "ws_trading_setup_timeout_ms must be greater than 0, was 0"
711 );
712 }
713
714 #[rstest]
715 #[case(1)]
716 #[case(60_000)]
717 fn test_exec_config_accepts_recv_window_bounds(#[case] recv_window_ms: u64) {
718 let config = BinanceExecutionClientConfig {
719 recv_window_ms,
720 ..Default::default()
721 };
722
723 assert!(config.validate().is_ok());
724 }
725
726 #[rstest]
727 #[case(
728 BinanceProductType::UsdM,
729 BinanceEnvironment::Live,
730 BinanceSpotMarketDataMode::Json,
731 "Binance US supports Spot clients only"
732 )]
733 #[case(
734 BinanceProductType::Spot,
735 BinanceEnvironment::Testnet,
736 BinanceSpotMarketDataMode::Json,
737 "Binance US supports the Live environment only"
738 )]
739 #[case(
740 BinanceProductType::Spot,
741 BinanceEnvironment::Live,
742 BinanceSpotMarketDataMode::Sbe,
743 "Binance US market data requires spot_market_data_mode=Json"
744 )]
745 fn test_data_config_rejects_unsupported_binance_us_combinations(
746 #[case] product_type: BinanceProductType,
747 #[case] environment: BinanceEnvironment,
748 #[case] spot_market_data_mode: BinanceSpotMarketDataMode,
749 #[case] expected: &str,
750 ) {
751 let config = BinanceDataClientConfig {
752 product_type,
753 environment,
754 spot_market_data_mode,
755 us: true,
756 ..Default::default()
757 };
758
759 assert_eq!(config.validate().unwrap_err().to_string(), expected);
760 }
761
762 #[rstest]
763 #[case(
764 BinanceProductType::CoinM,
765 BinanceEnvironment::Live,
766 "Binance US supports Spot clients only"
767 )]
768 #[case(
769 BinanceProductType::Spot,
770 BinanceEnvironment::Demo,
771 "Binance US supports the Live environment only"
772 )]
773 fn test_exec_config_rejects_unsupported_binance_us_combinations(
774 #[case] product_type: BinanceProductType,
775 #[case] environment: BinanceEnvironment,
776 #[case] expected: &str,
777 ) {
778 let config = BinanceExecutionClientConfig {
779 product_type,
780 environment,
781 us: true,
782 ..Default::default()
783 };
784
785 assert_eq!(config.validate().unwrap_err().to_string(), expected);
786 }
787
788 #[rstest]
789 fn test_instrument_provider_rejects_callable_and_spot_contract_filter() {
790 let callable = BinanceInstrumentProviderConfig {
791 filter_callable: Some("package.module:predicate".to_string()),
792 ..Default::default()
793 };
794 let contract_filter = BinanceInstrumentProviderConfig {
795 filters: HashMap::from([(
796 "contract_types".to_string(),
797 serde_json::json!("PERPETUAL"),
798 )]),
799 ..Default::default()
800 };
801
802 assert_eq!(
803 callable
804 .validate(BinanceProductType::Spot)
805 .unwrap_err()
806 .to_string(),
807 "Binance v2 does not support instrument filter_callable \"package.module:predicate\"; the legacy Binance provider never applied callable filters"
808 );
809 assert_eq!(
810 contract_filter
811 .validate(BinanceProductType::Spot)
812 .unwrap_err()
813 .to_string(),
814 "unsupported Binance instrument filter \"contract_types\" for Spot"
815 );
816 }
817
818 #[rstest]
819 fn test_instrument_provider_rejects_empty_and_non_string_filter_values() {
820 for value in [serde_json::json!([]), serde_json::json!(["BTC", 7])] {
821 let config = BinanceInstrumentProviderConfig {
822 filters: HashMap::from([("bases".to_string(), value)]),
823 ..Default::default()
824 };
825
826 assert_eq!(
827 config
828 .validate(BinanceProductType::UsdM)
829 .unwrap_err()
830 .to_string(),
831 "Binance instrument filter \"bases\" must be a non-empty string or array of strings"
832 );
833 }
834 }
835}