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, 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/// Configuration for Binance instrument loading.
38#[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    /// Whether to load all instruments on startup.
50    #[builder(default = true)]
51    pub load_all: bool,
52    /// Specific Nautilus instrument IDs to load when `load_all` is false.
53    pub load_ids: Option<Vec<String>>,
54    /// Venue filters applied while loading instruments.
55    ///
56    /// Supported keys are `symbols`, `bases`, `quotes`, and, for Futures,
57    /// `contract_types`. Each value may be a string or an array of strings.
58    #[builder(default)]
59    pub filters: HashMap<String, serde_json::Value>,
60    /// Fully qualified Python callable path requested by legacy configuration.
61    ///
62    /// Binance v2 rejects this field because the legacy Binance provider never
63    /// applied it and Rust live clients cannot safely invoke arbitrary Python.
64    pub filter_callable: Option<String>,
65    /// Whether instrument parser failures should be logged as warnings.
66    ///
67    /// Non-trading symbols skipped during bulk loads always log at debug;
68    /// this flag applies to explicitly selected symbols and unexpected failures.
69    #[builder(default = true)]
70    pub log_warnings: bool,
71    /// Whether to query account-specific commission rates for every loaded symbol.
72    #[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    /// Validates instrument loading configuration.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error for malformed IDs, unsupported filters, or a legacy
88    /// callable filter that Binance v2 cannot execute safely.
89    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/// Spot market-data transport mode.
160#[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    /// Spot SBE streams (requires Ed25519 credentials).
172    Sbe,
173    /// Force Spot public JSON streams (does not require credentials).
174    Json,
175}
176
177/// Configuration for Binance data client.
178///
179/// Ed25519 API keys are required for SBE WebSocket streams.
180#[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    /// Product type to subscribe to.
192    #[builder(default = BinanceProductType::Spot)]
193    pub product_type: BinanceProductType,
194    /// Environment (live, testnet, or demo).
195    #[builder(default = BinanceEnvironment::Live)]
196    pub environment: BinanceEnvironment,
197    /// API key (Ed25519).
198    pub api_key: Option<SecretString>,
199    /// API secret (Ed25519 base64-encoded or PEM).
200    pub api_secret: Option<SecretString>,
201    /// Optional base URL override for HTTP API.
202    pub base_url_http: Option<String>,
203    /// Optional base URL override for WebSocket.
204    ///
205    /// Live USD-M Futures data overrides are normalized onto the matching
206    /// `/market/ws` and `/public/ws` routes.
207    pub base_url_ws: Option<String>,
208    /// Optional proxy URL for HTTP and WebSocket transports.
209    pub proxy_url: Option<SecretString>,
210    /// Spot market-data transport mode.
211    ///
212    /// - `Sbe` uses SBE streams and requires Ed25519 credentials.
213    /// - `Json` forces public JSON streams with no credentials.
214    #[builder(default)]
215    pub spot_market_data_mode: BinanceSpotMarketDataMode,
216    /// Instrument loading and fee configuration.
217    #[builder(default)]
218    pub instrument_provider: BinanceInstrumentProviderConfig,
219    /// Interval in seconds for a full instrument catalog refresh.
220    ///
221    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
222    #[builder(default = 3600)]
223    pub instrument_refresh_interval_secs: u64,
224    /// Interval in seconds for polling exchange info to detect instrument status
225    /// changes (e.g. Trading -> Halt). Set to 0 to disable. Defaults to 3600 (60 minutes).
226    #[builder(default = 3600)]
227    pub instrument_status_poll_secs: u64,
228    /// Receive window in milliseconds for signed HTTP requests.
229    #[builder(default = 5_000)]
230    pub recv_window_ms: u64,
231    /// Maximum retries for HTTP GET requests. Mutating requests are sent once.
232    #[builder(default = RetryConfig::default().max_retries)]
233    pub max_retries: u32,
234    /// Initial HTTP retry delay in milliseconds.
235    #[builder(default = RetryConfig::default().initial_delay_ms)]
236    pub retry_delay_initial_ms: u64,
237    /// Maximum exponential HTTP retry delay in milliseconds.
238    #[builder(default = RetryConfig::default().max_delay_ms)]
239    pub retry_delay_max_ms: u64,
240    /// Whether to route this Spot client to Binance US.
241    #[builder(default)]
242    pub us: bool,
243    /// WebSocket transport backend (defaults to `Tungstenite`).
244    #[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    /// Validates Binance data client configuration.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error for invalid receive-window, provider, or Binance US settings.
278    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/// Configuration for Binance execution client.
318///
319/// Global execution uses WebSocket API authentication with Ed25519 credentials.
320/// Binance US uses HMAC-signed HTTP requests and listen-key user data streams.
321#[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    /// Account ID for the client.
333    #[builder(default = AccountId::from("BINANCE-001"))]
334    pub account_id: AccountId,
335    /// Product type to trade.
336    #[builder(default = BinanceProductType::Spot)]
337    pub product_type: BinanceProductType,
338    /// Environment (live, testnet, or demo).
339    #[builder(default = BinanceEnvironment::Live)]
340    pub environment: BinanceEnvironment,
341    /// API key (uses an environment variable if not provided).
342    pub api_key: Option<SecretString>,
343    /// API secret (Ed25519 for Global or HMAC for Binance US).
344    pub api_secret: Option<SecretString>,
345    /// Optional base URL override for HTTP API.
346    pub base_url_http: Option<String>,
347    /// Optional base URL override for WebSocket user data stream.
348    ///
349    /// Live USD-M Futures stream overrides are normalized onto the `/private/ws` route.
350    pub base_url_ws: Option<String>,
351    /// Optional base URL override for WebSocket trading API (Spot and USD-M Futures).
352    pub base_url_ws_trading: Option<String>,
353    /// Optional proxy URL for HTTP and WebSocket transports.
354    pub proxy_url: Option<SecretString>,
355    /// Whether to use the WebSocket trading API for order operations (Spot and USD-M Futures).
356    #[builder(default = true)]
357    pub use_ws_trading: bool,
358    /// Timeout in milliseconds for each Binance Spot WS trading setup response.
359    #[builder(default = 10_000)]
360    pub ws_trading_setup_timeout_ms: u64,
361    /// Instrument loading and fee configuration.
362    #[builder(default)]
363    pub instrument_provider: BinanceInstrumentProviderConfig,
364    /// Interval in seconds for refreshing the execution instrument cache.
365    ///
366    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
367    #[builder(default = 3600)]
368    pub instrument_refresh_interval_secs: u64,
369    /// Whether to use Binance-native GTD orders.
370    ///
371    /// Set to false only when the strategy manages GTD expiry locally. The adapter then maps GTD
372    /// to GTC and the strategy must enable `manage_gtd_expiry`.
373    #[builder(default = true)]
374    pub use_gtd: bool,
375    /// Whether to use canonical Binance Futures position IDs.
376    ///
377    /// When true, Futures hedge-mode order and fill reports include a `venue_position_id` derived
378    /// from the instrument and Binance position side (e.g. `ETHUSDT-PERP.BINANCE-LONG`). Hedge-mode
379    /// REST position reports use the same IDs. One-way `BOTH` reports remain unkeyed. When false,
380    /// `venue_position_id` is None, allowing virtual positions with `OmsType::Hedging`.
381    #[builder(default = true)]
382    pub use_position_ids: bool,
383    /// Optional OMS type override for Binance Futures accounts.
384    ///
385    /// Set to `Hedging` when the account uses dual-side position mode. When
386    /// `None`, Binance Futures clients use `Netting`. Ignored for Spot clients.
387    pub oms_type: Option<OmsType>,
388    /// Default taker fee rate for commission estimation.
389    ///
390    /// Used as a fallback when the venue omits commission fields in
391    /// exchange-generated fills (liquidation, ADL, settlement).
392    /// Standard Binance Futures taker fee is 0.0004 (0.04%).
393    #[builder(default = Decimal::new(4, 4))]
394    pub default_taker_fee: Decimal,
395    /// Receive window in milliseconds for signed HTTP requests.
396    #[builder(default = 5_000)]
397    pub recv_window_ms: u64,
398    /// Maximum retries for HTTP GET requests. Mutating requests are sent once.
399    #[builder(default = RetryConfig::default().max_retries)]
400    pub max_retries: u32,
401    /// Initial HTTP retry delay in milliseconds.
402    #[builder(default = RetryConfig::default().initial_delay_ms)]
403    pub retry_delay_initial_ms: u64,
404    /// Maximum exponential HTTP retry delay in milliseconds.
405    #[builder(default = RetryConfig::default().max_delay_ms)]
406    pub retry_delay_max_ms: u64,
407    /// Whether to route this Spot client to Binance US.
408    #[builder(default)]
409    pub us: bool,
410    /// Initial leverage per Binance symbol (e.g. BTCUSDT -> 20), applied during connect.
411    pub futures_leverages: Option<HashMap<String, u32>>,
412    /// Margin type per Binance symbol (e.g. BTCUSDT -> Cross), applied during connect.
413    pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
414    /// Currency that Binance Futures Credits (`BNFCR`) balances and fees resolve to (defaults to USDT).
415    #[builder(default = Currency::USDT())]
416    pub bnfcr_currency: Currency,
417    /// If true, the EXPIRED execution type emits `OrderCanceled` instead of `OrderExpired`.
418    ///
419    /// Binance uses EXPIRED for certain cancel scenarios depending on order type
420    /// and time-in-force combination.
421    #[builder(default = false)]
422    pub treat_expired_as_canceled: bool,
423    /// If true, drive fills from the lower-latency `TRADE_LITE` user data event
424    /// and dedup the matching fill portion of `ORDER_TRADE_UPDATE`. If false,
425    /// `TRADE_LITE` events are ignored and fills come from `ORDER_TRADE_UPDATE`.
426    #[builder(default = false)]
427    pub use_trade_lite: bool,
428    /// WebSocket transport backend (defaults to `Tungstenite`).
429    #[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    /// Validates Binance execution client configuration.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error for invalid receive-window, WS trading setup timeout, provider, or
474    /// Binance US settings.
475    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}