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