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