1#![expect(
22 clippy::missing_errors_doc,
23 reason = "errors documented on underlying Rust methods"
24)]
25
26pub mod config;
27pub mod factories;
28pub mod loader;
29pub mod positions;
30pub mod session;
31pub mod sort;
32
33use nautilus_common::factories::{ClientConfig, DataClientFactory, ExecutionClientFactory};
34use nautilus_core::{
35 python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err},
36 string::secret::SecretString,
37};
38use nautilus_execution::{models::fee::FeeModel, python::fee::PyFeeModel};
39use nautilus_model::{
40 data::ensure_rust_extractor_registered,
41 identifiers::InstrumentId,
42 python::{instruments::pyobject_to_instrument_any, orders::pyobject_to_order_any},
43 types::{Money, Price, Quantity},
44};
45use nautilus_network::websocket::TransportBackend;
46use nautilus_system::get_global_pyo3_registry;
47use pyo3::{prelude::*, types::PyDict};
48
49use crate::{
50 common::consts::{POLYMARKET, POLYMARKET_CLIENT_ID, POLYMARKET_VENUE},
51 config::{
52 PolymarketDataClientConfig, PolymarketExecutionClientConfig,
53 PolymarketInstrumentProviderConfig, PolymarketUpDownEventSlugConfig,
54 },
55 data_types::{
56 PolymarketRtdsCryptoPrice, PolymarketRtdsCryptoTwap, PolymarketRtdsEquityPrice,
57 register_polymarket_custom_data,
58 },
59 factories::{PolymarketDataClientFactory, PolymarketExecutionClientFactory},
60 models::PolymarketFeeModel,
61 providers::build_gamma_params_from_hashmap,
62};
63
64#[pyo3_stub_gen::derive::gen_stub_pymethods]
65#[pymethods]
66impl PolymarketFeeModel {
67 #[new]
73 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
74 fn py_new() -> PyClassInitializer<Self> {
75 PyClassInitializer::from(PyFeeModel).add_subclass(Self)
76 }
77
78 fn __repr__(&self) -> String {
79 format!("{self:?}")
80 }
81
82 fn get_commission(
83 &self,
84 order: &Bound<'_, PyAny>,
85 fill_quantity: Quantity,
86 fill_px: Price,
87 instrument: &Bound<'_, PyAny>,
88 ) -> PyResult<Money> {
89 let py = order.py();
90 let instrument =
91 pyobject_to_instrument_any(py, instrument.clone().unbind()).map_err(|_| {
92 let type_name = instrument
93 .get_type()
94 .name()
95 .map_or_else(|_| "unknown".to_string(), |name| name.to_string());
96 to_pytype_err(format!(
97 "`instrument` must be an `Instrument`, was `{type_name}`"
98 ))
99 })?;
100 let order = pyobject_to_order_any(py, order.clone().unbind()).map_err(|_| {
101 let type_name = order
102 .get_type()
103 .name()
104 .map_or_else(|_| "unknown".to_string(), |name| name.to_string());
105 to_pytype_err(format!("`order` must be an `Order`, was `{type_name}`"))
106 })?;
107
108 FeeModel::get_commission(self, &order, fill_quantity, fill_px, &instrument)
109 .map_err(to_pyruntime_err)
110 }
111}
112
113fn getattr_optional<'py>(
114 obj: &Bound<'py, PyAny>,
115 name: &str,
116) -> PyResult<Option<Bound<'py, PyAny>>> {
117 if !obj.hasattr(name)? {
118 return Ok(None);
119 }
120
121 let value = obj.getattr(name)?;
122 if value.is_none() {
123 Ok(None)
124 } else {
125 Ok(Some(value))
126 }
127}
128
129fn getattr_optional_option_u64(
130 obj: &Bound<'_, PyAny>,
131 name: &str,
132 default: Option<u64>,
133) -> PyResult<Option<u64>> {
134 if !obj.hasattr(name)? {
135 return Ok(default);
136 }
137
138 let value = obj.getattr(name)?;
139 if value.is_none() {
140 Ok(None)
141 } else {
142 value.extract::<u64>().map(Some)
143 }
144}
145
146fn py_scalar_to_string(value: &Bound<'_, PyAny>) -> PyResult<String> {
147 if let Ok(v) = value.extract::<bool>() {
148 return Ok(v.to_string().to_lowercase());
149 }
150
151 if let Ok(v) = value.extract::<i64>() {
152 return Ok(v.to_string());
153 }
154
155 if let Ok(v) = value.extract::<u64>() {
156 return Ok(v.to_string());
157 }
158
159 if let Ok(v) = value.extract::<f64>() {
160 if !v.is_finite() {
161 return Err(to_pyvalue_err("Gamma filter values must be finite"));
162 }
163 return Ok(v.to_string());
164 }
165
166 if let Ok(v) = value.extract::<String>() {
167 return Ok(v);
168 }
169
170 Err(to_pyvalue_err(
171 "Gamma filter values must be bool, int, float, string, or a list of those values",
172 ))
173}
174
175fn py_filter_value_to_string(value: &Bound<'_, PyAny>) -> PyResult<String> {
176 if value.extract::<f64>().is_ok_and(|value| !value.is_finite()) {
177 return Err(to_pyvalue_err("Gamma filter values must be finite"));
178 }
179
180 if let Ok(value) = py_scalar_to_string(value) {
181 return Ok(value);
182 }
183
184 if value.cast::<PyDict>().is_ok() {
185 return Err(to_pyvalue_err("Gamma filter values cannot be dictionaries"));
186 }
187
188 if let Ok(iter) = value.try_iter() {
189 let values = iter
190 .map(|item| py_scalar_to_string(&item?))
191 .collect::<PyResult<Vec<_>>>()?;
192
193 if values.is_empty() {
194 return Err(to_pyvalue_err("Gamma filter lists cannot be empty"));
195 }
196 return Ok(values.join(","));
197 }
198
199 Err(to_pyvalue_err(
200 "Gamma filter values must be bool, int, float, string, or a list of those values",
201 ))
202}
203
204pub(super) fn extract_string_map(
205 value: &Bound<'_, PyAny>,
206) -> PyResult<std::collections::HashMap<String, String>> {
207 let dict = value.cast::<PyDict>()?;
208 let mut map = std::collections::HashMap::with_capacity(dict.len());
209 for (key, value) in dict.iter() {
210 if value.is_none() {
211 continue;
212 }
213 map.insert(key.extract::<String>()?, py_filter_value_to_string(&value)?);
214 }
215 Ok(map)
216}
217
218fn validate_provider_config(config: &PolymarketInstrumentProviderConfig) -> PyResult<()> {
219 if let Some(filters) = config.filters.as_ref() {
220 build_gamma_params_from_hashmap(filters)
221 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket Gamma filters: {e}")))?;
222 }
223 Ok(())
224}
225
226fn validate_data_config(config: &PolymarketDataClientConfig) -> PyResult<()> {
227 if let Some(instrument_config) = config.instrument_config.as_ref() {
228 validate_provider_config(instrument_config)?;
229 }
230 config
231 .validated_proxy_url()
232 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
233 Ok(())
234}
235
236fn extract_event_slug_builder(
237 value: &Bound<'_, PyAny>,
238) -> PyResult<PolymarketUpDownEventSlugConfig> {
239 if let Ok(builder) = value.extract::<PolymarketUpDownEventSlugConfig>() {
240 return Ok(builder);
241 }
242
243 if value.extract::<String>().is_ok() {
244 return Err(to_pyvalue_err(
245 "Python callable event_slug_builder is not supported by the Rust Polymarket adapter; \
246 pass event_slugs, market_slugs, or PolymarketUpDownEventSlugConfig",
247 ));
248 }
249
250 Err(to_pyvalue_err(
251 "event_slug_builder must be PolymarketUpDownEventSlugConfig",
252 ))
253}
254
255fn extract_provider_config_from_pyobject(
256 obj: &Bound<'_, PyAny>,
257) -> PyResult<PolymarketInstrumentProviderConfig> {
258 if let Ok(config) = obj.extract::<PolymarketInstrumentProviderConfig>() {
259 validate_provider_config(&config)?;
260 return Ok(config);
261 }
262
263 let default = PolymarketInstrumentProviderConfig::default();
264 let load_all = getattr_optional(obj, "load_all")?
265 .map(|value| value.extract::<bool>())
266 .transpose()?
267 .unwrap_or(default.load_all);
268 let load_ids = getattr_optional(obj, "load_ids")?
269 .map(|value| value.extract::<Vec<InstrumentId>>())
270 .transpose()?;
271 let filters = getattr_optional(obj, "filters")?
272 .map(|value| extract_string_map(&value))
273 .transpose()?;
274 let event_slugs = getattr_optional(obj, "event_slugs")?
275 .map(|value| value.extract::<Vec<String>>())
276 .transpose()?;
277 let market_slugs = getattr_optional(obj, "market_slugs")?
278 .map(|value| value.extract::<Vec<String>>())
279 .transpose()?;
280 let event_slug_builder = getattr_optional(obj, "event_slug_builder")?
281 .map(|value| extract_event_slug_builder(&value))
282 .transpose()?;
283 let series_ids = getattr_optional(obj, "series_ids")?
284 .map(|value| value.extract::<Vec<u64>>())
285 .transpose()?;
286 let log_warnings = getattr_optional(obj, "log_warnings")?
287 .map(|value| value.extract::<bool>())
288 .transpose()?
289 .unwrap_or(default.log_warnings);
290 let use_gamma_markets = getattr_optional(obj, "use_gamma_markets")?
291 .map(|value| value.extract::<bool>())
292 .transpose()?
293 .unwrap_or(default.use_gamma_markets);
294
295 let config = PolymarketInstrumentProviderConfig {
296 load_all: load_all || event_slug_builder.is_some(),
297 load_ids,
298 filters,
299 event_slugs,
300 market_slugs,
301 event_slug_builder,
302 series_ids,
303 log_warnings,
304 use_gamma_markets,
305 };
306 validate_provider_config(&config)?;
307 Ok(config)
308}
309
310fn extract_data_config_from_pyobject(
311 py: Python<'_>,
312 config: &Py<PyAny>,
313) -> PyResult<PolymarketDataClientConfig> {
314 if let Ok(config) = config.extract::<PolymarketDataClientConfig>(py) {
315 validate_data_config(&config)?;
316 return Ok(config);
317 }
318
319 let obj = config.bind(py);
320 let default = PolymarketDataClientConfig::default();
321 let instrument_config = getattr_optional(obj, "instrument_config")?
322 .map(|value| extract_provider_config_from_pyobject(&value))
323 .transpose()?;
324 let base_url_http = getattr_optional(obj, "base_url_http")?
325 .map(|value| value.extract::<String>())
326 .transpose()?;
327 let base_url_ws = getattr_optional(obj, "base_url_ws")?
328 .map(|value| value.extract::<String>())
329 .transpose()?;
330 let base_url_rtds = getattr_optional(obj, "base_url_rtds")?
331 .map(|value| value.extract::<String>())
332 .transpose()?;
333 let base_url_gamma = getattr_optional(obj, "base_url_gamma")?
334 .map(|value| value.extract::<String>())
335 .transpose()?;
336 let base_url_data_api = getattr_optional(obj, "base_url_data_api")?
337 .map(|value| value.extract::<String>())
338 .transpose()?;
339 let proxy_url = getattr_optional(obj, "proxy_url")?
340 .map(|value| value.extract::<String>())
341 .transpose()?
342 .map(SecretString::from);
343 let http_timeout_secs = getattr_optional(obj, "http_timeout_secs")?
344 .map(|value| value.extract::<u64>())
345 .transpose()?
346 .unwrap_or(default.http_timeout_secs);
347 let ws_timeout_secs = getattr_optional(obj, "ws_timeout_secs")?
348 .map(|value| value.extract::<u64>())
349 .transpose()?
350 .unwrap_or(default.ws_timeout_secs);
351 let ws_max_subscriptions = getattr_optional(obj, "ws_max_subscriptions")?
352 .map(|value| value.extract::<usize>())
353 .transpose()?
354 .unwrap_or(default.ws_max_subscriptions);
355 let update_instruments_interval_mins = getattr_optional_option_u64(
356 obj,
357 "update_instruments_interval_mins",
358 default.update_instruments_interval_mins,
359 )?;
360 let subscribe_new_markets = getattr_optional(obj, "subscribe_new_markets")?
361 .map(|value| value.extract::<bool>())
362 .transpose()?
363 .unwrap_or(default.subscribe_new_markets);
364 let new_market_fetch_max_concurrency =
365 getattr_optional(obj, "new_market_fetch_max_concurrency")?
366 .map(|value| value.extract::<usize>())
367 .transpose()?
368 .unwrap_or(default.new_market_fetch_max_concurrency);
369 let drop_quotes_missing_side = getattr_optional(obj, "drop_quotes_missing_side")?
370 .map(|value| value.extract::<bool>())
371 .transpose()?
372 .unwrap_or(default.drop_quotes_missing_side);
373 let compute_effective_deltas = getattr_optional(obj, "compute_effective_deltas")?
374 .map(|value| value.extract::<bool>())
375 .transpose()?
376 .unwrap_or(default.compute_effective_deltas);
377 let auto_load_missing_instruments = getattr_optional(obj, "auto_load_missing_instruments")?
378 .map(|value| value.extract::<bool>())
379 .transpose()?
380 .unwrap_or(default.auto_load_missing_instruments);
381 let auto_load_debounce_ms = getattr_optional(obj, "auto_load_debounce_ms")?
382 .map(|value| value.extract::<u64>())
383 .transpose()?
384 .unwrap_or(default.auto_load_debounce_ms);
385 let auto_load_max_retries = getattr_optional(obj, "auto_load_max_retries")?
386 .map(|value| value.extract::<u32>())
387 .transpose()?
388 .unwrap_or(default.auto_load_max_retries);
389 let auto_load_retry_delay_initial_secs =
390 getattr_optional(obj, "auto_load_retry_delay_initial_secs")?
391 .map(|value| value.extract::<f64>())
392 .transpose()?
393 .unwrap_or(default.auto_load_retry_delay_initial_secs);
394 let auto_load_retry_delay_max_secs = getattr_optional(obj, "auto_load_retry_delay_max_secs")?
395 .map(|value| value.extract::<f64>())
396 .transpose()?
397 .unwrap_or(default.auto_load_retry_delay_max_secs);
398 let resolve_poll_enabled = getattr_optional(obj, "resolve_poll_enabled")?
399 .map(|value| value.extract::<bool>())
400 .transpose()?
401 .unwrap_or(default.resolve_poll_enabled);
402 let resolve_poll_interval_secs = getattr_optional(obj, "resolve_poll_interval_secs")?
403 .map(|value| value.extract::<u64>())
404 .transpose()?
405 .unwrap_or(default.resolve_poll_interval_secs);
406 let resolve_poll_grace_secs = getattr_optional(obj, "resolve_poll_grace_secs")?
407 .map(|value| value.extract::<u64>())
408 .transpose()?
409 .unwrap_or(default.resolve_poll_grace_secs);
410 let resolve_poll_max_wait_secs = getattr_optional(obj, "resolve_poll_max_wait_secs")?
411 .map(|value| value.extract::<u64>())
412 .transpose()?
413 .unwrap_or(default.resolve_poll_max_wait_secs);
414 let transport_backend = match getattr_optional(obj, "transport_backend")? {
415 Some(value) => value.extract::<TransportBackend>()?,
416 None => default.transport_backend,
417 };
418
419 let book_snapshot_timeout_secs = getattr_optional(obj, "book_snapshot_timeout_secs")?
420 .map(|value| value.extract::<u64>())
421 .transpose()?
422 .unwrap_or(default.book_snapshot_timeout_secs);
423 let book_stale_check_interval_secs = getattr_optional(obj, "book_stale_check_interval_secs")?
424 .map(|value| value.extract::<u64>())
425 .transpose()?
426 .unwrap_or(default.book_stale_check_interval_secs);
427 let book_stale_threshold_secs = getattr_optional(obj, "book_stale_threshold_secs")?
428 .map(|value| value.extract::<u64>())
429 .transpose()?
430 .unwrap_or(default.book_stale_threshold_secs);
431
432 let config = PolymarketDataClientConfig {
433 instrument_config,
434 filters: Vec::new(),
435 base_url_http,
436 base_url_ws,
437 base_url_rtds,
438 base_url_gamma,
439 base_url_data_api,
440 proxy_url,
441 http_timeout_secs,
442 ws_timeout_secs,
443 ws_max_subscriptions,
444 update_instruments_interval_mins,
445 subscribe_new_markets,
446 new_market_filter: None,
447 new_market_fetch_max_concurrency,
448 drop_quotes_missing_side,
449 compute_effective_deltas,
450 auto_load_missing_instruments,
451 auto_load_debounce_ms,
452 auto_load_max_retries,
453 auto_load_retry_delay_initial_secs,
454 auto_load_retry_delay_max_secs,
455 resolve_poll_enabled,
456 resolve_poll_interval_secs,
457 resolve_poll_grace_secs,
458 resolve_poll_max_wait_secs,
459 transport_backend,
460 book_snapshot_timeout_secs,
461 book_stale_check_interval_secs,
462 book_stale_threshold_secs,
463 };
464 validate_data_config(&config)?;
465 Ok(config)
466}
467
468#[expect(clippy::needless_pass_by_value)]
469fn extract_polymarket_data_factory(
470 py: Python<'_>,
471 factory: Py<PyAny>,
472) -> PyResult<Box<dyn DataClientFactory>> {
473 match factory.extract::<PolymarketDataClientFactory>(py) {
474 Ok(f) => Ok(Box::new(f)),
475 Err(e) => Err(to_pyvalue_err(format!(
476 "Failed to extract PolymarketDataClientFactory: {e}"
477 ))),
478 }
479}
480
481#[expect(clippy::needless_pass_by_value)]
482fn extract_polymarket_exec_factory(
483 py: Python<'_>,
484 factory: Py<PyAny>,
485) -> PyResult<Box<dyn ExecutionClientFactory>> {
486 match factory.extract::<PolymarketExecutionClientFactory>(py) {
487 Ok(f) => Ok(Box::new(f)),
488 Err(e) => Err(to_pyvalue_err(format!(
489 "Failed to extract PolymarketExecutionClientFactory: {e}"
490 ))),
491 }
492}
493
494#[expect(clippy::needless_pass_by_value)]
495fn extract_polymarket_data_config(
496 py: Python<'_>,
497 config: Py<PyAny>,
498) -> PyResult<Box<dyn ClientConfig>> {
499 match extract_data_config_from_pyobject(py, &config) {
500 Ok(c) => Ok(Box::new(c)),
501 Err(e) => Err(to_pyvalue_err(format!(
502 "Failed to extract PolymarketDataClientConfig: {e}"
503 ))),
504 }
505}
506
507#[expect(clippy::needless_pass_by_value)]
508fn extract_polymarket_exec_config(
509 py: Python<'_>,
510 config: Py<PyAny>,
511) -> PyResult<Box<dyn ClientConfig>> {
512 match config.extract::<PolymarketExecutionClientConfig>(py) {
513 Ok(c) => {
514 c.validated_proxy_url()
515 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
516 Ok(Box::new(c))
517 }
518 Err(e) => Err(to_pyvalue_err(format!(
519 "Failed to extract PolymarketExecutionClientConfig: {e}"
520 ))),
521 }
522}
523
524#[pymodule]
526pub fn polymarket(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
527 m.add(stringify!(POLYMARKET), POLYMARKET)?;
528 m.add(stringify!(POLYMARKET_CLIENT_ID), *POLYMARKET_CLIENT_ID)?;
529 m.add(stringify!(POLYMARKET_VENUE), *POLYMARKET_VENUE)?;
530 m.add_class::<crate::common::enums::PolymarketSignatureType>()?;
531 m.add_class::<crate::common::enums::PolymarketSignerType>()?;
532 m.add_class::<PolymarketUpDownEventSlugConfig>()?;
533 m.add_class::<PolymarketInstrumentProviderConfig>()?;
534 m.add_class::<PolymarketDataClientConfig>()?;
535 m.add_class::<PolymarketDataClientFactory>()?;
536 m.add_class::<PolymarketExecutionClientConfig>()?;
537 m.add_class::<PolymarketExecutionClientFactory>()?;
538 m.add_class::<PolymarketFeeModel>()?;
539 m.add_class::<loader::PyPolymarketDataLoader>()?;
540 m.add_class::<session::PyPolymarketSessionKeyClient>()?;
541 m.add_class::<session::PyPolymarketSessionKeyClientConfig>()?;
542 m.add_class::<session::PyPolymarketSessionKey>()?;
543 m.add_class::<positions::PyPolymarketPositionClient>()?;
544 m.add_class::<positions::PyPolymarketPositionOutcome>()?;
545 m.add_class::<positions::PyPolymarketPositionTransaction>()?;
546 m.add_class::<PolymarketRtdsCryptoPrice>()?;
547 m.add_class::<PolymarketRtdsCryptoTwap>()?;
548 m.add_class::<PolymarketRtdsEquityPrice>()?;
549 m.add_function(pyo3::wrap_pyfunction!(
550 sort::py_polymarket_trade_sort_key,
551 m
552 )?)?;
553 m.add_function(pyo3::wrap_pyfunction!(sort::py_polymarket_trade_id, m)?)?;
554
555 register_polymarket_custom_data();
556 let _result = ensure_rust_extractor_registered::<PolymarketRtdsCryptoPrice>();
557 let _result = ensure_rust_extractor_registered::<PolymarketRtdsCryptoTwap>();
558 let _result = ensure_rust_extractor_registered::<PolymarketRtdsEquityPrice>();
559
560 let registry = get_global_pyo3_registry();
561
562 if let Err(e) =
563 registry.register_factory_extractor(POLYMARKET.to_string(), extract_polymarket_data_factory)
564 {
565 return Err(to_pyruntime_err(format!(
566 "Failed to register Polymarket data factory extractor: {e}"
567 )));
568 }
569
570 if let Err(e) = registry
571 .register_exec_factory_extractor(POLYMARKET.to_string(), extract_polymarket_exec_factory)
572 {
573 return Err(to_pyruntime_err(format!(
574 "Failed to register Polymarket exec factory extractor: {e}"
575 )));
576 }
577
578 if let Err(e) = registry.register_config_extractor(
579 "PolymarketDataClientConfig".to_string(),
580 extract_polymarket_data_config,
581 ) {
582 return Err(to_pyruntime_err(format!(
583 "Failed to register Polymarket data config extractor: {e}"
584 )));
585 }
586
587 if let Err(e) = registry.register_config_extractor(
588 "PolymarketExecutionClientConfig".to_string(),
589 extract_polymarket_exec_config,
590 ) {
591 return Err(to_pyruntime_err(format!(
592 "Failed to register Polymarket exec config extractor: {e}"
593 )));
594 }
595
596 Ok(())
597}
598
599#[cfg(all(test, feature = "python"))]
600mod tests {
601 use std::sync::Arc;
602
603 use nautilus_core::Params;
604 use nautilus_model::{
605 data::{CustomData, DataType, custom::CustomDataTrait, ensure_rust_extractor_registered},
606 types::Price,
607 };
608 use pyo3::{exceptions::PyValueError, prelude::*, types::PyDict};
609 use rstest::rstest;
610 use serde_json::json;
611
612 use super::extract_data_config_from_pyobject;
613 use crate::{
614 config::{
615 PolymarketDataClientConfig, PolymarketInstrumentProviderConfig,
616 PolymarketUpDownEventSlugConfig,
617 },
618 data_types::{PolymarketRtdsCryptoPrice, register_polymarket_custom_data},
619 };
620
621 #[rstest]
622 fn extract_data_config_supports_python_style_namespace() {
623 Python::initialize();
624 Python::attach(|py| {
625 let types = py.import("types").expect("types");
626 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
627 let event_slug_builder = Py::new(
628 py,
629 PolymarketUpDownEventSlugConfig {
630 assets: vec!["btc".to_string(), "eth".to_string()],
631 interval_mins: 5,
632 periods: 2,
633 start_offset_periods: 0,
634 },
635 )
636 .expect("event slug builder should convert to Python object");
637
638 let instrument_kwargs = PyDict::new(py);
639 instrument_kwargs
640 .set_item("event_slug_builder", event_slug_builder)
641 .unwrap();
642 instrument_kwargs
643 .set_item("event_slugs", vec!["event-a", "event-b"])
644 .unwrap();
645 instrument_kwargs
646 .set_item("market_slugs", vec!["market-a"])
647 .unwrap();
648 instrument_kwargs.set_item("load_all", false).unwrap();
649 instrument_kwargs.set_item("log_warnings", false).unwrap();
650 let instrument_config = namespace
651 .call((), Some(&instrument_kwargs))
652 .expect("instrument namespace");
653
654 let config_kwargs = PyDict::new(py);
655 config_kwargs
656 .set_item("instrument_config", instrument_config)
657 .unwrap();
658 config_kwargs
659 .set_item("update_instruments_interval_mins", 1)
660 .unwrap();
661 config_kwargs
662 .set_item("subscribe_new_markets", false)
663 .unwrap();
664 config_kwargs
665 .set_item("new_market_fetch_max_concurrency", 13)
666 .unwrap();
667 config_kwargs
668 .set_item("drop_quotes_missing_side", false)
669 .unwrap();
670 config_kwargs
671 .set_item("compute_effective_deltas", true)
672 .unwrap();
673 config_kwargs
674 .set_item("base_url_gamma", "https://gamma.example")
675 .unwrap();
676 config_kwargs
677 .set_item("base_url_rtds", "wss://ws-live-data.example")
678 .unwrap();
679 config_kwargs
680 .set_item("base_url_data_api", "https://data.example")
681 .unwrap();
682 config_kwargs
683 .set_item("proxy_url", "http://proxy.example:18085")
684 .unwrap();
685 config_kwargs.set_item("ws_timeout_secs", 41).unwrap();
686 config_kwargs.set_item("ws_max_subscriptions", 512).unwrap();
687 config_kwargs
688 .set_item("auto_load_missing_instruments", true)
689 .unwrap();
690 config_kwargs
691 .set_item("auto_load_debounce_ms", 100)
692 .unwrap();
693 config_kwargs.set_item("auto_load_max_retries", 12).unwrap();
694 config_kwargs
695 .set_item("auto_load_retry_delay_initial_secs", 5.0)
696 .unwrap();
697 config_kwargs
698 .set_item("auto_load_retry_delay_max_secs", 15.0)
699 .unwrap();
700 config_kwargs
701 .set_item("resolve_poll_enabled", false)
702 .unwrap();
703 config_kwargs
704 .set_item("resolve_poll_interval_secs", 45)
705 .unwrap();
706 config_kwargs
707 .set_item("resolve_poll_grace_secs", 12)
708 .unwrap();
709 config_kwargs
710 .set_item("resolve_poll_max_wait_secs", 2400)
711 .unwrap();
712 config_kwargs
713 .set_item("book_snapshot_timeout_secs", 42)
714 .unwrap();
715 config_kwargs
716 .set_item("book_stale_check_interval_secs", 7)
717 .unwrap();
718 config_kwargs
719 .set_item("book_stale_threshold_secs", 9)
720 .unwrap();
721 let config_obj = namespace
722 .call((), Some(&config_kwargs))
723 .expect("config namespace");
724
725 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
726 .expect("extract rust config");
727 let instrument_config = rust_config
728 .instrument_config
729 .expect("instrument_config should be extracted");
730
731 assert!(
732 instrument_config.load_all,
733 "event_slug_builder should imply scoped load_all bootstrap"
734 );
735 let event_slug_builder = instrument_config
736 .event_slug_builder
737 .expect("event_slug_builder should be extracted");
738 assert_eq!(
739 event_slug_builder.assets,
740 ["btc".to_string(), "eth".to_string()]
741 );
742 assert_eq!(event_slug_builder.interval_mins, 5);
743 assert_eq!(event_slug_builder.periods, 2);
744 assert_eq!(event_slug_builder.start_offset_periods, 0);
745 assert_eq!(
746 instrument_config.event_slugs.as_deref(),
747 Some(&["event-a".to_string(), "event-b".to_string()][..])
748 );
749 assert_eq!(
750 instrument_config.market_slugs.as_deref(),
751 Some(&["market-a".to_string()][..])
752 );
753 assert!(!instrument_config.log_warnings);
754 assert_eq!(rust_config.update_instruments_interval_mins, Some(1));
755 assert!(!rust_config.subscribe_new_markets);
756 assert_eq!(rust_config.new_market_fetch_max_concurrency, 13);
757 assert!(!rust_config.drop_quotes_missing_side);
758 assert!(rust_config.compute_effective_deltas);
759 assert_eq!(
760 rust_config.base_url_gamma.as_deref(),
761 Some("https://gamma.example")
762 );
763 assert_eq!(
764 rust_config.base_url_rtds.as_deref(),
765 Some("wss://ws-live-data.example")
766 );
767 assert_eq!(
768 rust_config.base_url_data_api.as_deref(),
769 Some("https://data.example")
770 );
771 assert_eq!(rust_config.ws_timeout_secs, 41);
772 assert_eq!(rust_config.ws_max_subscriptions, 512);
773 assert_eq!(
774 rust_config
775 .proxy_url
776 .as_ref()
777 .map(|value| value.expose_secret()),
778 Some("http://proxy.example:18085")
779 );
780 assert!(!rust_config.resolve_poll_enabled);
781 assert_eq!(rust_config.resolve_poll_interval_secs, 45);
782 assert_eq!(rust_config.resolve_poll_grace_secs, 12);
783 assert_eq!(rust_config.resolve_poll_max_wait_secs, 2400);
784 assert_eq!(rust_config.book_snapshot_timeout_secs, 42);
785 assert_eq!(rust_config.book_stale_check_interval_secs, 7);
786 assert_eq!(rust_config.book_stale_threshold_secs, 9);
787 });
788 }
789
790 #[rstest]
791 fn data_config_py_constructor_accepts_book_sync_kwargs() {
792 Python::initialize();
793 Python::attach(|py| {
794 let kwargs = PyDict::new(py);
795 kwargs.set_item("book_snapshot_timeout_secs", 42).unwrap();
796 kwargs
797 .set_item("book_stale_check_interval_secs", 7)
798 .unwrap();
799 kwargs.set_item("book_stale_threshold_secs", 9).unwrap();
800
801 let config = py
802 .get_type::<PolymarketDataClientConfig>()
803 .call((), Some(&kwargs))
804 .expect("data config should accept book sync kwargs");
805
806 assert_eq!(
807 config
808 .getattr("book_snapshot_timeout_secs")
809 .expect("timeout getter")
810 .extract::<u64>()
811 .expect("u64 timeout"),
812 42
813 );
814 assert_eq!(
815 config
816 .getattr("book_stale_check_interval_secs")
817 .expect("interval getter")
818 .extract::<u64>()
819 .expect("u64 interval"),
820 7
821 );
822 assert_eq!(
823 config
824 .getattr("book_stale_threshold_secs")
825 .expect("threshold getter")
826 .extract::<u64>()
827 .expect("u64 threshold"),
828 9
829 );
830 });
831 }
832
833 #[rstest]
834 fn extract_data_config_rejects_python_callable_event_slug_builder() {
835 Python::initialize();
836 Python::attach(|py| {
837 let types = py.import("types").expect("types");
838 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
839
840 let instrument_kwargs = PyDict::new(py);
841 instrument_kwargs
842 .set_item("event_slug_builder", "pkg.module:build_event_slugs")
843 .unwrap();
844 let instrument_config = namespace
845 .call((), Some(&instrument_kwargs))
846 .expect("instrument namespace");
847
848 let config_kwargs = PyDict::new(py);
849 config_kwargs
850 .set_item("instrument_config", instrument_config)
851 .unwrap();
852 let config_obj = namespace
853 .call((), Some(&config_kwargs))
854 .expect("config namespace");
855
856 let err = extract_data_config_from_pyobject(py, &config_obj.unbind())
857 .expect_err("Python callable event_slug_builder should be rejected");
858
859 assert!(
860 err.to_string()
861 .contains("Python callable event_slug_builder is not supported")
862 );
863 });
864 }
865
866 #[rstest]
867 fn native_provider_config_rejects_invalid_gamma_filters_as_value_error() {
868 Python::initialize();
869 Python::attach(|py| {
870 let filters = PyDict::new(py);
871 filters.set_item("active", "yes").unwrap();
872 let kwargs = PyDict::new(py);
873 kwargs.set_item("filters", filters).unwrap();
874
875 let err = py
876 .get_type::<PolymarketInstrumentProviderConfig>()
877 .call((), Some(&kwargs))
878 .unwrap_err();
879
880 assert!(err.is_instance_of::<PyValueError>(py));
881 assert!(err.to_string().contains("must be true or false"));
882 });
883 }
884
885 #[rstest]
886 fn extract_data_config_accepts_v1_shaped_filter_values() {
887 Python::initialize();
888 Python::attach(|py| {
889 let types = py.import("types").expect("types");
890 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
891 let filters = PyDict::new(py);
892 filters.set_item("is_active", true).unwrap();
893 filters.set_item("id", vec![1, 2]).unwrap();
894 filters.set_item("volume_num_min", 1.25).unwrap();
895 filters.set_item("tag_id", py.None()).unwrap();
896 let instrument_kwargs = PyDict::new(py);
897 instrument_kwargs.set_item("filters", filters).unwrap();
898 let instrument_config = namespace
899 .call((), Some(&instrument_kwargs))
900 .expect("instrument namespace");
901 let config_kwargs = PyDict::new(py);
902 config_kwargs
903 .set_item("instrument_config", instrument_config)
904 .unwrap();
905 let config_obj = namespace
906 .call((), Some(&config_kwargs))
907 .expect("config namespace");
908
909 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
910 .expect("v1-shaped filters should convert");
911 let filters = rust_config
912 .instrument_config
913 .and_then(|config| config.filters)
914 .expect("filters should be extracted");
915
916 assert_eq!(filters.get("is_active").map(String::as_str), Some("true"));
917 assert_eq!(filters.get("id").map(String::as_str), Some("1,2"));
918 assert_eq!(
919 filters.get("volume_num_min").map(String::as_str),
920 Some("1.25")
921 );
922 assert!(!filters.contains_key("tag_id"));
923 });
924 }
925
926 #[rstest]
927 fn extract_data_config_rejects_non_finite_filter_as_value_error() {
928 Python::initialize();
929 Python::attach(|py| {
930 let types = py.import("types").expect("types");
931 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
932 let filters = PyDict::new(py);
933 filters.set_item("volume_num_min", f64::NAN).unwrap();
934 let instrument_kwargs = PyDict::new(py);
935 instrument_kwargs.set_item("filters", filters).unwrap();
936 let instrument_config = namespace
937 .call((), Some(&instrument_kwargs))
938 .expect("instrument namespace");
939 let config_kwargs = PyDict::new(py);
940 config_kwargs
941 .set_item("instrument_config", instrument_config)
942 .unwrap();
943 let config_obj = namespace
944 .call((), Some(&config_kwargs))
945 .expect("config namespace");
946
947 let err = extract_data_config_from_pyobject(py, &config_obj.unbind()).unwrap_err();
948
949 assert!(err.is_instance_of::<PyValueError>(py));
950 assert!(err.to_string().contains("must be finite"));
951 });
952 }
953
954 #[rstest]
955 fn extract_data_config_rejects_v1_shaped_unknown_filter_as_value_error() {
956 Python::initialize();
957 Python::attach(|py| {
958 let types = py.import("types").expect("types");
959 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
960 let filters = PyDict::new(py);
961 filters.set_item("unsupported_filter", true).unwrap();
962 let instrument_kwargs = PyDict::new(py);
963 instrument_kwargs.set_item("filters", filters).unwrap();
964 let instrument_config = namespace
965 .call((), Some(&instrument_kwargs))
966 .expect("instrument namespace");
967 let config_kwargs = PyDict::new(py);
968 config_kwargs
969 .set_item("instrument_config", instrument_config)
970 .unwrap();
971 let config_obj = namespace
972 .call((), Some(&config_kwargs))
973 .expect("config namespace");
974
975 let err = extract_data_config_from_pyobject(py, &config_obj.unbind()).unwrap_err();
976
977 assert!(err.is_instance_of::<PyValueError>(py));
978 assert!(err.to_string().contains("Unknown Gamma market filter key"));
979 });
980 }
981
982 #[rstest]
983 fn extract_data_config_preserves_none_update_interval() {
984 Python::initialize();
985 Python::attach(|py| {
986 let types = py.import("types").expect("types");
987 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
988 let config_kwargs = PyDict::new(py);
989 config_kwargs
990 .set_item("update_instruments_interval_mins", py.None())
991 .unwrap();
992 let config_obj = namespace
993 .call((), Some(&config_kwargs))
994 .expect("config namespace");
995
996 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
997 .expect("extract rust config");
998
999 assert_eq!(rust_config.update_instruments_interval_mins, None);
1000 });
1001 }
1002
1003 #[rstest]
1004 fn custom_data_getter_unwraps_rtds_payload_to_python_class() {
1005 Python::initialize();
1006 Python::attach(|py| {
1007 register_polymarket_custom_data();
1008 let _result = ensure_rust_extractor_registered::<PolymarketRtdsCryptoPrice>();
1009
1010 let mut metadata = Params::new();
1011 metadata.insert("symbol".to_string(), json!("btcusdt"));
1012 let payload = Arc::new(PolymarketRtdsCryptoPrice::new(
1013 "btcusdt".to_string(),
1014 Price::from("67234.50"),
1015 1_753_314_088_395,
1016 1_753_314_088_421,
1017 nautilus_core::UnixNanos::from_millis(1_753_314_088_395),
1018 nautilus_core::UnixNanos::from_millis(1_753_314_088_421),
1019 ));
1020 let custom = CustomData::new(
1021 payload,
1022 DataType::new(
1023 PolymarketRtdsCryptoPrice::type_name_static(),
1024 Some(metadata),
1025 None,
1026 ),
1027 );
1028
1029 let py_custom = Py::new(py, custom).expect("create Python CustomData");
1030 let py_payload = py_custom.bind(py).getattr("data").expect("CustomData.data");
1031
1032 assert_eq!(
1033 py_payload.get_type().name().expect("type name"),
1034 "PolymarketRtdsCryptoPrice"
1035 );
1036 assert_eq!(
1037 py_payload
1038 .getattr("symbol")
1039 .expect("symbol")
1040 .extract::<String>()
1041 .expect("extract symbol"),
1042 "btcusdt"
1043 );
1044 assert_eq!(
1045 py_payload
1046 .getattr("value")
1047 .expect("value")
1048 .str()
1049 .expect("value str")
1050 .to_string(),
1051 "67234.50"
1052 );
1053 });
1054 }
1055}