1use std::{any::Any, collections::HashMap, str::FromStr};
19
20use nautilus_common::factories::ClientConfig;
21use nautilus_model::{
22 enums::OmsType,
23 identifiers::{AccountId, InstrumentId},
24 types::Currency,
25};
26use nautilus_network::websocket::TransportBackend;
27use rust_decimal::Decimal;
28use serde::{Deserialize, Serialize};
29
30use crate::common::enums::{BinanceEnvironment, BinanceMarginType, BinanceProductType};
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
34#[serde(default, deny_unknown_fields)]
35#[cfg_attr(
36 feature = "python",
37 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
38)]
39#[cfg_attr(
40 feature = "python",
41 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
42)]
43pub struct BinanceInstrumentProviderConfig {
44 #[builder(default = true)]
46 pub load_all: bool,
47 pub load_ids: Option<Vec<String>>,
49 #[builder(default)]
54 pub filters: HashMap<String, serde_json::Value>,
55 pub filter_callable: Option<String>,
60 #[builder(default = true)]
62 pub log_warnings: bool,
63 #[builder(default)]
65 pub query_commission_rates: bool,
66}
67
68impl Default for BinanceInstrumentProviderConfig {
69 fn default() -> Self {
70 Self::builder().build()
71 }
72}
73
74impl BinanceInstrumentProviderConfig {
75 pub fn validate(&self, product_type: BinanceProductType) -> anyhow::Result<()> {
82 if let Some(filter_callable) = self
83 .filter_callable
84 .as_deref()
85 .map(str::trim)
86 .filter(|value| !value.is_empty())
87 {
88 anyhow::bail!(
89 "Binance v2 does not support instrument filter_callable {filter_callable:?}; \
90 the legacy Binance provider never applied callable filters"
91 );
92 }
93
94 if let Some(load_ids) = &self.load_ids {
95 for raw in load_ids {
96 let instrument_id = InstrumentId::from_str(raw)
97 .map_err(|e| anyhow::anyhow!("invalid Binance load_ids value {raw:?}: {e}"))?;
98 anyhow::ensure!(
99 instrument_id.venue.as_str() == "BINANCE",
100 "Binance load_ids value {raw:?} must use venue BINANCE"
101 );
102 }
103 }
104
105 for (key, value) in &self.filters {
106 let supported = matches!(key.as_str(), "symbols" | "bases" | "quotes")
107 || key == "contract_types"
108 && matches!(
109 product_type,
110 BinanceProductType::UsdM | BinanceProductType::CoinM
111 );
112 anyhow::ensure!(
113 supported,
114 "unsupported Binance instrument filter {key:?} for {product_type:?}"
115 );
116 validate_filter_strings(key, value)?;
117 }
118
119 Ok(())
120 }
121}
122
123fn validate_filter_strings(name: &str, value: &serde_json::Value) -> anyhow::Result<()> {
124 let valid = match value {
125 serde_json::Value::String(value) => !value.trim().is_empty(),
126 serde_json::Value::Array(values) => {
127 !values.is_empty()
128 && values
129 .iter()
130 .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))
131 }
132 _ => false,
133 };
134
135 anyhow::ensure!(
136 valid,
137 "Binance instrument filter {name:?} must be a non-empty string or array of strings"
138 );
139 Ok(())
140}
141
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(
145 feature = "python",
146 pyo3::pyclass(module = "nautilus_trader.adapters.binance", eq, from_py_object)
147)]
148#[cfg_attr(
149 feature = "python",
150 pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.binance")
151)]
152pub enum BinanceSpotMarketDataMode {
153 #[default]
154 Sbe,
156 Json,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
164#[serde(default, deny_unknown_fields)]
165#[cfg_attr(
166 feature = "python",
167 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
168)]
169#[cfg_attr(
170 feature = "python",
171 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
172)]
173pub struct BinanceDataClientConfig {
174 #[builder(default = BinanceProductType::Spot)]
176 pub product_type: BinanceProductType,
177 #[builder(default = BinanceEnvironment::Live)]
179 pub environment: BinanceEnvironment,
180 pub base_url_http: Option<String>,
182 pub base_url_ws: Option<String>,
187 pub api_key: Option<String>,
189 pub api_secret: Option<String>,
191 #[builder(default)]
196 pub spot_market_data_mode: BinanceSpotMarketDataMode,
197 #[builder(default)]
199 pub instrument_provider: BinanceInstrumentProviderConfig,
200 #[builder(default = 3600)]
204 pub instrument_refresh_interval_secs: u64,
205 #[builder(default = 3600)]
208 pub instrument_status_poll_secs: u64,
209 pub proxy_url: Option<String>,
211 #[builder(default = 5_000)]
213 pub recv_window_ms: u64,
214 #[builder(default)]
216 pub us: bool,
217 #[builder(default)]
219 pub transport_backend: TransportBackend,
220}
221
222#[cfg(feature = "python")]
223nautilus_core::impl_pyo3_config_getters!(BinanceDataClientConfig {
224 product_type: BinanceProductType,
225 environment: BinanceEnvironment,
226 base_url_http: Option<String>,
227 base_url_ws: Option<String>,
228 spot_market_data_mode: BinanceSpotMarketDataMode,
229 instrument_provider: BinanceInstrumentProviderConfig,
230 instrument_refresh_interval_secs: u64,
231 instrument_status_poll_secs: u64,
232 recv_window_ms: u64,
233 us: bool,
234 transport_backend: TransportBackend,
235});
236
237impl Default for BinanceDataClientConfig {
238 fn default() -> Self {
239 Self::builder().build()
240 }
241}
242
243impl BinanceDataClientConfig {
244 pub fn validate(&self) -> anyhow::Result<()> {
250 validate_recv_window(self.recv_window_ms)?;
251 self.instrument_provider.validate(self.product_type)?;
252
253 if self.us {
254 anyhow::ensure!(
255 self.product_type == BinanceProductType::Spot,
256 "Binance US supports Spot clients only"
257 );
258 anyhow::ensure!(
259 self.environment == BinanceEnvironment::Live,
260 "Binance US supports the Live environment only"
261 );
262 anyhow::ensure!(
263 self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
264 "Binance US market data requires spot_market_data_mode=Json"
265 );
266 }
267
268 Ok(())
269 }
270}
271
272impl ClientConfig for BinanceDataClientConfig {
273 fn as_any(&self) -> &dyn Any {
274 self
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
283#[serde(default, deny_unknown_fields)]
284#[cfg_attr(
285 feature = "python",
286 pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
287)]
288#[cfg_attr(
289 feature = "python",
290 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
291)]
292pub struct BinanceExecutionClientConfig {
293 #[builder(default = AccountId::from("BINANCE-001"))]
295 pub account_id: AccountId,
296 #[builder(default = BinanceProductType::Spot)]
298 pub product_type: BinanceProductType,
299 #[builder(default = BinanceEnvironment::Live)]
301 pub environment: BinanceEnvironment,
302 pub base_url_http: Option<String>,
304 pub base_url_ws: Option<String>,
308 pub base_url_ws_trading: Option<String>,
310 #[builder(default = true)]
312 pub use_ws_trading: bool,
313 #[builder(default = 10_000)]
315 pub ws_trading_setup_timeout_ms: u64,
316 #[builder(default)]
318 pub instrument_provider: BinanceInstrumentProviderConfig,
319 #[builder(default = 3600)]
323 pub instrument_refresh_interval_secs: u64,
324 #[builder(default = true)]
329 pub use_gtd: bool,
330 #[builder(default = true)]
337 pub use_position_ids: bool,
338 pub oms_type: Option<OmsType>,
343 #[builder(default = Decimal::new(4, 4))]
349 pub default_taker_fee: Decimal,
350 pub proxy_url: Option<String>,
352 #[builder(default = 5_000)]
354 pub recv_window_ms: u64,
355 #[builder(default)]
357 pub us: bool,
358 pub api_key: Option<String>,
360 pub api_secret: Option<String>,
362 pub futures_leverages: Option<HashMap<String, u32>>,
364 pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
366 #[builder(default = Currency::USDT())]
368 pub bnfcr_currency: Currency,
369 #[builder(default = false)]
374 pub treat_expired_as_canceled: bool,
375 #[builder(default = false)]
379 pub use_trade_lite: bool,
380 #[builder(default)]
382 pub transport_backend: TransportBackend,
383}
384
385#[cfg(feature = "python")]
386nautilus_core::impl_pyo3_config_getters!(BinanceExecutionClientConfig {
387 account_id: AccountId,
388 product_type: BinanceProductType,
389 environment: BinanceEnvironment,
390 base_url_http: Option<String>,
391 base_url_ws: Option<String>,
392 base_url_ws_trading: Option<String>,
393 use_ws_trading: bool,
394 ws_trading_setup_timeout_ms: u64,
395 instrument_provider: BinanceInstrumentProviderConfig,
396 instrument_refresh_interval_secs: u64,
397 use_gtd: bool,
398 use_position_ids: bool,
399 oms_type: Option<OmsType>,
400 default_taker_fee: Decimal,
401 recv_window_ms: u64,
402 us: bool,
403 futures_leverages: Option<HashMap<String, u32>>,
404 futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
405 treat_expired_as_canceled: bool,
406 use_trade_lite: bool,
407 bnfcr_currency: Currency,
408 transport_backend: TransportBackend,
409});
410
411impl Default for BinanceExecutionClientConfig {
412 fn default() -> Self {
413 Self::builder().build()
414 }
415}
416
417impl BinanceExecutionClientConfig {
418 pub fn validate(&self) -> anyhow::Result<()> {
425 validate_recv_window(self.recv_window_ms)?;
426 anyhow::ensure!(
427 self.ws_trading_setup_timeout_ms > 0,
428 "ws_trading_setup_timeout_ms must be greater than 0, was {}",
429 self.ws_trading_setup_timeout_ms
430 );
431 self.instrument_provider.validate(self.product_type)?;
432
433 if self.us {
434 anyhow::ensure!(
435 self.product_type == BinanceProductType::Spot,
436 "Binance US supports Spot clients only"
437 );
438 anyhow::ensure!(
439 self.environment == BinanceEnvironment::Live,
440 "Binance US supports the Live environment only"
441 );
442 }
443
444 Ok(())
445 }
446}
447
448fn validate_recv_window(recv_window_ms: u64) -> anyhow::Result<()> {
449 anyhow::ensure!(
450 (1..=60_000).contains(&recv_window_ms),
451 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
452 );
453 Ok(())
454}
455
456impl ClientConfig for BinanceExecutionClientConfig {
457 fn as_any(&self) -> &dyn Any {
458 self
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use rstest::rstest;
465
466 use super::*;
467
468 #[rstest]
469 fn test_data_config_toml_minimal() {
470 let config: BinanceDataClientConfig = toml::from_str(
471 r#"
472environment = "Testnet"
473product_type = "USD_M"
474instrument_status_poll_secs = 600
475"#,
476 )
477 .unwrap();
478
479 assert_eq!(config.environment, BinanceEnvironment::Testnet);
480 assert_eq!(config.product_type, BinanceProductType::UsdM);
481 assert_eq!(config.spot_market_data_mode, BinanceSpotMarketDataMode::Sbe);
482 assert_eq!(config.instrument_status_poll_secs, 600);
483 }
484
485 #[rstest]
486 fn test_data_config_toml_spot_market_data_mode_override() {
487 let config: BinanceDataClientConfig = toml::from_str(
488 r#"
489spot_market_data_mode = "Json"
490"#,
491 )
492 .unwrap();
493
494 assert_eq!(
495 config.spot_market_data_mode,
496 BinanceSpotMarketDataMode::Json
497 );
498 }
499
500 #[rstest]
501 fn test_data_config_toml_rejects_plural_product_types() {
502 let result = toml::from_str::<BinanceDataClientConfig>(
503 r#"
504product_types = ["SPOT", "USD_M"]
505"#,
506 );
507
508 let message = result.unwrap_err().to_string();
509 assert!(message.contains("unknown field `product_types`"));
510 }
511
512 #[rstest]
513 fn test_exec_config_toml_empty_uses_defaults() {
514 let config: BinanceExecutionClientConfig = toml::from_str("").unwrap();
515 let expected = BinanceExecutionClientConfig::default();
516
517 assert_eq!(config.environment, expected.environment);
518 assert_eq!(config.product_type, expected.product_type);
519 assert_eq!(config.use_ws_trading, expected.use_ws_trading);
520 assert_eq!(config.ws_trading_setup_timeout_ms, 10_000);
521 assert_eq!(config.instrument_provider, expected.instrument_provider);
522 assert_eq!(
523 config.instrument_refresh_interval_secs,
524 expected.instrument_refresh_interval_secs
525 );
526 assert_eq!(config.use_gtd, expected.use_gtd);
527 assert_eq!(config.use_position_ids, expected.use_position_ids);
528 assert_eq!(config.oms_type, expected.oms_type);
529 assert_eq!(config.default_taker_fee, expected.default_taker_fee);
530 assert_eq!(config.proxy_url, expected.proxy_url);
531 assert_eq!(config.recv_window_ms, expected.recv_window_ms);
532 assert_eq!(config.us, expected.us);
533 assert_eq!(
534 config.treat_expired_as_canceled,
535 expected.treat_expired_as_canceled,
536 );
537 assert_eq!(config.use_trade_lite, expected.use_trade_lite);
538 assert_eq!(config.transport_backend, expected.transport_backend);
539 }
540
541 #[rstest]
542 fn test_exec_config_toml_oms_type_override() {
543 let config: BinanceExecutionClientConfig = toml::from_str(
544 r#"
545oms_type = "Hedging"
546"#,
547 )
548 .unwrap();
549
550 assert_eq!(config.oms_type, Some(OmsType::Hedging));
551 }
552
553 #[rstest]
554 fn test_exec_config_toml_use_gtd_override() {
555 let config: BinanceExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
556
557 assert!(!config.use_gtd);
558 }
559
560 #[rstest]
561 fn test_exec_config_toml_ws_trading_setup_timeout_override() {
562 let config: BinanceExecutionClientConfig =
563 toml::from_str("ws_trading_setup_timeout_ms = 250").unwrap();
564
565 assert_eq!(config.ws_trading_setup_timeout_ms, 250);
566 }
567
568 #[rstest]
569 #[case(0)]
570 #[case(60_001)]
571 fn test_data_config_rejects_recv_window_out_of_bounds(#[case] recv_window_ms: u64) {
572 let config = BinanceDataClientConfig {
573 recv_window_ms,
574 ..Default::default()
575 };
576
577 let message = config.validate().unwrap_err().to_string();
578
579 assert_eq!(
580 message,
581 format!(
582 "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
583 )
584 );
585 }
586
587 #[rstest]
588 fn test_exec_config_rejects_zero_ws_trading_setup_timeout() {
589 let config = BinanceExecutionClientConfig {
590 ws_trading_setup_timeout_ms: 0,
591 ..Default::default()
592 };
593
594 let message = config.validate().unwrap_err().to_string();
595
596 assert_eq!(
597 message,
598 "ws_trading_setup_timeout_ms must be greater than 0, was 0"
599 );
600 }
601
602 #[rstest]
603 #[case(1)]
604 #[case(60_000)]
605 fn test_exec_config_accepts_recv_window_bounds(#[case] recv_window_ms: u64) {
606 let config = BinanceExecutionClientConfig {
607 recv_window_ms,
608 ..Default::default()
609 };
610
611 assert!(config.validate().is_ok());
612 }
613
614 #[rstest]
615 #[case(
616 BinanceProductType::UsdM,
617 BinanceEnvironment::Live,
618 BinanceSpotMarketDataMode::Json,
619 "Binance US supports Spot clients only"
620 )]
621 #[case(
622 BinanceProductType::Spot,
623 BinanceEnvironment::Testnet,
624 BinanceSpotMarketDataMode::Json,
625 "Binance US supports the Live environment only"
626 )]
627 #[case(
628 BinanceProductType::Spot,
629 BinanceEnvironment::Live,
630 BinanceSpotMarketDataMode::Sbe,
631 "Binance US market data requires spot_market_data_mode=Json"
632 )]
633 fn test_data_config_rejects_unsupported_binance_us_combinations(
634 #[case] product_type: BinanceProductType,
635 #[case] environment: BinanceEnvironment,
636 #[case] spot_market_data_mode: BinanceSpotMarketDataMode,
637 #[case] expected: &str,
638 ) {
639 let config = BinanceDataClientConfig {
640 product_type,
641 environment,
642 spot_market_data_mode,
643 us: true,
644 ..Default::default()
645 };
646
647 assert_eq!(config.validate().unwrap_err().to_string(), expected);
648 }
649
650 #[rstest]
651 #[case(
652 BinanceProductType::CoinM,
653 BinanceEnvironment::Live,
654 "Binance US supports Spot clients only"
655 )]
656 #[case(
657 BinanceProductType::Spot,
658 BinanceEnvironment::Demo,
659 "Binance US supports the Live environment only"
660 )]
661 fn test_exec_config_rejects_unsupported_binance_us_combinations(
662 #[case] product_type: BinanceProductType,
663 #[case] environment: BinanceEnvironment,
664 #[case] expected: &str,
665 ) {
666 let config = BinanceExecutionClientConfig {
667 product_type,
668 environment,
669 us: true,
670 ..Default::default()
671 };
672
673 assert_eq!(config.validate().unwrap_err().to_string(), expected);
674 }
675
676 #[rstest]
677 fn test_instrument_provider_rejects_callable_and_spot_contract_filter() {
678 let callable = BinanceInstrumentProviderConfig {
679 filter_callable: Some("package.module:predicate".to_string()),
680 ..Default::default()
681 };
682 let contract_filter = BinanceInstrumentProviderConfig {
683 filters: HashMap::from([(
684 "contract_types".to_string(),
685 serde_json::json!("PERPETUAL"),
686 )]),
687 ..Default::default()
688 };
689
690 assert_eq!(
691 callable
692 .validate(BinanceProductType::Spot)
693 .unwrap_err()
694 .to_string(),
695 "Binance v2 does not support instrument filter_callable \"package.module:predicate\"; the legacy Binance provider never applied callable filters"
696 );
697 assert_eq!(
698 contract_filter
699 .validate(BinanceProductType::Spot)
700 .unwrap_err()
701 .to_string(),
702 "unsupported Binance instrument filter \"contract_types\" for Spot"
703 );
704 }
705
706 #[rstest]
707 fn test_instrument_provider_rejects_empty_and_non_string_filter_values() {
708 for value in [serde_json::json!([]), serde_json::json!(["BTC", 7])] {
709 let config = BinanceInstrumentProviderConfig {
710 filters: HashMap::from([("bases".to_string(), value)]),
711 ..Default::default()
712 };
713
714 assert_eq!(
715 config
716 .validate(BinanceProductType::UsdM)
717 .unwrap_err()
718 .to_string(),
719 "Binance instrument filter \"bases\" must be a non-empty string or array of strings"
720 );
721 }
722 }
723}