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_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
25use nautilus_model::{
26    enums::OmsType,
27    identifiers::{AccountId, InstrumentId},
28    types::Currency,
29};
30use nautilus_network::{
31    backoff::ExponentialBackoff, retry::RetryConfig, websocket::TransportBackend,
32};
33use rust_decimal::Decimal;
34use serde::{Deserialize, Serialize};
35
36use crate::common::enums::{BinanceEnvironment, BinanceMarginType, BinanceProductType};
37
38/// Configuration for Binance instrument loading.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
40#[serde(default, deny_unknown_fields)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
44)]
45#[cfg_attr(
46    feature = "python",
47    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
48)]
49pub struct BinanceInstrumentProviderConfig {
50    /// Whether to load all instruments on startup.
51    #[builder(default = true)]
52    pub load_all: bool,
53    /// Specific Nautilus instrument IDs to load when `load_all` is false.
54    pub load_ids: Option<Vec<String>>,
55    /// Venue filters applied while loading instruments.
56    ///
57    /// Supported keys are `symbols`, `bases`, `quotes`, and, for Futures,
58    /// `contract_types`. Each value may be a string or an array of strings.
59    #[builder(default)]
60    pub filters: HashMap<String, serde_json::Value>,
61    /// Fully qualified Python callable path requested by legacy configuration.
62    ///
63    /// Binance v2 rejects this field because the legacy Binance provider never
64    /// applied it and Rust live clients cannot safely invoke arbitrary Python.
65    pub filter_callable: Option<String>,
66    /// Whether instrument parser failures should be logged as warnings.
67    ///
68    /// Non-trading symbols skipped during bulk loads always log at debug;
69    /// this flag applies to explicitly selected symbols and unexpected failures.
70    #[builder(default = true)]
71    pub log_warnings: bool,
72    /// Whether to query account-specific commission rates for every loaded symbol.
73    #[builder(default)]
74    pub query_commission_rates: bool,
75}
76
77impl Default for BinanceInstrumentProviderConfig {
78    fn default() -> Self {
79        Self::builder().build()
80    }
81}
82
83impl BinanceInstrumentProviderConfig {
84    /// Validates instrument loading configuration.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if IDs are malformed, filters are unsupported, or a legacy callable filter
89    /// cannot run safely in Binance v2.
90    pub fn validate(&self, product_type: BinanceProductType) -> anyhow::Result<()> {
91        if let Some(filter_callable) = self
92            .filter_callable
93            .as_deref()
94            .map(str::trim)
95            .filter(|value| !value.is_empty())
96        {
97            anyhow::bail!(
98                "Binance v2 does not support instrument filter_callable {filter_callable:?}; \
99                 the legacy Binance provider never applied callable filters"
100            );
101        }
102
103        if let Some(load_ids) = &self.load_ids {
104            for raw in load_ids {
105                let instrument_id = InstrumentId::from_str(raw)
106                    .map_err(|e| anyhow::anyhow!("invalid Binance load_ids value {raw:?}: {e}"))?;
107                anyhow::ensure!(
108                    instrument_id.venue.as_str() == "BINANCE",
109                    "Binance load_ids value {raw:?} must use venue BINANCE"
110                );
111            }
112        }
113
114        for (key, value) in &self.filters {
115            let supported = matches!(key.as_str(), "symbols" | "bases" | "quotes")
116                || key == "contract_types"
117                    && matches!(
118                        product_type,
119                        BinanceProductType::UsdM | BinanceProductType::CoinM
120                    );
121            anyhow::ensure!(
122                supported,
123                "unsupported Binance instrument filter {key:?} for {product_type:?}"
124            );
125            validate_filter_strings(key, value)?;
126        }
127
128        Ok(())
129    }
130
131    pub(crate) fn excludes(&self, instrument_id: InstrumentId) -> bool {
132        !self.load_all
133            && self.load_ids.as_ref().is_some_and(|load_ids| {
134                load_ids
135                    .iter()
136                    .all(|raw_id| InstrumentId::from(raw_id.as_str()) != instrument_id)
137            })
138    }
139}
140
141fn validate_filter_strings(name: &str, value: &serde_json::Value) -> anyhow::Result<()> {
142    let valid = match value {
143        serde_json::Value::String(value) => !value.trim().is_empty(),
144        serde_json::Value::Array(values) => {
145            !values.is_empty()
146                && values
147                    .iter()
148                    .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))
149        }
150        _ => false,
151    };
152
153    anyhow::ensure!(
154        valid,
155        "Binance instrument filter {name:?} must be a non-empty string or array of strings"
156    );
157    Ok(())
158}
159
160/// Spot market-data transport mode.
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(
163    feature = "python",
164    pyo3::pyclass(module = "nautilus_trader.adapters.binance", eq, from_py_object)
165)]
166#[cfg_attr(
167    feature = "python",
168    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.adapters.binance")
169)]
170pub enum BinanceSpotMarketDataMode {
171    #[default]
172    /// Spot SBE streams (requires Ed25519 credentials).
173    Sbe,
174    /// Force Spot public JSON streams (does not require credentials).
175    Json,
176}
177
178/// Configuration for Binance data client.
179///
180/// Ed25519 API keys are required for SBE WebSocket streams.
181#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
182#[serde(default, deny_unknown_fields)]
183#[cfg_attr(
184    feature = "python",
185    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
186)]
187#[cfg_attr(
188    feature = "python",
189    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
190)]
191pub struct BinanceDataClientConfig {
192    /// Product type to subscribe to.
193    #[builder(default = BinanceProductType::Spot)]
194    pub product_type: BinanceProductType,
195    /// Environment (live, testnet, or demo).
196    #[builder(default = BinanceEnvironment::Live)]
197    pub environment: BinanceEnvironment,
198    /// API key (Ed25519).
199    pub api_key: Option<SecretString>,
200    /// API secret (Ed25519 base64-encoded or PEM).
201    pub api_secret: Option<SecretString>,
202    /// Optional base URL override for HTTP API.
203    pub base_url_http: Option<String>,
204    /// Optional base URL override for WebSocket.
205    ///
206    /// Live USD-M Futures data overrides are normalized onto the matching
207    /// `/market/ws` and `/public/ws` routes.
208    pub base_url_ws: Option<String>,
209    /// Optional proxy URL for HTTP and WebSocket transports.
210    pub proxy_url: Option<SecretString>,
211    /// Spot market-data transport mode.
212    ///
213    /// - `Sbe` uses SBE streams and requires Ed25519 credentials.
214    /// - `Json` forces public JSON streams with no credentials.
215    #[builder(default)]
216    pub spot_market_data_mode: BinanceSpotMarketDataMode,
217    /// Instrument loading and fee configuration.
218    #[builder(default)]
219    pub instrument_provider: BinanceInstrumentProviderConfig,
220    /// Interval in seconds for a full instrument catalog refresh.
221    ///
222    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
223    #[builder(default = 3600)]
224    pub instrument_refresh_interval_secs: u64,
225    /// Interval in seconds for polling exchange info to detect instrument status
226    /// changes (e.g. Trading -> Halt). Set to 0 to disable. Defaults to 3600 (60 minutes).
227    #[builder(default = 3600)]
228    pub instrument_status_poll_secs: u64,
229    /// Maximum time to wait for an initial, post-reconnect, or recovery order book
230    /// snapshot in seconds.
231    ///
232    /// Bounds each REST depth snapshot request for diff depth books. Set to 0 to disable.
233    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
234    pub book_snapshot_timeout_secs: u64,
235    /// Receive window in milliseconds for signed HTTP requests.
236    #[builder(default = 5_000)]
237    pub recv_window_ms: u64,
238    /// Maximum retries for HTTP GET requests. Mutating requests are sent once.
239    #[builder(default = RetryConfig::default().max_retries)]
240    pub max_retries: u32,
241    /// Initial HTTP retry delay in milliseconds.
242    #[builder(default = RetryConfig::default().initial_delay_ms)]
243    pub retry_delay_initial_ms: u64,
244    /// Maximum exponential HTTP retry delay in milliseconds.
245    #[builder(default = RetryConfig::default().max_delay_ms)]
246    pub retry_delay_max_ms: u64,
247    /// Whether to route this Spot client to Binance US.
248    #[builder(default)]
249    pub us: bool,
250    /// WebSocket transport backend (defaults to `Tungstenite`).
251    #[builder(default)]
252    pub transport_backend: TransportBackend,
253}
254
255#[cfg(feature = "python")]
256nautilus_core::impl_pyo3_config_getters!(BinanceDataClientConfig {
257    product_type: BinanceProductType,
258    environment: BinanceEnvironment,
259    base_url_http: Option<String>,
260    base_url_ws: Option<String>,
261    spot_market_data_mode: BinanceSpotMarketDataMode,
262    instrument_provider: BinanceInstrumentProviderConfig,
263    instrument_refresh_interval_secs: u64,
264    instrument_status_poll_secs: u64,
265    book_snapshot_timeout_secs: u64,
266    recv_window_ms: u64,
267    max_retries: u32,
268    retry_delay_initial_ms: u64,
269    retry_delay_max_ms: u64,
270    us: bool,
271    transport_backend: TransportBackend,
272});
273
274impl Default for BinanceDataClientConfig {
275    fn default() -> Self {
276        Self::builder().build()
277    }
278}
279
280impl BinanceDataClientConfig {
281    /// Validates Binance data client configuration.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if the receive-window, provider, or Binance US settings are invalid.
286    pub fn validate(&self) -> anyhow::Result<()> {
287        validate_recv_window(self.recv_window_ms)?;
288        validate_retry_config(&self.retry_config())?;
289        self.instrument_provider.validate(self.product_type)?;
290
291        if self.us {
292            anyhow::ensure!(
293                self.product_type == BinanceProductType::Spot,
294                "Binance US supports Spot clients only"
295            );
296            anyhow::ensure!(
297                self.environment == BinanceEnvironment::Live,
298                "Binance US supports the Live environment only"
299            );
300            anyhow::ensure!(
301                self.spot_market_data_mode == BinanceSpotMarketDataMode::Json,
302                "Binance US market data requires spot_market_data_mode=Json"
303            );
304        }
305
306        Ok(())
307    }
308
309    pub(crate) fn retry_config(&self) -> RetryConfig {
310        RetryConfig {
311            max_retries: self.max_retries,
312            initial_delay_ms: self.retry_delay_initial_ms,
313            max_delay_ms: self.retry_delay_max_ms,
314            ..crate::common::http::retry_config()
315        }
316    }
317}
318
319impl ClientConfig for BinanceDataClientConfig {
320    fn as_any(&self) -> &dyn Any {
321        self
322    }
323}
324
325/// Configuration for Binance execution client.
326///
327/// Global execution uses WebSocket API authentication with Ed25519 credentials.
328/// Binance US uses HMAC-signed HTTP requests and listen-key user data streams.
329#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
330#[serde(default, deny_unknown_fields)]
331#[cfg_attr(
332    feature = "python",
333    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
334)]
335#[cfg_attr(
336    feature = "python",
337    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
338)]
339pub struct BinanceExecutionClientConfig {
340    /// Account ID for the client.
341    #[builder(default = AccountId::from("BINANCE-001"))]
342    pub account_id: AccountId,
343    /// Product type to trade.
344    #[builder(default = BinanceProductType::Spot)]
345    pub product_type: BinanceProductType,
346    /// Environment (live, testnet, or demo).
347    #[builder(default = BinanceEnvironment::Live)]
348    pub environment: BinanceEnvironment,
349    /// API key (uses an environment variable if not provided).
350    pub api_key: Option<SecretString>,
351    /// API secret (Ed25519 for Global or HMAC for Binance US).
352    pub api_secret: Option<SecretString>,
353    /// Optional base URL override for HTTP API.
354    pub base_url_http: Option<String>,
355    /// Optional base URL override for WebSocket user data stream.
356    ///
357    /// Live USD-M Futures stream overrides are normalized onto the `/private/ws` route.
358    pub base_url_ws: Option<String>,
359    /// Optional base URL override for WebSocket trading API (Spot and USD-M Futures).
360    pub base_url_ws_trading: Option<String>,
361    /// Optional proxy URL for HTTP and WebSocket transports.
362    pub proxy_url: Option<SecretString>,
363    /// Whether to use the WebSocket trading API for order operations (Spot and USD-M Futures).
364    #[builder(default = true)]
365    pub use_ws_trading: bool,
366    /// Timeout in milliseconds for each Binance Spot WS trading setup response.
367    #[builder(default = 10_000)]
368    pub ws_trading_setup_timeout_ms: u64,
369    /// Instrument loading and fee configuration.
370    #[builder(default)]
371    pub instrument_provider: BinanceInstrumentProviderConfig,
372    /// Interval in seconds for refreshing the execution instrument cache.
373    ///
374    /// Set to 0 to disable. Defaults to 3600 (60 minutes).
375    #[builder(default = 3600)]
376    pub instrument_refresh_interval_secs: u64,
377    /// Whether to use Binance-native GTD orders.
378    ///
379    /// Set to false only when the strategy manages GTD expiry locally. The adapter then maps GTD
380    /// to GTC and the strategy must enable `manage_gtd_expiry`.
381    #[builder(default = true)]
382    pub use_gtd: bool,
383    /// Whether to use canonical Binance Futures position IDs.
384    ///
385    /// When true, Futures hedge-mode order and fill reports include a `venue_position_id` derived
386    /// from the instrument and Binance position side (e.g. `ETHUSDT-PERP.BINANCE-LONG`). Hedge-mode
387    /// REST position reports use the same IDs. One-way `BOTH` reports remain unkeyed. When false,
388    /// `venue_position_id` is None, allowing virtual positions with `OmsType::Hedging`.
389    #[builder(default = true)]
390    pub use_position_ids: bool,
391    /// Optional OMS type override for Binance Futures accounts.
392    ///
393    /// Set to `Hedging` when the account uses dual-side position mode. When
394    /// `None`, Binance Futures clients use `Netting`. Ignored for Spot clients.
395    pub oms_type: Option<OmsType>,
396    /// Default taker fee rate for commission estimation.
397    ///
398    /// Used as a fallback when the venue omits commission fields in
399    /// exchange-generated fills (liquidation, ADL, settlement).
400    /// Standard Binance Futures taker fee is 0.0004 (0.04%).
401    #[builder(default = Decimal::new(4, 4))]
402    pub default_taker_fee: Decimal,
403    /// Receive window in milliseconds for signed HTTP requests.
404    #[builder(default = 5_000)]
405    pub recv_window_ms: u64,
406    /// Maximum retries for HTTP GET requests. Mutating requests are sent once.
407    #[builder(default = RetryConfig::default().max_retries)]
408    pub max_retries: u32,
409    /// Initial HTTP retry delay in milliseconds.
410    #[builder(default = RetryConfig::default().initial_delay_ms)]
411    pub retry_delay_initial_ms: u64,
412    /// Maximum exponential HTTP retry delay in milliseconds.
413    #[builder(default = RetryConfig::default().max_delay_ms)]
414    pub retry_delay_max_ms: u64,
415    /// Whether to route this Spot client to Binance US.
416    #[builder(default)]
417    pub us: bool,
418    /// Initial leverage per Binance symbol (e.g. BTCUSDT -> 20), applied during connect.
419    pub futures_leverages: Option<HashMap<String, u32>>,
420    /// Margin type per Binance symbol (e.g. BTCUSDT -> Cross), applied during connect.
421    pub futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
422    /// Currency that Binance Futures Credits (`BNFCR`) balances and fees resolve to (defaults to USDT).
423    #[builder(default = Currency::USDT())]
424    pub bnfcr_currency: Currency,
425    /// If true, the EXPIRED execution type emits `OrderCanceled` instead of `OrderExpired`.
426    ///
427    /// Binance uses EXPIRED for certain cancel scenarios depending on order type
428    /// and time-in-force combination.
429    #[builder(default = false)]
430    pub treat_expired_as_canceled: bool,
431    /// If true, drive fills from the lower-latency `TRADE_LITE` user data event
432    /// and dedup the matching fill portion of `ORDER_TRADE_UPDATE`. If false,
433    /// `TRADE_LITE` events are ignored and fills come from `ORDER_TRADE_UPDATE`.
434    #[builder(default = false)]
435    pub use_trade_lite: bool,
436    /// WebSocket transport backend (defaults to `Tungstenite`).
437    #[builder(default)]
438    pub transport_backend: TransportBackend,
439}
440
441#[cfg(feature = "python")]
442nautilus_core::impl_pyo3_config_getters!(BinanceExecutionClientConfig {
443    account_id: AccountId,
444    product_type: BinanceProductType,
445    environment: BinanceEnvironment,
446    base_url_http: Option<String>,
447    base_url_ws: Option<String>,
448    base_url_ws_trading: Option<String>,
449    use_ws_trading: bool,
450    ws_trading_setup_timeout_ms: u64,
451    instrument_provider: BinanceInstrumentProviderConfig,
452    instrument_refresh_interval_secs: u64,
453    use_gtd: bool,
454    use_position_ids: bool,
455    oms_type: Option<OmsType>,
456    default_taker_fee: Decimal,
457    recv_window_ms: u64,
458    max_retries: u32,
459    retry_delay_initial_ms: u64,
460    retry_delay_max_ms: u64,
461    us: bool,
462    futures_leverages: Option<HashMap<String, u32>>,
463    futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
464    treat_expired_as_canceled: bool,
465    use_trade_lite: bool,
466    bnfcr_currency: Currency,
467    transport_backend: TransportBackend,
468});
469
470impl Default for BinanceExecutionClientConfig {
471    fn default() -> Self {
472        Self::builder().build()
473    }
474}
475
476impl BinanceExecutionClientConfig {
477    /// Validates Binance execution client configuration.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the receive-window, WS trading setup timeout, provider, or Binance US
482    /// settings are invalid.
483    pub fn validate(&self) -> anyhow::Result<()> {
484        validate_recv_window(self.recv_window_ms)?;
485        validate_retry_config(&self.retry_config())?;
486        anyhow::ensure!(
487            self.ws_trading_setup_timeout_ms > 0,
488            "ws_trading_setup_timeout_ms must be greater than 0, was {}",
489            self.ws_trading_setup_timeout_ms
490        );
491        self.instrument_provider.validate(self.product_type)?;
492
493        if self.us {
494            anyhow::ensure!(
495                self.product_type == BinanceProductType::Spot,
496                "Binance US supports Spot clients only"
497            );
498            anyhow::ensure!(
499                self.environment == BinanceEnvironment::Live,
500                "Binance US supports the Live environment only"
501            );
502        }
503
504        Ok(())
505    }
506
507    pub(crate) fn retry_config(&self) -> RetryConfig {
508        RetryConfig {
509            max_retries: self.max_retries,
510            initial_delay_ms: self.retry_delay_initial_ms,
511            max_delay_ms: self.retry_delay_max_ms,
512            ..crate::common::http::retry_config()
513        }
514    }
515}
516
517fn validate_retry_config(config: &RetryConfig) -> anyhow::Result<()> {
518    ExponentialBackoff::new(
519        Duration::from_millis(config.initial_delay_ms),
520        Duration::from_millis(config.max_delay_ms),
521        config.backoff_factor,
522        config.jitter_ms,
523        config.immediate_first,
524    )?;
525    Ok(())
526}
527
528fn validate_recv_window(recv_window_ms: u64) -> anyhow::Result<()> {
529    anyhow::ensure!(
530        (1..=60_000).contains(&recv_window_ms),
531        "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
532    );
533    Ok(())
534}
535
536impl ClientConfig for BinanceExecutionClientConfig {
537    fn as_any(&self) -> &dyn Any {
538        self
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use rstest::rstest;
545
546    use super::*;
547
548    #[rstest]
549    fn test_config_debug_redacts_credentials() {
550        let data = BinanceDataClientConfig {
551            api_key: Some("data-api-key".into()),
552            api_secret: Some("data-api-secret".into()),
553            proxy_url: Some("http://user:data-proxy@localhost".into()),
554            ..Default::default()
555        };
556        let execution = BinanceExecutionClientConfig {
557            api_key: Some("exec-api-key".into()),
558            api_secret: Some("exec-api-secret".into()),
559            proxy_url: Some("http://user:exec-proxy@localhost".into()),
560            ..Default::default()
561        };
562
563        let formatted = format!("{data:?} {execution:?}");
564
565        assert_eq!(formatted.matches(REDACTED).count(), 6);
566
567        for secret in [
568            "data-api-key",
569            "data-api-secret",
570            "data-proxy",
571            "exec-api-key",
572            "exec-api-secret",
573            "exec-proxy",
574        ] {
575            assert!(!formatted.contains(secret));
576        }
577    }
578
579    #[rstest]
580    fn test_data_config_toml_minimal() {
581        let config: BinanceDataClientConfig = toml::from_str(
582            r#"
583environment = "Testnet"
584product_type = "USD_M"
585instrument_status_poll_secs = 600
586"#,
587        )
588        .unwrap();
589
590        assert_eq!(config.environment, BinanceEnvironment::Testnet);
591        assert_eq!(config.product_type, BinanceProductType::UsdM);
592        assert_eq!(config.spot_market_data_mode, BinanceSpotMarketDataMode::Sbe);
593        assert_eq!(config.instrument_status_poll_secs, 600);
594        assert_eq!(config.book_snapshot_timeout_secs, 10);
595    }
596
597    #[rstest]
598    fn test_data_config_toml_spot_market_data_mode_override() {
599        let config: BinanceDataClientConfig = toml::from_str(
600            r#"
601spot_market_data_mode = "Json"
602"#,
603        )
604        .unwrap();
605
606        assert_eq!(
607            config.spot_market_data_mode,
608            BinanceSpotMarketDataMode::Json
609        );
610    }
611
612    #[rstest]
613    fn test_data_config_toml_rejects_plural_product_types() {
614        let result = toml::from_str::<BinanceDataClientConfig>(
615            r#"
616product_types = ["SPOT", "USD_M"]
617"#,
618        );
619
620        let message = result.unwrap_err().to_string();
621        assert!(message.contains("unknown field `product_types`"));
622    }
623
624    #[rstest]
625    fn test_exec_config_toml_empty_uses_defaults() {
626        let config: BinanceExecutionClientConfig = toml::from_str("").unwrap();
627        let expected = BinanceExecutionClientConfig::default();
628
629        assert_eq!(config.environment, expected.environment);
630        assert_eq!(config.product_type, expected.product_type);
631        assert_eq!(config.use_ws_trading, expected.use_ws_trading);
632        assert_eq!(config.ws_trading_setup_timeout_ms, 10_000);
633        assert_eq!(config.instrument_provider, expected.instrument_provider);
634        assert_eq!(
635            config.instrument_refresh_interval_secs,
636            expected.instrument_refresh_interval_secs
637        );
638        assert_eq!(config.use_gtd, expected.use_gtd);
639        assert_eq!(config.use_position_ids, expected.use_position_ids);
640        assert_eq!(config.oms_type, expected.oms_type);
641        assert_eq!(config.default_taker_fee, expected.default_taker_fee);
642        assert_eq!(config.proxy_url, expected.proxy_url);
643        assert_eq!(config.recv_window_ms, expected.recv_window_ms);
644        assert_eq!(config.us, expected.us);
645        assert_eq!(
646            config.treat_expired_as_canceled,
647            expected.treat_expired_as_canceled,
648        );
649        assert_eq!(config.use_trade_lite, expected.use_trade_lite);
650        assert_eq!(config.transport_backend, expected.transport_backend);
651    }
652
653    #[rstest]
654    fn test_exec_config_toml_oms_type_override() {
655        let config: BinanceExecutionClientConfig = toml::from_str(
656            r#"
657oms_type = "Hedging"
658"#,
659        )
660        .unwrap();
661
662        assert_eq!(config.oms_type, Some(OmsType::Hedging));
663    }
664
665    #[rstest]
666    fn test_exec_config_toml_use_gtd_override() {
667        let config: BinanceExecutionClientConfig = toml::from_str("use_gtd = false").unwrap();
668
669        assert!(!config.use_gtd);
670    }
671
672    #[rstest]
673    fn test_exec_config_toml_ws_trading_setup_timeout_override() {
674        let config: BinanceExecutionClientConfig =
675            toml::from_str("ws_trading_setup_timeout_ms = 250").unwrap();
676
677        assert_eq!(config.ws_trading_setup_timeout_ms, 250);
678    }
679
680    #[rstest]
681    #[case(0)]
682    #[case(60_001)]
683    fn test_data_config_rejects_recv_window_out_of_bounds(#[case] recv_window_ms: u64) {
684        let config = BinanceDataClientConfig {
685            recv_window_ms,
686            ..Default::default()
687        };
688
689        let message = config.validate().unwrap_err().to_string();
690
691        assert_eq!(
692            message,
693            format!(
694                "recv_window_ms must be in the inclusive range 1..=60000, was {recv_window_ms}"
695            )
696        );
697    }
698
699    #[rstest]
700    fn test_exec_config_rejects_zero_ws_trading_setup_timeout() {
701        let config = BinanceExecutionClientConfig {
702            ws_trading_setup_timeout_ms: 0,
703            ..Default::default()
704        };
705
706        let message = config.validate().unwrap_err().to_string();
707
708        assert_eq!(
709            message,
710            "ws_trading_setup_timeout_ms must be greater than 0, was 0"
711        );
712    }
713
714    #[rstest]
715    #[case(1)]
716    #[case(60_000)]
717    fn test_exec_config_accepts_recv_window_bounds(#[case] recv_window_ms: u64) {
718        let config = BinanceExecutionClientConfig {
719            recv_window_ms,
720            ..Default::default()
721        };
722
723        assert!(config.validate().is_ok());
724    }
725
726    #[rstest]
727    #[case(
728        BinanceProductType::UsdM,
729        BinanceEnvironment::Live,
730        BinanceSpotMarketDataMode::Json,
731        "Binance US supports Spot clients only"
732    )]
733    #[case(
734        BinanceProductType::Spot,
735        BinanceEnvironment::Testnet,
736        BinanceSpotMarketDataMode::Json,
737        "Binance US supports the Live environment only"
738    )]
739    #[case(
740        BinanceProductType::Spot,
741        BinanceEnvironment::Live,
742        BinanceSpotMarketDataMode::Sbe,
743        "Binance US market data requires spot_market_data_mode=Json"
744    )]
745    fn test_data_config_rejects_unsupported_binance_us_combinations(
746        #[case] product_type: BinanceProductType,
747        #[case] environment: BinanceEnvironment,
748        #[case] spot_market_data_mode: BinanceSpotMarketDataMode,
749        #[case] expected: &str,
750    ) {
751        let config = BinanceDataClientConfig {
752            product_type,
753            environment,
754            spot_market_data_mode,
755            us: true,
756            ..Default::default()
757        };
758
759        assert_eq!(config.validate().unwrap_err().to_string(), expected);
760    }
761
762    #[rstest]
763    #[case(
764        BinanceProductType::CoinM,
765        BinanceEnvironment::Live,
766        "Binance US supports Spot clients only"
767    )]
768    #[case(
769        BinanceProductType::Spot,
770        BinanceEnvironment::Demo,
771        "Binance US supports the Live environment only"
772    )]
773    fn test_exec_config_rejects_unsupported_binance_us_combinations(
774        #[case] product_type: BinanceProductType,
775        #[case] environment: BinanceEnvironment,
776        #[case] expected: &str,
777    ) {
778        let config = BinanceExecutionClientConfig {
779            product_type,
780            environment,
781            us: true,
782            ..Default::default()
783        };
784
785        assert_eq!(config.validate().unwrap_err().to_string(), expected);
786    }
787
788    #[rstest]
789    fn test_instrument_provider_rejects_callable_and_spot_contract_filter() {
790        let callable = BinanceInstrumentProviderConfig {
791            filter_callable: Some("package.module:predicate".to_string()),
792            ..Default::default()
793        };
794        let contract_filter = BinanceInstrumentProviderConfig {
795            filters: HashMap::from([(
796                "contract_types".to_string(),
797                serde_json::json!("PERPETUAL"),
798            )]),
799            ..Default::default()
800        };
801
802        assert_eq!(
803            callable
804                .validate(BinanceProductType::Spot)
805                .unwrap_err()
806                .to_string(),
807            "Binance v2 does not support instrument filter_callable \"package.module:predicate\"; the legacy Binance provider never applied callable filters"
808        );
809        assert_eq!(
810            contract_filter
811                .validate(BinanceProductType::Spot)
812                .unwrap_err()
813                .to_string(),
814            "unsupported Binance instrument filter \"contract_types\" for Spot"
815        );
816    }
817
818    #[rstest]
819    fn test_instrument_provider_rejects_empty_and_non_string_filter_values() {
820        for value in [serde_json::json!([]), serde_json::json!(["BTC", 7])] {
821            let config = BinanceInstrumentProviderConfig {
822                filters: HashMap::from([("bases".to_string(), value)]),
823                ..Default::default()
824            };
825
826            assert_eq!(
827                config
828                    .validate(BinanceProductType::UsdM)
829                    .unwrap_err()
830                    .to_string(),
831                "Binance instrument filter \"bases\" must be a non-empty string or array of strings"
832            );
833        }
834    }
835}