Skip to main content

nautilus_binance/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Binance adapter configuration structures.
17
18use 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/// Configuration for Binance instrument loading.
33#[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    /// Whether to load all instruments on startup.
45    #[builder(default = true)]
46    pub load_all: bool,
47    /// Specific Nautilus instrument IDs to load when `load_all` is false.
48    pub load_ids: Option<Vec<String>>,
49    /// Venue filters applied while loading instruments.
50    ///
51    /// Supported keys are `symbols`, `bases`, `quotes`, and, for Futures,
52    /// `contract_types`. Each value may be a string or an array of strings.
53    #[builder(default)]
54    pub filters: HashMap<String, serde_json::Value>,
55    /// Fully qualified Python callable path requested by legacy configuration.
56    ///
57    /// Binance v2 rejects this field because the legacy Binance provider never
58    /// applied it and Rust live clients cannot safely invoke arbitrary Python.
59    pub filter_callable: Option<String>,
60    /// Whether instrument parser failures should be logged as warnings.
61    #[builder(default = true)]
62    pub log_warnings: bool,
63    /// Whether to query account-specific commission rates for every loaded symbol.
64    #[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    /// Validates instrument loading configuration.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error for malformed IDs, unsupported filters, or a legacy
80    /// callable filter that Binance v2 cannot execute safely.
81    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/// Spot market-data transport mode.
143#[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    /// Spot SBE streams (requires Ed25519 credentials).
155    Sbe,
156    /// Force Spot public JSON streams (does not require credentials).
157    Json,
158}
159
160/// Configuration for Binance data client.
161///
162/// Ed25519 API keys are required for SBE WebSocket streams.
163#[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    /// Product type to subscribe to.
175    #[builder(default = BinanceProductType::Spot)]
176    pub product_type: BinanceProductType,
177    /// Environment (live, testnet, or demo).
178    #[builder(default = BinanceEnvironment::Live)]
179    pub environment: BinanceEnvironment,
180    /// Optional base URL override for HTTP API.
181    pub base_url_http: Option<String>,
182    /// Optional base URL override for WebSocket.
183    ///
184    /// Live USD-M Futures data overrides are normalized onto the matching
185    /// `/market/ws` and `/public/ws` routes.
186    pub base_url_ws: Option<String>,
187    /// API key (Ed25519).
188    pub api_key: Option<String>,
189    /// API secret (Ed25519 base64-encoded or PEM).
190    pub api_secret: Option<String>,
191    /// Spot market-data transport mode.
192    ///
193    /// - `Sbe` uses SBE streams and requires Ed25519 credentials.
194    /// - `Json` forces public JSON streams with no credentials.
195    #[builder(default)]
196    pub spot_market_data_mode: BinanceSpotMarketDataMode,
197    /// Instrument loading and fee configuration.
198    #[builder(default)]
199    pub instrument_provider: BinanceInstrumentProviderConfig,
200    /// Interval in seconds for a full instrument catalogue refresh.
201    ///
202    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
203    #[builder(default = 3600)]
204    pub instrument_refresh_interval_secs: u64,
205    /// Interval in seconds for polling exchange info to detect instrument status
206    /// changes (e.g. Trading -> Halt). Set to 0 to disable. Defaults to 3600 (60 minutes).
207    #[builder(default = 3600)]
208    pub instrument_status_poll_secs: u64,
209    /// Optional proxy URL for HTTP and WebSocket transports.
210    pub proxy_url: Option<String>,
211    /// Receive window in milliseconds for signed HTTP requests.
212    #[builder(default = 5_000)]
213    pub recv_window_ms: u64,
214    /// Whether to route this Spot client to Binance US.
215    #[builder(default)]
216    pub us: bool,
217    /// WebSocket transport backend (defaults to `Tungstenite`).
218    #[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    /// Validates Binance data client configuration.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error for invalid receive-window, provider, or Binance US settings.
249    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/// Configuration for Binance execution client.
279///
280/// Global execution uses WebSocket API authentication with Ed25519 credentials.
281/// Binance US uses HMAC-signed HTTP requests and listen-key user data streams.
282#[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    /// Account ID for the client.
294    #[builder(default = AccountId::from("BINANCE-001"))]
295    pub account_id: AccountId,
296    /// Product type to trade.
297    #[builder(default = BinanceProductType::Spot)]
298    pub product_type: BinanceProductType,
299    /// Environment (live, testnet, or demo).
300    #[builder(default = BinanceEnvironment::Live)]
301    pub environment: BinanceEnvironment,
302    /// Optional base URL override for HTTP API.
303    pub base_url_http: Option<String>,
304    /// Optional base URL override for WebSocket user data stream.
305    ///
306    /// Live USD-M Futures stream overrides are normalized onto the `/private/ws` route.
307    pub base_url_ws: Option<String>,
308    /// Optional base URL override for WebSocket trading API (Spot and USD-M Futures).
309    pub base_url_ws_trading: Option<String>,
310    /// Whether to use the WebSocket trading API for order operations (Spot and USD-M Futures).
311    #[builder(default = true)]
312    pub use_ws_trading: bool,
313    /// Timeout in milliseconds for each Binance Spot WS trading setup response.
314    #[builder(default = 10_000)]
315    pub ws_trading_setup_timeout_ms: u64,
316    /// Instrument loading and fee configuration.
317    #[builder(default)]
318    pub instrument_provider: BinanceInstrumentProviderConfig,
319    /// Interval in seconds for refreshing the execution instrument cache.
320    ///
321    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
322    #[builder(default = 3600)]
323    pub instrument_refresh_interval_secs: u64,
324    /// Whether to use Binance-native GTD orders.
325    ///
326    /// Set to false only when the strategy manages GTD expiry locally. The adapter then maps GTD
327    /// to GTC and the strategy must enable `manage_gtd_expiry`.
328    #[builder(default = true)]
329    pub use_gtd: bool,
330    /// Whether to use canonical Binance Futures position IDs.
331    ///
332    /// When true, Futures hedge-mode order and fill reports include a `venue_position_id` derived
333    /// from the instrument and Binance position side (e.g. `ETHUSDT-PERP.BINANCE-LONG`). Hedge-mode
334    /// REST position reports use the same IDs. One-way `BOTH` reports remain unkeyed. When false,
335    /// `venue_position_id` is None, allowing virtual positions with `OmsType::Hedging`.
336    #[builder(default = true)]
337    pub use_position_ids: bool,
338    /// Optional OMS type override for Binance Futures accounts.
339    ///
340    /// Set to `Hedging` when the account uses dual-side position mode. When
341    /// `None`, Binance Futures clients use `Netting`. Ignored for Spot clients.
342    pub oms_type: Option<OmsType>,
343    /// Default taker fee rate for commission estimation.
344    ///
345    /// Used as a fallback when the venue omits commission fields in
346    /// exchange-generated fills (liquidation, ADL, settlement).
347    /// Standard Binance Futures taker fee is 0.0004 (0.04%).
348    #[builder(default = Decimal::new(4, 4))]
349    pub default_taker_fee: Decimal,
350    /// Optional proxy URL for HTTP and WebSocket transports.
351    pub proxy_url: Option<String>,
352    /// Receive window in milliseconds for signed HTTP requests.
353    #[builder(default = 5_000)]
354    pub recv_window_ms: u64,
355    /// Whether to route this Spot client to Binance US.
356    #[builder(default)]
357    pub us: bool,
358    /// API key (uses an environment variable if not provided).
359    pub api_key: Option<String>,
360    /// API secret (Ed25519 for Global or HMAC for Binance US).
361    pub api_secret: Option<String>,
362    /// Initial leverage per Binance symbol (e.g. BTCUSDT -> 20), applied during connect.
363    pub futures_leverages: Option<HashMap<String, u32>>,
364    /// Margin type per Binance symbol (e.g. BTCUSDT -> Cross), applied during connect.
365    pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
366    /// Currency that Binance Futures Credits (`BNFCR`) balances and fees resolve to (defaults to USDT).
367    #[builder(default = Currency::USDT())]
368    pub bnfcr_currency: Currency,
369    /// If true, the EXPIRED execution type emits `OrderCanceled` instead of `OrderExpired`.
370    ///
371    /// Binance uses EXPIRED for certain cancel scenarios depending on order type
372    /// and time-in-force combination.
373    #[builder(default = false)]
374    pub treat_expired_as_canceled: bool,
375    /// If true, drive fills from the lower-latency `TRADE_LITE` user data event
376    /// and dedup the matching fill portion of `ORDER_TRADE_UPDATE`. If false,
377    /// `TRADE_LITE` events are ignored and fills come from `ORDER_TRADE_UPDATE`.
378    #[builder(default = false)]
379    pub use_trade_lite: bool,
380    /// WebSocket transport backend (defaults to `Tungstenite`).
381    #[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    /// Validates Binance execution client configuration.
419    ///
420    /// # Errors
421    ///
422    /// Returns an error for invalid receive-window, WS trading setup timeout, provider, or
423    /// Binance US settings.
424    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}