1use 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 #[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 #[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 #[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}