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