1use nautilus_common::python::config_error_to_pyvalue_err;
17use nautilus_core::{python::to_pyvalue_err, string::secret::SecretString};
18use nautilus_model::identifiers::{AccountId, InstrumentId};
19use nautilus_network::websocket::TransportBackend;
20use pyo3::{PyResult, pymethods};
21
22use crate::{
23 common::enums::{PolymarketSignatureType, PolymarketSignerType},
24 config::{
25 PolymarketDataClientConfig, PolymarketExecutionClientConfig,
26 PolymarketInstrumentProviderConfig, PolymarketUpDownEventSlugConfig,
27 },
28};
29
30const PY_OPTION_U64_MISSING_SENTINEL: u64 = u64::MAX;
31
32fn resolve_optional_u64_arg(value: Option<u64>, default: Option<u64>) -> Option<u64> {
33 match value {
34 Some(PY_OPTION_U64_MISSING_SENTINEL) => default,
35 other => other,
36 }
37}
38
39#[pymethods]
40#[pyo3_stub_gen::derive::gen_stub_pymethods]
41impl PolymarketUpDownEventSlugConfig {
42 #[new]
49 #[pyo3(signature = (assets=None, interval_mins=None, periods=None, start_offset_periods=None))]
50 fn py_new(
51 assets: Option<Vec<String>>,
52 interval_mins: Option<u64>,
53 periods: Option<u64>,
54 start_offset_periods: Option<i64>,
55 ) -> PyResult<Self> {
56 let default = Self::default();
57
58 let config = Self {
59 assets: assets.unwrap_or(default.assets),
60 interval_mins: interval_mins.unwrap_or(default.interval_mins),
61 periods: periods.unwrap_or(default.periods),
62 start_offset_periods: start_offset_periods.unwrap_or(default.start_offset_periods),
63 };
64
65 config.validate().map_err(config_error_to_pyvalue_err)?;
66 Ok(config)
67 }
68
69 fn __repr__(&self) -> String {
70 format!("{self:?}")
71 }
72
73 fn __str__(&self) -> String {
74 format!("{self:?}")
75 }
76}
77
78#[pymethods]
79#[pyo3_stub_gen::derive::gen_stub_pymethods]
80impl PolymarketInstrumentProviderConfig {
81 #[new]
86 #[pyo3(signature = (load_all=None, load_ids=None, filters=None, event_slugs=None, market_slugs=None, event_slug_builder=None, log_warnings=None, use_gamma_markets=None, series_ids=None))]
87 #[expect(clippy::too_many_arguments)]
88 fn py_new(
89 load_all: Option<bool>,
90 load_ids: Option<Vec<InstrumentId>>,
91 filters: Option<std::collections::HashMap<String, String>>,
92 event_slugs: Option<Vec<String>>,
93 market_slugs: Option<Vec<String>>,
94 event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
95 log_warnings: Option<bool>,
96 use_gamma_markets: Option<bool>,
97 series_ids: Option<Vec<u64>>,
98 ) -> PyResult<Self> {
99 let default = Self::default();
100 let config = Self {
101 load_all: load_all.unwrap_or(default.load_all),
102 load_ids,
103 filters,
104 event_slugs,
105 market_slugs,
106 event_slug_builder,
107 series_ids,
108 log_warnings: log_warnings.unwrap_or(default.log_warnings),
109 use_gamma_markets: use_gamma_markets.unwrap_or(default.use_gamma_markets),
110 };
111
112 config.validate().map_err(to_pyvalue_err)?;
113 Ok(config)
114 }
115
116 fn __repr__(&self) -> String {
117 format!("{self:?}")
118 }
119
120 fn __str__(&self) -> String {
121 format!("{self:?}")
122 }
123}
124
125#[pymethods]
126#[pyo3_stub_gen::derive::gen_stub_pymethods]
127impl PolymarketDataClientConfig {
128 #[new]
134 #[pyo3(signature = (instrument_config=None, base_url_http=None, base_url_ws=None, base_url_gamma=None, base_url_data_api=None, http_timeout_secs=None, ws_timeout_secs=None, ws_max_subscriptions=None, update_instruments_interval_mins=PY_OPTION_U64_MISSING_SENTINEL, subscribe_new_markets=None, auto_load_missing_instruments=None, auto_load_debounce_ms=None, auto_load_max_retries=None, auto_load_retry_delay_initial_secs=None, auto_load_retry_delay_max_secs=None, new_market_fetch_max_concurrency=None, resolve_poll_enabled=None, resolve_poll_interval_secs=None, resolve_poll_grace_secs=None, resolve_poll_max_wait_secs=None, base_url_reference_prices=None, transport_backend=None, drop_quotes_missing_side=None, proxy_url=None, compute_effective_deltas=None, book_snapshot_timeout_secs=None, book_stale_check_interval_secs=None, book_stale_threshold_secs=None, api_key=None, api_secret=None, passphrase=None))]
135 #[expect(clippy::too_many_arguments)]
136 fn py_new(
137 instrument_config: Option<PolymarketInstrumentProviderConfig>,
138 base_url_http: Option<String>,
139 base_url_ws: Option<String>,
140 base_url_gamma: Option<String>,
141 base_url_data_api: Option<String>,
142 http_timeout_secs: Option<u64>,
143 ws_timeout_secs: Option<u64>,
144 ws_max_subscriptions: Option<usize>,
145 update_instruments_interval_mins: Option<u64>,
146 subscribe_new_markets: Option<bool>,
147 auto_load_missing_instruments: Option<bool>,
148 auto_load_debounce_ms: Option<u64>,
149 auto_load_max_retries: Option<u32>,
150 auto_load_retry_delay_initial_secs: Option<f64>,
151 auto_load_retry_delay_max_secs: Option<f64>,
152 new_market_fetch_max_concurrency: Option<usize>,
153 resolve_poll_enabled: Option<bool>,
154 resolve_poll_interval_secs: Option<u64>,
155 resolve_poll_grace_secs: Option<u64>,
156 resolve_poll_max_wait_secs: Option<u64>,
157 base_url_reference_prices: Option<String>,
158 transport_backend: Option<TransportBackend>,
159 drop_quotes_missing_side: Option<bool>,
160 proxy_url: Option<String>,
161 compute_effective_deltas: Option<bool>,
162 book_snapshot_timeout_secs: Option<u64>,
163 book_stale_check_interval_secs: Option<u64>,
164 book_stale_threshold_secs: Option<u64>,
165 api_key: Option<String>,
166 api_secret: Option<String>,
167 passphrase: Option<String>,
168 ) -> PyResult<Self> {
169 let default = Self::default();
170
171 let config = Self {
172 instrument_config,
173 filters: Vec::new(),
174 base_url_http,
175 base_url_ws,
176 base_url_reference_prices,
177 base_url_gamma,
178 base_url_data_api,
179 api_key: api_key.map(SecretString::from),
180 api_secret: api_secret.map(SecretString::from),
181 passphrase: passphrase.map(SecretString::from),
182 proxy_url: proxy_url.map(SecretString::from),
183 http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
184 ws_timeout_secs: ws_timeout_secs.unwrap_or(default.ws_timeout_secs),
185 ws_max_subscriptions: ws_max_subscriptions.unwrap_or(default.ws_max_subscriptions),
186 update_instruments_interval_mins: resolve_optional_u64_arg(
187 update_instruments_interval_mins,
188 default.update_instruments_interval_mins,
189 ),
190 subscribe_new_markets: subscribe_new_markets.unwrap_or(default.subscribe_new_markets),
191 new_market_filter: None,
192 new_market_fetch_max_concurrency: new_market_fetch_max_concurrency
193 .unwrap_or(default.new_market_fetch_max_concurrency),
194 drop_quotes_missing_side: drop_quotes_missing_side
195 .unwrap_or(default.drop_quotes_missing_side),
196 auto_load_missing_instruments: auto_load_missing_instruments
197 .unwrap_or(default.auto_load_missing_instruments),
198 auto_load_debounce_ms: auto_load_debounce_ms.unwrap_or(default.auto_load_debounce_ms),
199 auto_load_max_retries: auto_load_max_retries.unwrap_or(default.auto_load_max_retries),
200 auto_load_retry_delay_initial_secs: auto_load_retry_delay_initial_secs
201 .unwrap_or(default.auto_load_retry_delay_initial_secs),
202 auto_load_retry_delay_max_secs: auto_load_retry_delay_max_secs
203 .unwrap_or(default.auto_load_retry_delay_max_secs),
204 resolve_poll_enabled: resolve_poll_enabled.unwrap_or(default.resolve_poll_enabled),
205 resolve_poll_interval_secs: resolve_poll_interval_secs
206 .unwrap_or(default.resolve_poll_interval_secs),
207 resolve_poll_grace_secs: resolve_poll_grace_secs
208 .unwrap_or(default.resolve_poll_grace_secs),
209 resolve_poll_max_wait_secs: resolve_poll_max_wait_secs
210 .unwrap_or(default.resolve_poll_max_wait_secs),
211 transport_backend: transport_backend.unwrap_or(default.transport_backend),
212 compute_effective_deltas: compute_effective_deltas
213 .unwrap_or(default.compute_effective_deltas),
214 book_snapshot_timeout_secs: book_snapshot_timeout_secs
215 .unwrap_or(default.book_snapshot_timeout_secs),
216 book_stale_check_interval_secs: book_stale_check_interval_secs
217 .unwrap_or(default.book_stale_check_interval_secs),
218 book_stale_threshold_secs: book_stale_threshold_secs
219 .unwrap_or(default.book_stale_threshold_secs),
220 };
221 config
222 .validated_proxy_url()
223 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
224 config.validate().map_err(to_pyvalue_err)?;
225 Ok(config)
226 }
227
228 #[getter]
229 #[pyo3(name = "has_proxy_url")]
230 const fn py_has_proxy_url(&self) -> bool {
231 self.has_proxy_url()
232 }
233
234 fn __repr__(&self) -> String {
235 format!("{self:?}")
236 }
237
238 fn __str__(&self) -> String {
239 format!("{self:?}")
240 }
241}
242
243#[pymethods]
244#[pyo3_stub_gen::derive::gen_stub_pymethods]
245impl PolymarketExecutionClientConfig {
246 #[new]
248 #[expect(clippy::too_many_arguments)]
249 #[pyo3(signature = (account_id=None, private_key=None, api_key=None, api_secret=None, passphrase=None, funder=None, signature_type=None, base_url_http=None, base_url_ws=None, base_url_data_api=None, http_timeout_secs=None, max_retries=None, retry_delay_initial_ms=None, retry_delay_max_ms=None, heartbeat_enabled=None, transport_backend=None, proxy_url=None, instrument_config=None, signer_type=None))]
250 fn py_new(
251 account_id: Option<String>,
252 private_key: Option<String>,
253 api_key: Option<String>,
254 api_secret: Option<String>,
255 passphrase: Option<String>,
256 funder: Option<String>,
257 signature_type: Option<PolymarketSignatureType>,
258 base_url_http: Option<String>,
259 base_url_ws: Option<String>,
260 base_url_data_api: Option<String>,
261 http_timeout_secs: Option<u64>,
262 max_retries: Option<u32>,
263 retry_delay_initial_ms: Option<u64>,
264 retry_delay_max_ms: Option<u64>,
265 heartbeat_enabled: Option<bool>,
266 transport_backend: Option<TransportBackend>,
267 proxy_url: Option<String>,
268 instrument_config: Option<PolymarketInstrumentProviderConfig>,
269 signer_type: Option<PolymarketSignerType>,
270 ) -> PyResult<Self> {
271 let default = Self::default();
272 let config = Self {
273 account_id: account_id.map_or(default.account_id, |s| AccountId::from(s.as_str())),
274 private_key: private_key.map(SecretString::from),
275 api_key: api_key.map(SecretString::from),
276 api_secret: api_secret.map(SecretString::from),
277 passphrase: passphrase.map(SecretString::from),
278 funder,
279 signature_type: signature_type.unwrap_or(default.signature_type),
280 signer_type: signer_type.unwrap_or_default(),
281 base_url_http,
282 base_url_ws,
283 base_url_data_api,
284 proxy_url: proxy_url.map(SecretString::from),
285 http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
286 max_retries: max_retries.unwrap_or(default.max_retries),
287 retry_delay_initial_ms: retry_delay_initial_ms
288 .unwrap_or(default.retry_delay_initial_ms),
289 retry_delay_max_ms: retry_delay_max_ms.unwrap_or(default.retry_delay_max_ms),
290 heartbeat_enabled: heartbeat_enabled.unwrap_or(default.heartbeat_enabled),
291 transport_backend: transport_backend.unwrap_or(default.transport_backend),
292 instrument_config,
293 };
294
295 config.validate().map_err(to_pyvalue_err)?;
296 config.validate_signer().map_err(to_pyvalue_err)?;
297 config
298 .validated_proxy_url()
299 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
300 Ok(config)
301 }
302
303 #[getter]
304 #[pyo3(name = "has_proxy_url")]
305 const fn py_has_proxy_url(&self) -> bool {
306 self.has_proxy_url()
307 }
308
309 fn __repr__(&self) -> String {
310 format!("{self:?}")
311 }
312
313 fn __str__(&self) -> String {
314 format!("{self:?}")
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use pyo3::{
321 Bound, IntoPyObject, Python,
322 types::{PyAnyMethods, PyDict, PyDictMethods, PyStringMethods, PyTuple},
323 };
324 use rstest::rstest;
325
326 use super::*;
327
328 fn construct_data_client_config(
329 py: Python<'_>,
330 args: Option<&Bound<'_, PyTuple>>,
331 kwargs: Option<&Bound<'_, PyDict>>,
332 ) -> PolymarketDataClientConfig {
333 let cls = py.get_type::<PolymarketDataClientConfig>();
334
335 let config = match args {
336 Some(args) => cls.call(args, kwargs),
337 None => cls.call((), kwargs),
338 }
339 .expect("construct PolymarketDataClientConfig");
340
341 config
342 .extract::<PolymarketDataClientConfig>()
343 .expect("extract PolymarketDataClientConfig")
344 }
345
346 #[rstest]
347 fn direct_pyo3_constructor_preserves_none_update_interval() {
348 Python::initialize();
349 Python::attach(|py| {
350 let kwargs = PyDict::new(py);
351 kwargs
352 .set_item("update_instruments_interval_mins", py.None())
353 .unwrap();
354
355 let config = construct_data_client_config(py, None, Some(&kwargs));
356
357 assert_eq!(config.update_instruments_interval_mins, None);
358 });
359 }
360
361 #[rstest]
362 fn direct_pyo3_constructor_uses_default_update_interval_when_omitted() {
363 Python::initialize();
364 Python::attach(|py| {
365 let config = construct_data_client_config(py, None, None);
366
367 assert_eq!(
368 config.update_instruments_interval_mins,
369 PolymarketDataClientConfig::default().update_instruments_interval_mins,
370 );
371 assert!(config.drop_quotes_missing_side);
372 });
373 }
374
375 #[rstest]
376 fn direct_pyo3_constructor_preserves_none_update_interval_for_positional_args() {
377 Python::initialize();
378 Python::attach(|py| {
379 let args = PyTuple::new(
380 py,
381 [
382 py.None(),
383 py.None(),
384 py.None(),
385 py.None(),
386 py.None(),
387 py.None(),
388 py.None(),
389 py.None(),
390 py.None(),
391 py.None(),
392 ],
393 )
394 .expect("args");
395
396 let config = construct_data_client_config(py, Some(&args), None);
397
398 assert_eq!(config.update_instruments_interval_mins, None);
399 });
400 }
401
402 #[rstest]
403 fn direct_pyo3_constructor_keeps_positional_slots_ahead_of_credentials() {
404 Python::initialize();
405 Python::attach(|py| {
406 let mut args: Vec<pyo3::Bound<'_, pyo3::PyAny>> =
410 (0..22).map(|_| py.None().into_bound(py)).collect();
411 args.push(pyo3::types::PyBool::new(py, false).to_owned().into_any());
412 let args = PyTuple::new(py, args).expect("args");
413
414 let config = construct_data_client_config(py, Some(&args), None);
415
416 assert!(!config.drop_quotes_missing_side);
417 assert!(config.api_key.is_none());
418 assert!(config.api_secret.is_none());
419 assert!(config.passphrase.is_none());
420 });
421 }
422
423 #[rstest]
424 fn direct_pyo3_constructor_sets_new_market_fetch_max_concurrency() {
425 Python::initialize();
426 Python::attach(|py| {
427 let kwargs = PyDict::new(py);
428 kwargs
429 .set_item("new_market_fetch_max_concurrency", 23)
430 .unwrap();
431
432 let config = construct_data_client_config(py, None, Some(&kwargs));
433
434 assert_eq!(config.new_market_fetch_max_concurrency, 23);
435 });
436 }
437
438 #[rstest]
439 fn direct_pyo3_constructor_sets_drop_quotes_missing_side() {
440 Python::initialize();
441 Python::attach(|py| {
442 let kwargs = PyDict::new(py);
443 kwargs.set_item("drop_quotes_missing_side", false).unwrap();
444
445 let config = construct_data_client_config(py, None, Some(&kwargs));
446
447 assert!(!config.drop_quotes_missing_side);
448 });
449 }
450
451 #[rstest]
452 fn direct_pyo3_constructor_sets_compute_effective_deltas() {
453 Python::initialize();
454 Python::attach(|py| {
455 let kwargs = PyDict::new(py);
456 kwargs.set_item("compute_effective_deltas", true).unwrap();
457
458 let config = construct_data_client_config(py, None, Some(&kwargs));
459
460 assert!(config.compute_effective_deltas);
461 });
462 }
463
464 #[rstest]
465 fn direct_pyo3_constructor_sets_base_url_reference_prices() {
466 Python::initialize();
467 Python::attach(|py| {
468 let kwargs = PyDict::new(py);
469 kwargs
470 .set_item("base_url_reference_prices", "wss://ws-live-data.example")
471 .unwrap();
472
473 let config = construct_data_client_config(py, None, Some(&kwargs));
474
475 assert_eq!(
476 config.base_url_reference_prices.as_deref(),
477 Some("wss://ws-live-data.example")
478 );
479 });
480 }
481
482 #[rstest]
483 fn direct_pyo3_constructor_preserves_existing_positional_order() {
484 Python::initialize();
485 Python::attach(|py| {
486 let args = PyTuple::new(
487 py,
488 [
489 py.None(),
490 "https://http.example"
491 .into_pyobject(py)
492 .unwrap()
493 .into_any()
494 .unbind(),
495 "wss://ws.example"
496 .into_pyobject(py)
497 .unwrap()
498 .into_any()
499 .unbind(),
500 "https://gamma.example"
501 .into_pyobject(py)
502 .unwrap()
503 .into_any()
504 .unbind(),
505 "https://data.example"
506 .into_pyobject(py)
507 .unwrap()
508 .into_any()
509 .unbind(),
510 41_u64.into_pyobject(py).unwrap().into_any().unbind(),
511 42_u64.into_pyobject(py).unwrap().into_any().unbind(),
512 512_usize.into_pyobject(py).unwrap().into_any().unbind(),
513 ],
514 )
515 .expect("args");
516
517 let config = construct_data_client_config(py, Some(&args), None);
518
519 assert_eq!(
520 config.base_url_http.as_deref(),
521 Some("https://http.example")
522 );
523 assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
524 assert_eq!(
525 config.base_url_gamma.as_deref(),
526 Some("https://gamma.example")
527 );
528 assert_eq!(
529 config.base_url_data_api.as_deref(),
530 Some("https://data.example")
531 );
532 assert_eq!(config.base_url_reference_prices, None);
533 assert_eq!(config.http_timeout_secs, 41);
534 assert_eq!(config.ws_timeout_secs, 42);
535 assert_eq!(config.ws_max_subscriptions, 512);
536 });
537 }
538
539 #[rstest]
540 fn direct_pyo3_constructor_preserves_base_url_reference_prices_positional_slot() {
541 Python::initialize();
542 Python::attach(|py| {
543 let args = PyTuple::new(
544 py,
545 [
546 py.None(),
547 py.None(),
548 py.None(),
549 py.None(),
550 py.None(),
551 py.None(),
552 py.None(),
553 py.None(),
554 py.None(),
555 py.None(),
556 py.None(),
557 py.None(),
558 py.None(),
559 py.None(),
560 py.None(),
561 py.None(),
562 py.None(),
563 py.None(),
564 py.None(),
565 py.None(),
566 "wss://ws-live-data.example"
567 .into_pyobject(py)
568 .unwrap()
569 .into_any()
570 .unbind(),
571 ],
572 )
573 .expect("args");
574
575 let config = construct_data_client_config(py, Some(&args), None);
576
577 assert_eq!(
578 config.base_url_reference_prices.as_deref(),
579 Some("wss://ws-live-data.example")
580 );
581 });
582 }
583
584 #[rstest]
585 fn direct_pyo3_data_config_propagates_proxy_without_raw_getter() {
586 const SECRET: &str = "data-python-proxy-secret";
587 Python::initialize();
588 Python::attach(|py| {
589 let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18083");
590 let kwargs = PyDict::new(py);
591 kwargs.set_item("proxy_url", &proxy_url).unwrap();
592 let cls = py.get_type::<PolymarketDataClientConfig>();
593 let obj = cls.call((), Some(&kwargs)).expect("construct data config");
594 let has_proxy_url = obj
595 .getattr("has_proxy_url")
596 .expect("has_proxy_url getter")
597 .extract::<bool>()
598 .expect("bool getter");
599 let repr = obj.repr().expect("data config repr").to_string();
600 let config = obj
601 .extract::<PolymarketDataClientConfig>()
602 .expect("extract data config");
603
604 assert_eq!(
605 config.proxy_url.as_ref().map(SecretString::expose_secret),
606 Some(proxy_url.as_str()),
607 );
608 assert!(has_proxy_url);
609 assert!(!obj.hasattr("proxy_url").unwrap());
610 assert!(!repr.contains(SECRET));
611 });
612 }
613
614 #[rstest]
615 fn direct_pyo3_exec_config_propagates_proxy_without_raw_getter() {
616 const SECRET: &str = "exec-python-proxy-secret";
617 Python::initialize();
618 Python::attach(|py| {
619 let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18084");
620 let kwargs = PyDict::new(py);
621 kwargs.set_item("proxy_url", &proxy_url).unwrap();
622 kwargs.set_item("heartbeat_enabled", true).unwrap();
623 let cls = py.get_type::<PolymarketExecutionClientConfig>();
624 let obj = cls
625 .call((), Some(&kwargs))
626 .expect("construct execution config");
627 let has_proxy_url = obj
628 .getattr("has_proxy_url")
629 .expect("has_proxy_url getter")
630 .extract::<bool>()
631 .expect("bool getter");
632 let repr = obj.repr().expect("execution config repr").to_string();
633 let heartbeat_enabled = obj
634 .getattr("heartbeat_enabled")
635 .expect("heartbeat_enabled getter")
636 .extract::<bool>()
637 .expect("bool getter");
638 let config = obj
639 .extract::<PolymarketExecutionClientConfig>()
640 .expect("extract execution config");
641
642 assert_eq!(
643 config.proxy_url.as_ref().map(SecretString::expose_secret),
644 Some(proxy_url.as_str()),
645 );
646 assert!(config.heartbeat_enabled);
647 assert!(has_proxy_url);
648 assert!(heartbeat_enabled);
649 assert!(!obj.hasattr("proxy_url").unwrap());
650 assert!(!repr.contains(SECRET));
651 });
652 }
653
654 #[rstest]
655 fn direct_pyo3_exec_config_wires_instrument_config_load_ids() {
656 Python::initialize();
657 Python::attach(|py| {
658 let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
659 let provider_kwargs = PyDict::new(py);
660 provider_kwargs.set_item("load_ids", vec![scoped]).unwrap();
661 let provider = py
662 .get_type::<PolymarketInstrumentProviderConfig>()
663 .call((), Some(&provider_kwargs))
664 .expect("construct provider config");
665 let kwargs = PyDict::new(py);
666 kwargs.set_item("instrument_config", &provider).unwrap();
667 let obj = py
668 .get_type::<PolymarketExecutionClientConfig>()
669 .call((), Some(&kwargs))
670 .expect("construct execution config");
671 let exposed = obj
672 .getattr("instrument_config")
673 .expect("instrument_config getter")
674 .extract::<PolymarketInstrumentProviderConfig>()
675 .expect("extract provider config");
676 let config = obj
677 .extract::<PolymarketExecutionClientConfig>()
678 .expect("extract execution config");
679
680 assert_eq!(exposed.load_ids.as_deref(), Some([scoped].as_slice()));
681 assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
682 });
683 }
684
685 #[rstest]
686 fn direct_pyo3_proxy_validation_error_redacts_credentials() {
687 const SECRET: &str = "invalid-python-proxy-secret";
688 Python::initialize();
689 Python::attach(|py| {
690 let kwargs = PyDict::new(py);
691 kwargs
692 .set_item("proxy_url", format!("http://proxy-user:{SECRET}@[::1"))
693 .unwrap();
694 let error = py
695 .get_type::<PolymarketDataClientConfig>()
696 .call((), Some(&kwargs))
697 .expect_err("malformed proxy URL should fail");
698 let message = error.to_string();
699
700 assert!(message.contains("Invalid Polymarket proxy URL"));
701 assert!(!message.contains(SECRET));
702 });
703 }
704
705 #[rstest]
706 #[case::provider("provider")]
707 #[case::data("data")]
708 #[case::execution("execution")]
709 fn test_python_config_rejects_nested_or_filter_duplicates(#[case] kind: &str) {
710 Python::initialize();
711 Python::attach(|py| {
712 let kwargs = PyDict::new(py);
713
714 let cls = match kind {
715 "provider" => {
716 kwargs
717 .set_item(
718 "filters",
719 std::collections::HashMap::from([("slug", "one,two,one")]),
720 )
721 .unwrap();
722 py.get_type::<PolymarketInstrumentProviderConfig>()
723 }
724 _ => {
725 let provider = PolymarketInstrumentProviderConfig {
726 series_ids: Some(vec![1, 2, 1]),
727 ..Default::default()
728 };
729
730 kwargs
731 .set_item("instrument_config", pyo3::Py::new(py, provider).unwrap())
732 .unwrap();
733
734 if kind == "data" {
735 py.get_type::<PolymarketDataClientConfig>()
736 } else {
737 py.get_type::<PolymarketExecutionClientConfig>()
738 }
739 }
740 };
741
742 let error = cls.call((), Some(&kwargs)).unwrap_err();
743
744 let field = if kind == "provider" {
745 "filters.slug"
746 } else {
747 "series_ids"
748 };
749
750 assert!(error.is_instance_of::<pyo3::exceptions::PyValueError>(py));
751 assert_eq!(
752 error.value(py).str().unwrap().to_str().unwrap(),
753 format!("duplicate {field}")
754 );
755 });
756 }
757}