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