Skip to main content

nautilus_binance/python/
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//! Python bindings for Binance configuration.
17
18use std::collections::HashMap;
19
20use nautilus_core::{python::to_pyvalue_err, string::secret::SecretString};
21use nautilus_model::{enums::OmsType, identifiers::AccountId, types::Currency};
22use nautilus_network::websocket::TransportBackend;
23use pyo3::{
24    prelude::*,
25    types::{PyDict, PyDictMethods},
26};
27use rust_decimal::Decimal;
28
29use crate::{
30    common::enums::{BinanceEnvironment, BinanceMarginType, BinanceProductType},
31    config::{
32        BinanceDataClientConfig, BinanceExecutionClientConfig, BinanceInstrumentProviderConfig,
33        BinanceSpotMarketDataMode,
34    },
35};
36
37#[pymethods]
38#[pyo3_stub_gen::derive::gen_stub_pymethods]
39impl BinanceInstrumentProviderConfig {
40    /// Configuration for Binance instrument loading.
41    #[new]
42    #[pyo3(signature = (
43        load_all = true,
44        load_ids = None,
45        filters = None,
46        filter_callable = None,
47        log_warnings = true,
48        query_commission_rates = false,
49    ))]
50    fn py_new(
51        load_all: bool,
52        load_ids: Option<Vec<String>>,
53        filters: Option<HashMap<String, Py<PyAny>>>,
54        filter_callable: Option<String>,
55        log_warnings: bool,
56        query_commission_rates: bool,
57    ) -> PyResult<Self> {
58        let filters = filters
59            .map(nautilus_live::python::config::coerce_json_config)
60            .transpose()?
61            .unwrap_or_default();
62
63        let config = Self {
64            load_all,
65            load_ids,
66            filters,
67            filter_callable,
68            log_warnings,
69            query_commission_rates,
70        };
71
72        config.validate_selections().map_err(to_pyvalue_err)?;
73        Ok(config)
74    }
75
76    fn __repr__(&self) -> String {
77        stringify!(BinanceInstrumentProviderConfig).to_string()
78    }
79
80    #[getter]
81    fn load_all(&self) -> bool {
82        self.load_all
83    }
84
85    #[getter]
86    fn load_ids(&self) -> Option<Vec<String>> {
87        self.load_ids.clone()
88    }
89
90    #[getter]
91    fn filters(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
92        let dict = PyDict::new(py);
93        for (key, value) in &self.filters {
94            dict.set_item(
95                key,
96                nautilus_live::python::config::json_value_to_py(py, value)?,
97            )?;
98        }
99        Ok(dict.into_any().unbind())
100    }
101
102    #[getter]
103    fn filter_callable(&self) -> Option<String> {
104        self.filter_callable.clone()
105    }
106
107    #[getter]
108    fn log_warnings(&self) -> bool {
109        self.log_warnings
110    }
111
112    #[getter]
113    fn query_commission_rates(&self) -> bool {
114        self.query_commission_rates
115    }
116}
117
118#[pymethods]
119#[pyo3_stub_gen::derive::gen_stub_pymethods]
120impl BinanceDataClientConfig {
121    /// Configuration for Binance data client.
122    ///
123    /// Ed25519 API keys are required for SBE WebSocket streams.
124    #[new]
125    #[pyo3(signature = (
126        product_type = None,
127        environment = None,
128        base_url_http = None,
129        base_url_ws = None,
130        api_key = None,
131        api_secret = None,
132        spot_market_data_mode = None,
133        instrument_provider = None,
134        instrument_refresh_interval_secs = None,
135        instrument_status_poll_secs = None,
136        proxy_url = None,
137        recv_window_ms = None,
138        us = false,
139        transport_backend = None,
140        max_retries = None,
141        retry_delay_initial_ms = None,
142        retry_delay_max_ms = None,
143        book_snapshot_timeout_secs = None,
144    ))]
145    #[expect(clippy::too_many_arguments)]
146    fn py_new(
147        product_type: Option<BinanceProductType>,
148        environment: Option<BinanceEnvironment>,
149        base_url_http: Option<String>,
150        base_url_ws: Option<String>,
151        api_key: Option<String>,
152        api_secret: Option<String>,
153        spot_market_data_mode: Option<BinanceSpotMarketDataMode>,
154        instrument_provider: Option<BinanceInstrumentProviderConfig>,
155        instrument_refresh_interval_secs: Option<u64>,
156        instrument_status_poll_secs: Option<u64>,
157        proxy_url: Option<String>,
158        recv_window_ms: Option<u64>,
159        us: bool,
160        transport_backend: Option<TransportBackend>,
161        max_retries: Option<u32>,
162        retry_delay_initial_ms: Option<u64>,
163        retry_delay_max_ms: Option<u64>,
164        book_snapshot_timeout_secs: Option<u64>,
165    ) -> PyResult<Self> {
166        let defaults = Self::default();
167        let config = Self {
168            product_type: product_type.unwrap_or(defaults.product_type),
169            environment: environment.unwrap_or(defaults.environment),
170            base_url_http: base_url_http.or(defaults.base_url_http),
171            base_url_ws: base_url_ws.or(defaults.base_url_ws),
172            api_key: api_key.map(SecretString::from).or(defaults.api_key),
173            api_secret: api_secret.map(SecretString::from).or(defaults.api_secret),
174            spot_market_data_mode: spot_market_data_mode.unwrap_or(defaults.spot_market_data_mode),
175            instrument_provider: instrument_provider.unwrap_or(defaults.instrument_provider),
176            instrument_refresh_interval_secs: instrument_refresh_interval_secs
177                .unwrap_or(defaults.instrument_refresh_interval_secs),
178            instrument_status_poll_secs: instrument_status_poll_secs
179                .unwrap_or(defaults.instrument_status_poll_secs),
180            book_snapshot_timeout_secs: book_snapshot_timeout_secs
181                .unwrap_or(defaults.book_snapshot_timeout_secs),
182            proxy_url: proxy_url.map(SecretString::from).or(defaults.proxy_url),
183            recv_window_ms: recv_window_ms.unwrap_or(defaults.recv_window_ms),
184            max_retries: max_retries.unwrap_or(defaults.max_retries),
185            retry_delay_initial_ms: retry_delay_initial_ms
186                .unwrap_or(defaults.retry_delay_initial_ms),
187            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(defaults.retry_delay_max_ms),
188            us,
189            transport_backend: transport_backend.unwrap_or(defaults.transport_backend),
190        };
191        config.validate().map_err(to_pyvalue_err)?;
192        Ok(config)
193    }
194
195    #[getter]
196    const fn has_proxy_url(&self) -> bool {
197        self.proxy_url.is_some()
198    }
199
200    fn __repr__(&self) -> String {
201        stringify!(BinanceDataClientConfig).to_string()
202    }
203}
204
205#[pymethods]
206#[pyo3_stub_gen::derive::gen_stub_pymethods]
207impl BinanceExecutionClientConfig {
208    /// Configuration for Binance execution client.
209    ///
210    /// Global execution uses WebSocket API authentication with Ed25519 credentials.
211    /// Binance US uses HMAC-signed HTTP requests and listen-key user data streams.
212    #[new]
213    #[pyo3(signature = (
214        account_id,
215        product_type = None,
216        environment = None,
217        base_url_http = None,
218        base_url_ws = None,
219        base_url_ws_trading = None,
220        use_ws_trading = true,
221        ws_trading_setup_timeout_ms = None,
222        instrument_provider = None,
223        instrument_refresh_interval_secs = None,
224        use_gtd = true,
225        use_position_ids = true,
226        oms_type = None,
227        default_taker_fee = None,
228        proxy_url = None,
229        recv_window_ms = None,
230        us = false,
231        api_key = None,
232        api_secret = None,
233        futures_leverages = None,
234        futures_margin_types = None,
235        treat_expired_as_canceled = false,
236        use_trade_lite = false,
237        bnfcr_currency = None,
238        transport_backend = None,
239        max_retries = None,
240        retry_delay_initial_ms = None,
241        retry_delay_max_ms = None,
242    ))]
243    #[expect(clippy::too_many_arguments)]
244    fn py_new(
245        account_id: AccountId,
246        product_type: Option<BinanceProductType>,
247        environment: Option<BinanceEnvironment>,
248        base_url_http: Option<String>,
249        base_url_ws: Option<String>,
250        base_url_ws_trading: Option<String>,
251        use_ws_trading: bool,
252        ws_trading_setup_timeout_ms: Option<u64>,
253        instrument_provider: Option<BinanceInstrumentProviderConfig>,
254        instrument_refresh_interval_secs: Option<u64>,
255        use_gtd: bool,
256        use_position_ids: bool,
257        oms_type: Option<OmsType>,
258        default_taker_fee: Option<f64>,
259        proxy_url: Option<String>,
260        recv_window_ms: Option<u64>,
261        us: bool,
262        api_key: Option<String>,
263        api_secret: Option<String>,
264        futures_leverages: Option<HashMap<String, u32>>,
265        futures_margin_types: Option<HashMap<String, BinanceMarginType>>,
266        treat_expired_as_canceled: bool,
267        use_trade_lite: bool,
268        bnfcr_currency: Option<Currency>,
269        transport_backend: Option<TransportBackend>,
270        max_retries: Option<u32>,
271        retry_delay_initial_ms: Option<u64>,
272        retry_delay_max_ms: Option<u64>,
273    ) -> PyResult<Self> {
274        let defaults = Self::default();
275        let config = Self {
276            account_id,
277            product_type: product_type.unwrap_or(defaults.product_type),
278            environment: environment.unwrap_or(defaults.environment),
279            base_url_http: base_url_http.or(defaults.base_url_http),
280            base_url_ws: base_url_ws.or(defaults.base_url_ws),
281            base_url_ws_trading: base_url_ws_trading.or(defaults.base_url_ws_trading),
282            use_ws_trading,
283            ws_trading_setup_timeout_ms: ws_trading_setup_timeout_ms
284                .unwrap_or(defaults.ws_trading_setup_timeout_ms),
285            instrument_provider: instrument_provider.unwrap_or(defaults.instrument_provider),
286            instrument_refresh_interval_secs: instrument_refresh_interval_secs
287                .unwrap_or(defaults.instrument_refresh_interval_secs),
288            use_gtd,
289            use_position_ids,
290            oms_type,
291            default_taker_fee: default_taker_fee
292                .map_or_else(|| Ok(defaults.default_taker_fee), Decimal::try_from)
293                .unwrap_or(defaults.default_taker_fee),
294            proxy_url: proxy_url.map(SecretString::from).or(defaults.proxy_url),
295            recv_window_ms: recv_window_ms.unwrap_or(defaults.recv_window_ms),
296            max_retries: max_retries.unwrap_or(defaults.max_retries),
297            retry_delay_initial_ms: retry_delay_initial_ms
298                .unwrap_or(defaults.retry_delay_initial_ms),
299            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(defaults.retry_delay_max_ms),
300            us,
301            api_key: api_key.map(SecretString::from).or(defaults.api_key),
302            api_secret: api_secret.map(SecretString::from).or(defaults.api_secret),
303            futures_leverages,
304            futures_margin_types,
305            bnfcr_currency: bnfcr_currency.unwrap_or(defaults.bnfcr_currency),
306            treat_expired_as_canceled,
307            use_trade_lite,
308            transport_backend: transport_backend.unwrap_or(defaults.transport_backend),
309        };
310        config.validate().map_err(to_pyvalue_err)?;
311        Ok(config)
312    }
313
314    #[getter]
315    const fn has_proxy_url(&self) -> bool {
316        self.proxy_url.is_some()
317    }
318
319    fn __repr__(&self) -> String {
320        stringify!(BinanceExecutionClientConfig).to_string()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use rstest::rstest;
327    use rust_decimal::Decimal;
328
329    use super::*;
330
331    #[rstest]
332    fn test_python_constructors_preserve_existing_positional_arguments() {
333        Python::initialize();
334        Python::attach(|py| {
335            let locals = PyDict::new(py);
336            locals
337                .set_item("DataConfig", py.get_type::<BinanceDataClientConfig>())
338                .unwrap();
339            locals
340                .set_item("ExecConfig", py.get_type::<BinanceExecutionClientConfig>())
341                .unwrap();
342            locals
343                .set_item(
344                    "account_id",
345                    Py::new(py, AccountId::from("BINANCE-001")).unwrap(),
346                )
347                .unwrap();
348            let data = py.eval(
349                c"DataConfig(None, None, None, None, None, None, None, None, None, None, None, None, False, None)",
350                None, Some(&locals),
351            ).unwrap();
352            let execution = py.eval(
353                c"ExecConfig(account_id, None, None, None, None, None, True, None, None, None, True, True, None, None, None, None, False, None, None, None, None, False, False, None, None)",
354                None, Some(&locals),
355            ).unwrap();
356            let data = data.extract::<PyRef<BinanceDataClientConfig>>().unwrap();
357            let execution = execution
358                .extract::<PyRef<BinanceExecutionClientConfig>>()
359                .unwrap();
360
361            assert_eq!(
362                data.max_retries,
363                BinanceDataClientConfig::default().max_retries
364            );
365            assert!(!data.us);
366            assert_eq!(
367                execution.max_retries,
368                BinanceExecutionClientConfig::default().max_retries
369            );
370            assert!(!execution.us);
371            assert_eq!(execution.account_id, AccountId::from("BINANCE-001"));
372        });
373    }
374
375    #[rstest]
376    fn test_data_client_py_new_uses_defaults_for_omitted_fields() {
377        let config = BinanceDataClientConfig::py_new(
378            None, None, None, None, None, None, None, None, None, None, None, None, false, None,
379            None, None, None, None,
380        )
381        .unwrap();
382        let defaults = BinanceDataClientConfig::default();
383
384        assert_eq!(config.product_type, defaults.product_type);
385        assert_eq!(config.environment, defaults.environment);
386        assert_eq!(config.base_url_http, defaults.base_url_http);
387        assert_eq!(config.base_url_ws, defaults.base_url_ws);
388        assert_eq!(config.api_key, defaults.api_key);
389        assert_eq!(config.api_secret, defaults.api_secret);
390        assert_eq!(config.spot_market_data_mode, defaults.spot_market_data_mode);
391        assert_eq!(config.instrument_provider, defaults.instrument_provider);
392        assert_eq!(
393            config.instrument_refresh_interval_secs,
394            defaults.instrument_refresh_interval_secs
395        );
396        assert_eq!(
397            config.instrument_status_poll_secs,
398            defaults.instrument_status_poll_secs
399        );
400        assert_eq!(
401            config.book_snapshot_timeout_secs,
402            defaults.book_snapshot_timeout_secs
403        );
404        assert_eq!(config.proxy_url, defaults.proxy_url);
405        assert_eq!(config.recv_window_ms, defaults.recv_window_ms);
406        assert!(!config.us);
407    }
408
409    #[rstest]
410    fn test_data_client_py_new_uses_explicit_overrides() {
411        let config = BinanceDataClientConfig::py_new(
412            Some(BinanceProductType::UsdM),
413            Some(BinanceEnvironment::Testnet),
414            Some("https://http.example".to_string()),
415            Some("wss://ws.example".to_string()),
416            Some("api-key".to_string()),
417            Some("api-secret".to_string()),
418            Some(BinanceSpotMarketDataMode::Json),
419            None,
420            Some(30),
421            Some(15),
422            Some("http://proxy.example:8080".to_string()),
423            Some(45_000),
424            false,
425            None,
426            Some(7),
427            Some(123),
428            Some(456),
429            Some(0),
430        )
431        .unwrap();
432
433        assert_eq!(config.product_type, BinanceProductType::UsdM);
434        assert_eq!(config.environment, BinanceEnvironment::Testnet);
435        assert_eq!(
436            config.base_url_http.as_deref(),
437            Some("https://http.example")
438        );
439        assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
440        assert_eq!(
441            config.api_key.as_ref().map(SecretString::expose_secret),
442            Some("api-key"),
443        );
444        assert_eq!(
445            config.api_secret.as_ref().map(SecretString::expose_secret),
446            Some("api-secret"),
447        );
448        assert_eq!(
449            config.spot_market_data_mode,
450            BinanceSpotMarketDataMode::Json
451        );
452        assert_eq!(config.instrument_refresh_interval_secs, 30);
453        assert_eq!(config.instrument_status_poll_secs, 15);
454        assert_eq!(
455            config.proxy_url.as_ref().map(SecretString::expose_secret),
456            Some("http://proxy.example:8080")
457        );
458        assert_eq!(config.recv_window_ms, 45_000);
459        assert_eq!(config.max_retries, 7);
460        assert_eq!(config.retry_delay_initial_ms, 123);
461        assert_eq!(config.retry_delay_max_ms, 456);
462        assert_eq!(config.book_snapshot_timeout_secs, 0);
463    }
464
465    #[rstest]
466    fn test_exec_client_py_new_uses_defaults_for_optional_fields() {
467        let account_id = AccountId::from("BINANCE-001");
468        let config = BinanceExecutionClientConfig::py_new(
469            account_id, None, None, None, None, None, true, None, None, None, true, true, None,
470            None, None, None, false, None, None, None, None, false, false, None, None, None, None,
471            None,
472        )
473        .unwrap();
474        let defaults = BinanceExecutionClientConfig::default();
475
476        assert_eq!(config.account_id, account_id);
477        assert_eq!(config.product_type, defaults.product_type);
478        assert_eq!(config.environment, defaults.environment);
479        assert_eq!(config.base_url_http, defaults.base_url_http);
480        assert_eq!(config.base_url_ws, defaults.base_url_ws);
481        assert_eq!(config.base_url_ws_trading, defaults.base_url_ws_trading);
482        assert!(config.use_ws_trading);
483        assert_eq!(config.ws_trading_setup_timeout_ms, 10_000);
484        assert_eq!(config.instrument_provider, defaults.instrument_provider);
485        assert_eq!(
486            config.instrument_refresh_interval_secs,
487            defaults.instrument_refresh_interval_secs
488        );
489        assert!(config.use_gtd);
490        assert_eq!(config.oms_type, defaults.oms_type);
491        assert_eq!(config.default_taker_fee, defaults.default_taker_fee);
492        assert_eq!(config.proxy_url, defaults.proxy_url);
493        assert_eq!(config.recv_window_ms, defaults.recv_window_ms);
494        assert!(!config.us);
495        assert_eq!(config.api_key, defaults.api_key);
496        assert_eq!(config.api_secret, defaults.api_secret);
497        assert_eq!(config.futures_leverages, defaults.futures_leverages);
498        assert_eq!(config.futures_margin_types, defaults.futures_margin_types);
499        assert_eq!(config.bnfcr_currency, defaults.bnfcr_currency);
500        assert_eq!(config.bnfcr_currency, Currency::USDT());
501        assert_eq!(
502            config.treat_expired_as_canceled,
503            defaults.treat_expired_as_canceled
504        );
505    }
506
507    #[rstest]
508    fn test_exec_client_py_new_preserves_explicit_overrides() {
509        use std::collections::HashMap;
510
511        use crate::common::enums::BinanceMarginType;
512
513        let leverages = HashMap::from([("BTCUSDT".to_string(), 20)]);
514        let margin_types = HashMap::from([("BTCUSDT".to_string(), BinanceMarginType::Cross)]);
515
516        let config = BinanceExecutionClientConfig::py_new(
517            AccountId::from("BINANCE-002"),
518            Some(BinanceProductType::UsdM),
519            Some(BinanceEnvironment::Demo),
520            Some("https://http.example".to_string()),
521            Some("wss://stream.example".to_string()),
522            Some("wss://trade.example".to_string()),
523            false,
524            Some(250),
525            None,
526            Some(45),
527            false,
528            false,
529            Some(OmsType::Hedging),
530            Some(0.0015),
531            Some("http://proxy.example:8080".to_string()),
532            Some(60_000),
533            false,
534            Some("api-key".to_string()),
535            Some("api-secret".to_string()),
536            Some(leverages.clone()),
537            Some(margin_types.clone()),
538            true,
539            true,
540            Some(Currency::USDC()),
541            None,
542            None,
543            None,
544            None,
545        )
546        .unwrap();
547
548        assert_eq!(config.product_type, BinanceProductType::UsdM);
549        assert_eq!(config.environment, BinanceEnvironment::Demo);
550        assert_eq!(
551            config.base_url_http.as_deref(),
552            Some("https://http.example")
553        );
554        assert_eq!(config.base_url_ws.as_deref(), Some("wss://stream.example"));
555        assert_eq!(
556            config.base_url_ws_trading.as_deref(),
557            Some("wss://trade.example")
558        );
559        assert!(!config.use_ws_trading);
560        assert_eq!(config.ws_trading_setup_timeout_ms, 250);
561        assert_eq!(config.instrument_refresh_interval_secs, 45);
562        assert!(!config.use_gtd);
563        assert!(!config.use_position_ids);
564        assert_eq!(config.oms_type, Some(OmsType::Hedging));
565        assert_eq!(config.default_taker_fee, Decimal::try_from(0.0015).unwrap());
566        assert_eq!(
567            config.proxy_url.as_ref().map(SecretString::expose_secret),
568            Some("http://proxy.example:8080")
569        );
570        assert_eq!(config.recv_window_ms, 60_000);
571        assert_eq!(
572            config.api_key.as_ref().map(SecretString::expose_secret),
573            Some("api-key"),
574        );
575        assert_eq!(
576            config.api_secret.as_ref().map(SecretString::expose_secret),
577            Some("api-secret"),
578        );
579        assert_eq!(config.futures_leverages, Some(leverages));
580        assert_eq!(config.futures_margin_types, Some(margin_types));
581        assert_eq!(config.bnfcr_currency, Currency::USDC());
582        assert!(config.treat_expired_as_canceled);
583        assert!(config.use_trade_lite);
584    }
585
586    #[rstest]
587    fn test_exec_client_py_new_uses_default_fee_for_invalid_float() {
588        let defaults = BinanceExecutionClientConfig::default();
589        let config = BinanceExecutionClientConfig::py_new(
590            AccountId::from("BINANCE-003"),
591            None,
592            None,
593            None,
594            None,
595            None,
596            true,
597            None,
598            None,
599            None,
600            true,
601            true,
602            None,
603            Some(f64::NAN),
604            None,
605            None,
606            false,
607            None,
608            None,
609            None,
610            None,
611            false,
612            false,
613            None,
614            None,
615            None,
616            None,
617            None,
618        )
619        .unwrap();
620
621        assert_eq!(config.default_taker_fee, defaults.default_taker_fee);
622    }
623
624    #[rstest]
625    fn test_instrument_provider_py_new_preserves_filters() {
626        Python::initialize();
627        Python::attach(|py| {
628            let symbols = vec!["BTCUSDT", "ETHUSDT"].into_pyobject(py).unwrap();
629            let filters = HashMap::from([("symbols".to_string(), symbols.into_any().unbind())]);
630
631            let config = BinanceInstrumentProviderConfig::py_new(
632                false,
633                Some(vec!["BTCUSDT.BINANCE".to_string()]),
634                Some(filters),
635                None,
636                false,
637                true,
638            )
639            .unwrap();
640
641            assert!(!config.load_all);
642            assert_eq!(config.load_ids, Some(vec!["BTCUSDT.BINANCE".to_string()]));
643            assert_eq!(
644                config.filters["symbols"],
645                serde_json::json!(["BTCUSDT", "ETHUSDT"])
646            );
647            assert!(!config.log_warnings);
648            assert!(config.query_commission_rates);
649        });
650    }
651
652    #[rstest]
653    #[case("load_ids")]
654    #[case("symbols")]
655    #[case("contract_types")]
656    fn test_python_provider_selection_uniqueness(
657        #[case] field: &str,
658        #[values(false, true)] duplicate: bool,
659    ) {
660        Python::initialize();
661        Python::attach(|py| {
662            let mut values = vec!["first", "second"];
663            if duplicate {
664                values.push("first");
665            }
666
667            let kwargs = PyDict::new(py);
668            if field == "load_ids" {
669                kwargs.set_item("load_ids", &values).unwrap();
670            } else {
671                let filters = PyDict::new(py);
672                filters.set_item(field, &values).unwrap();
673                kwargs.set_item("filters", filters).unwrap();
674            }
675
676            let result = py
677                .get_type::<BinanceInstrumentProviderConfig>()
678                .call((), Some(&kwargs));
679
680            if duplicate {
681                let error = result.unwrap_err();
682
683                let expected = if field == "load_ids" {
684                    "instrument_provider.load_ids".to_string()
685                } else {
686                    format!("instrument_provider.filters.{field}")
687                };
688
689                assert!(error.is_instance_of::<pyo3::exceptions::PyValueError>(py));
690                assert_eq!(
691                    error.value(py).str().unwrap().to_str().unwrap(),
692                    format!("duplicate {expected}")
693                );
694            } else {
695                let config = result.unwrap();
696                let config = config
697                    .extract::<PyRef<BinanceInstrumentProviderConfig>>()
698                    .unwrap();
699
700                if field == "load_ids" {
701                    assert_eq!(config.load_ids.as_ref().unwrap(), &values);
702                } else {
703                    assert_eq!(config.filters.get(field), Some(&serde_json::json!(values)));
704                }
705            }
706        });
707    }
708
709    #[rstest]
710    #[case("symbols", vec!["BTCUSDT", " btcusdt "])]
711    #[case("bases", vec!["BTC", " btc "])]
712    #[case("quotes", vec!["USDT", " usdt "])]
713    #[case("contract_types", vec!["PERPETUAL", " perpetual "])]
714    fn test_python_provider_rejects_normalized_duplicates(
715        #[case] field: &str,
716        #[case] values: Vec<&str>,
717    ) {
718        Python::initialize();
719        Python::attach(|py| {
720            let filters = PyDict::new(py);
721            filters.set_item(field, values).unwrap();
722            let kwargs = PyDict::new(py);
723            kwargs.set_item("filters", filters).unwrap();
724            let error = py
725                .get_type::<BinanceInstrumentProviderConfig>()
726                .call((), Some(&kwargs))
727                .unwrap_err();
728            assert!(error.is_instance_of::<pyo3::exceptions::PyValueError>(py));
729            assert_eq!(
730                error.value(py).str().unwrap().to_str().unwrap(),
731                format!("duplicate instrument_provider.filters.{field}")
732            );
733        });
734    }
735}