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 PolymarketCryptoPrice, PolymarketCryptoTwap, PolymarketEquityPrice,
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_reference_prices = getattr_optional(obj, "base_url_reference_prices")?
331 .map(|value| value.extract::<String>())
332 .transpose()?;
333 let api_key = getattr_optional(obj, "api_key")?
334 .map(|value| value.extract::<String>())
335 .transpose()?
336 .map(SecretString::from);
337 let api_secret = getattr_optional(obj, "api_secret")?
338 .map(|value| value.extract::<String>())
339 .transpose()?
340 .map(SecretString::from);
341 let passphrase = getattr_optional(obj, "passphrase")?
342 .map(|value| value.extract::<String>())
343 .transpose()?
344 .map(SecretString::from);
345 let base_url_gamma = getattr_optional(obj, "base_url_gamma")?
346 .map(|value| value.extract::<String>())
347 .transpose()?;
348 let base_url_data_api = getattr_optional(obj, "base_url_data_api")?
349 .map(|value| value.extract::<String>())
350 .transpose()?;
351 let proxy_url = getattr_optional(obj, "proxy_url")?
352 .map(|value| value.extract::<String>())
353 .transpose()?
354 .map(SecretString::from);
355 let http_timeout_secs = getattr_optional(obj, "http_timeout_secs")?
356 .map(|value| value.extract::<u64>())
357 .transpose()?
358 .unwrap_or(default.http_timeout_secs);
359 let ws_timeout_secs = getattr_optional(obj, "ws_timeout_secs")?
360 .map(|value| value.extract::<u64>())
361 .transpose()?
362 .unwrap_or(default.ws_timeout_secs);
363 let ws_max_subscriptions = getattr_optional(obj, "ws_max_subscriptions")?
364 .map(|value| value.extract::<usize>())
365 .transpose()?
366 .unwrap_or(default.ws_max_subscriptions);
367 let update_instruments_interval_mins = getattr_optional_option_u64(
368 obj,
369 "update_instruments_interval_mins",
370 default.update_instruments_interval_mins,
371 )?;
372 let subscribe_new_markets = getattr_optional(obj, "subscribe_new_markets")?
373 .map(|value| value.extract::<bool>())
374 .transpose()?
375 .unwrap_or(default.subscribe_new_markets);
376 let new_market_fetch_max_concurrency =
377 getattr_optional(obj, "new_market_fetch_max_concurrency")?
378 .map(|value| value.extract::<usize>())
379 .transpose()?
380 .unwrap_or(default.new_market_fetch_max_concurrency);
381 let drop_quotes_missing_side = getattr_optional(obj, "drop_quotes_missing_side")?
382 .map(|value| value.extract::<bool>())
383 .transpose()?
384 .unwrap_or(default.drop_quotes_missing_side);
385 let compute_effective_deltas = getattr_optional(obj, "compute_effective_deltas")?
386 .map(|value| value.extract::<bool>())
387 .transpose()?
388 .unwrap_or(default.compute_effective_deltas);
389 let auto_load_missing_instruments = getattr_optional(obj, "auto_load_missing_instruments")?
390 .map(|value| value.extract::<bool>())
391 .transpose()?
392 .unwrap_or(default.auto_load_missing_instruments);
393 let auto_load_debounce_ms = getattr_optional(obj, "auto_load_debounce_ms")?
394 .map(|value| value.extract::<u64>())
395 .transpose()?
396 .unwrap_or(default.auto_load_debounce_ms);
397 let auto_load_max_retries = getattr_optional(obj, "auto_load_max_retries")?
398 .map(|value| value.extract::<u32>())
399 .transpose()?
400 .unwrap_or(default.auto_load_max_retries);
401 let auto_load_retry_delay_initial_secs =
402 getattr_optional(obj, "auto_load_retry_delay_initial_secs")?
403 .map(|value| value.extract::<f64>())
404 .transpose()?
405 .unwrap_or(default.auto_load_retry_delay_initial_secs);
406 let auto_load_retry_delay_max_secs = getattr_optional(obj, "auto_load_retry_delay_max_secs")?
407 .map(|value| value.extract::<f64>())
408 .transpose()?
409 .unwrap_or(default.auto_load_retry_delay_max_secs);
410 let resolve_poll_enabled = getattr_optional(obj, "resolve_poll_enabled")?
411 .map(|value| value.extract::<bool>())
412 .transpose()?
413 .unwrap_or(default.resolve_poll_enabled);
414 let resolve_poll_interval_secs = getattr_optional(obj, "resolve_poll_interval_secs")?
415 .map(|value| value.extract::<u64>())
416 .transpose()?
417 .unwrap_or(default.resolve_poll_interval_secs);
418 let resolve_poll_grace_secs = getattr_optional(obj, "resolve_poll_grace_secs")?
419 .map(|value| value.extract::<u64>())
420 .transpose()?
421 .unwrap_or(default.resolve_poll_grace_secs);
422 let resolve_poll_max_wait_secs = getattr_optional(obj, "resolve_poll_max_wait_secs")?
423 .map(|value| value.extract::<u64>())
424 .transpose()?
425 .unwrap_or(default.resolve_poll_max_wait_secs);
426 let transport_backend = match getattr_optional(obj, "transport_backend")? {
427 Some(value) => value.extract::<TransportBackend>()?,
428 None => default.transport_backend,
429 };
430
431 let book_snapshot_timeout_secs = getattr_optional(obj, "book_snapshot_timeout_secs")?
432 .map(|value| value.extract::<u64>())
433 .transpose()?
434 .unwrap_or(default.book_snapshot_timeout_secs);
435 let book_stale_check_interval_secs = getattr_optional(obj, "book_stale_check_interval_secs")?
436 .map(|value| value.extract::<u64>())
437 .transpose()?
438 .unwrap_or(default.book_stale_check_interval_secs);
439 let book_stale_threshold_secs = getattr_optional(obj, "book_stale_threshold_secs")?
440 .map(|value| value.extract::<u64>())
441 .transpose()?
442 .unwrap_or(default.book_stale_threshold_secs);
443
444 let config = PolymarketDataClientConfig {
445 instrument_config,
446 filters: Vec::new(),
447 base_url_http,
448 base_url_ws,
449 base_url_reference_prices,
450 base_url_gamma,
451 base_url_data_api,
452 api_key,
453 api_secret,
454 passphrase,
455 proxy_url,
456 http_timeout_secs,
457 ws_timeout_secs,
458 ws_max_subscriptions,
459 update_instruments_interval_mins,
460 subscribe_new_markets,
461 new_market_filter: None,
462 new_market_fetch_max_concurrency,
463 drop_quotes_missing_side,
464 compute_effective_deltas,
465 auto_load_missing_instruments,
466 auto_load_debounce_ms,
467 auto_load_max_retries,
468 auto_load_retry_delay_initial_secs,
469 auto_load_retry_delay_max_secs,
470 resolve_poll_enabled,
471 resolve_poll_interval_secs,
472 resolve_poll_grace_secs,
473 resolve_poll_max_wait_secs,
474 transport_backend,
475 book_snapshot_timeout_secs,
476 book_stale_check_interval_secs,
477 book_stale_threshold_secs,
478 };
479 validate_data_config(&config)?;
480 Ok(config)
481}
482
483#[expect(clippy::needless_pass_by_value)]
484fn extract_polymarket_data_factory(
485 py: Python<'_>,
486 factory: Py<PyAny>,
487) -> PyResult<Box<dyn DataClientFactory>> {
488 match factory.extract::<PolymarketDataClientFactory>(py) {
489 Ok(f) => Ok(Box::new(f)),
490 Err(e) => Err(to_pyvalue_err(format!(
491 "Failed to extract PolymarketDataClientFactory: {e}"
492 ))),
493 }
494}
495
496#[expect(clippy::needless_pass_by_value)]
497fn extract_polymarket_exec_factory(
498 py: Python<'_>,
499 factory: Py<PyAny>,
500) -> PyResult<Box<dyn ExecutionClientFactory>> {
501 match factory.extract::<PolymarketExecutionClientFactory>(py) {
502 Ok(f) => Ok(Box::new(f)),
503 Err(e) => Err(to_pyvalue_err(format!(
504 "Failed to extract PolymarketExecutionClientFactory: {e}"
505 ))),
506 }
507}
508
509#[expect(clippy::needless_pass_by_value)]
510fn extract_polymarket_data_config(
511 py: Python<'_>,
512 config: Py<PyAny>,
513) -> PyResult<Box<dyn ClientConfig>> {
514 match extract_data_config_from_pyobject(py, &config) {
515 Ok(c) => Ok(Box::new(c)),
516 Err(e) => Err(to_pyvalue_err(format!(
517 "Failed to extract PolymarketDataClientConfig: {e}"
518 ))),
519 }
520}
521
522#[expect(clippy::needless_pass_by_value)]
523fn extract_polymarket_exec_config(
524 py: Python<'_>,
525 config: Py<PyAny>,
526) -> PyResult<Box<dyn ClientConfig>> {
527 match config.extract::<PolymarketExecutionClientConfig>(py) {
528 Ok(c) => {
529 c.validated_proxy_url()
530 .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
531 Ok(Box::new(c))
532 }
533 Err(e) => Err(to_pyvalue_err(format!(
534 "Failed to extract PolymarketExecutionClientConfig: {e}"
535 ))),
536 }
537}
538
539#[pymodule]
541pub fn polymarket(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
542 m.add(stringify!(POLYMARKET), POLYMARKET)?;
543 m.add(stringify!(POLYMARKET_CLIENT_ID), *POLYMARKET_CLIENT_ID)?;
544 m.add(stringify!(POLYMARKET_VENUE), *POLYMARKET_VENUE)?;
545 m.add_class::<crate::common::enums::PolymarketSignatureType>()?;
546 m.add_class::<crate::common::enums::PolymarketSignerType>()?;
547 m.add_class::<PolymarketUpDownEventSlugConfig>()?;
548 m.add_class::<PolymarketInstrumentProviderConfig>()?;
549 m.add_class::<PolymarketDataClientConfig>()?;
550 m.add_class::<PolymarketDataClientFactory>()?;
551 m.add_class::<PolymarketExecutionClientConfig>()?;
552 m.add_class::<PolymarketExecutionClientFactory>()?;
553 m.add_class::<PolymarketFeeModel>()?;
554 m.add_class::<loader::PyPolymarketDataLoader>()?;
555 m.add_class::<session::PyPolymarketSessionKeyClient>()?;
556 m.add_class::<session::PyPolymarketSessionKeyClientConfig>()?;
557 m.add_class::<session::PyPolymarketSessionKey>()?;
558 m.add_class::<positions::PyPolymarketPositionClient>()?;
559 m.add_class::<positions::PyPolymarketPositionOutcome>()?;
560 m.add_class::<positions::PyPolymarketPositionTransaction>()?;
561 m.add_class::<PolymarketCryptoPrice>()?;
562 m.add_class::<PolymarketCryptoTwap>()?;
563 m.add_class::<PolymarketEquityPrice>()?;
564 m.add_function(pyo3::wrap_pyfunction!(
565 sort::py_polymarket_trade_sort_key,
566 m
567 )?)?;
568 m.add_function(pyo3::wrap_pyfunction!(sort::py_polymarket_trade_id, m)?)?;
569
570 register_polymarket_custom_data();
571 let _result = ensure_rust_extractor_registered::<PolymarketCryptoPrice>();
572 let _result = ensure_rust_extractor_registered::<PolymarketCryptoTwap>();
573 let _result = ensure_rust_extractor_registered::<PolymarketEquityPrice>();
574
575 let registry = get_global_pyo3_registry();
576
577 if let Err(e) =
578 registry.register_factory_extractor(POLYMARKET.to_string(), extract_polymarket_data_factory)
579 {
580 return Err(to_pyruntime_err(format!(
581 "Failed to register Polymarket data factory extractor: {e}"
582 )));
583 }
584
585 if let Err(e) = registry
586 .register_exec_factory_extractor(POLYMARKET.to_string(), extract_polymarket_exec_factory)
587 {
588 return Err(to_pyruntime_err(format!(
589 "Failed to register Polymarket exec factory extractor: {e}"
590 )));
591 }
592
593 if let Err(e) = registry.register_config_extractor(
594 "PolymarketDataClientConfig".to_string(),
595 extract_polymarket_data_config,
596 ) {
597 return Err(to_pyruntime_err(format!(
598 "Failed to register Polymarket data config extractor: {e}"
599 )));
600 }
601
602 if let Err(e) = registry.register_config_extractor(
603 "PolymarketExecutionClientConfig".to_string(),
604 extract_polymarket_exec_config,
605 ) {
606 return Err(to_pyruntime_err(format!(
607 "Failed to register Polymarket exec config extractor: {e}"
608 )));
609 }
610
611 Ok(())
612}
613
614#[cfg(all(test, feature = "python"))]
615mod tests {
616 use std::sync::Arc;
617
618 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
619 use nautilus_core::Params;
620 use nautilus_model::data::{
621 CustomData, DataType, custom::CustomDataTrait, ensure_rust_extractor_registered,
622 };
623 use pyo3::{exceptions::PyValueError, prelude::*, types::PyDict};
624 use rstest::rstest;
625 use rust_decimal_macros::dec;
626 use serde_json::json;
627
628 use super::extract_data_config_from_pyobject;
629 use crate::{
630 config::{
631 PolymarketDataClientConfig, PolymarketInstrumentProviderConfig,
632 PolymarketUpDownEventSlugConfig,
633 },
634 data_types::{PolymarketCryptoPrice, register_polymarket_custom_data},
635 };
636
637 #[rstest]
638 fn extract_data_config_supports_python_style_namespace() {
639 Python::initialize();
640 Python::attach(|py| {
641 let types = py.import("types").expect("types");
642 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
643 let event_slug_builder = Py::new(
644 py,
645 PolymarketUpDownEventSlugConfig {
646 assets: vec!["btc".to_string(), "eth".to_string()],
647 interval_mins: 5,
648 periods: 2,
649 start_offset_periods: 0,
650 },
651 )
652 .expect("event slug builder should convert to Python object");
653
654 let instrument_kwargs = PyDict::new(py);
655 instrument_kwargs
656 .set_item("event_slug_builder", event_slug_builder)
657 .unwrap();
658 instrument_kwargs
659 .set_item("event_slugs", vec!["event-a", "event-b"])
660 .unwrap();
661 instrument_kwargs
662 .set_item("market_slugs", vec!["market-a"])
663 .unwrap();
664 instrument_kwargs.set_item("load_all", false).unwrap();
665 instrument_kwargs.set_item("log_warnings", false).unwrap();
666 let instrument_config = namespace
667 .call((), Some(&instrument_kwargs))
668 .expect("instrument namespace");
669
670 let config_kwargs = PyDict::new(py);
671 config_kwargs
672 .set_item("instrument_config", instrument_config)
673 .unwrap();
674 config_kwargs
675 .set_item("update_instruments_interval_mins", 1)
676 .unwrap();
677 config_kwargs
678 .set_item("subscribe_new_markets", false)
679 .unwrap();
680 config_kwargs
681 .set_item("new_market_fetch_max_concurrency", 13)
682 .unwrap();
683 config_kwargs
684 .set_item("drop_quotes_missing_side", false)
685 .unwrap();
686 config_kwargs
687 .set_item("compute_effective_deltas", true)
688 .unwrap();
689 config_kwargs
690 .set_item("base_url_gamma", "https://gamma.example")
691 .unwrap();
692 config_kwargs
693 .set_item("base_url_reference_prices", "wss://ws-live-data.example")
694 .unwrap();
695 let api_secret = BASE64_STANDARD.encode(b"namespace-secret");
696 config_kwargs.set_item("api_key", "namespace-key").unwrap();
697 config_kwargs.set_item("api_secret", api_secret).unwrap();
698 config_kwargs
699 .set_item("passphrase", "namespace-passphrase")
700 .unwrap();
701 config_kwargs
702 .set_item("base_url_data_api", "https://data.example")
703 .unwrap();
704 config_kwargs
705 .set_item("proxy_url", "http://proxy.example:18085")
706 .unwrap();
707 config_kwargs.set_item("ws_timeout_secs", 41).unwrap();
708 config_kwargs.set_item("ws_max_subscriptions", 512).unwrap();
709 config_kwargs
710 .set_item("auto_load_missing_instruments", true)
711 .unwrap();
712 config_kwargs
713 .set_item("auto_load_debounce_ms", 100)
714 .unwrap();
715 config_kwargs.set_item("auto_load_max_retries", 12).unwrap();
716 config_kwargs
717 .set_item("auto_load_retry_delay_initial_secs", 5.0)
718 .unwrap();
719 config_kwargs
720 .set_item("auto_load_retry_delay_max_secs", 15.0)
721 .unwrap();
722 config_kwargs
723 .set_item("resolve_poll_enabled", false)
724 .unwrap();
725 config_kwargs
726 .set_item("resolve_poll_interval_secs", 45)
727 .unwrap();
728 config_kwargs
729 .set_item("resolve_poll_grace_secs", 12)
730 .unwrap();
731 config_kwargs
732 .set_item("resolve_poll_max_wait_secs", 2400)
733 .unwrap();
734 config_kwargs
735 .set_item("book_snapshot_timeout_secs", 42)
736 .unwrap();
737 config_kwargs
738 .set_item("book_stale_check_interval_secs", 7)
739 .unwrap();
740 config_kwargs
741 .set_item("book_stale_threshold_secs", 9)
742 .unwrap();
743 let config_obj = namespace
744 .call((), Some(&config_kwargs))
745 .expect("config namespace");
746
747 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
748 .expect("extract rust config");
749 let instrument_config = rust_config
750 .instrument_config
751 .expect("instrument_config should be extracted");
752
753 assert!(
754 instrument_config.load_all,
755 "event_slug_builder should imply scoped load_all bootstrap"
756 );
757 let event_slug_builder = instrument_config
758 .event_slug_builder
759 .expect("event_slug_builder should be extracted");
760 assert_eq!(
761 event_slug_builder.assets,
762 ["btc".to_string(), "eth".to_string()]
763 );
764 assert_eq!(event_slug_builder.interval_mins, 5);
765 assert_eq!(event_slug_builder.periods, 2);
766 assert_eq!(event_slug_builder.start_offset_periods, 0);
767 assert_eq!(
768 instrument_config.event_slugs.as_deref(),
769 Some(&["event-a".to_string(), "event-b".to_string()][..])
770 );
771 assert_eq!(
772 instrument_config.market_slugs.as_deref(),
773 Some(&["market-a".to_string()][..])
774 );
775 assert!(!instrument_config.log_warnings);
776 assert_eq!(rust_config.update_instruments_interval_mins, Some(1));
777 assert!(!rust_config.subscribe_new_markets);
778 assert_eq!(rust_config.new_market_fetch_max_concurrency, 13);
779 assert!(!rust_config.drop_quotes_missing_side);
780 assert!(rust_config.compute_effective_deltas);
781 assert_eq!(
782 rust_config.base_url_gamma.as_deref(),
783 Some("https://gamma.example")
784 );
785 assert_eq!(
786 rust_config.base_url_reference_prices.as_deref(),
787 Some("wss://ws-live-data.example")
788 );
789 assert_eq!(
790 rust_config.base_url_data_api.as_deref(),
791 Some("https://data.example")
792 );
793 assert_eq!(rust_config.ws_timeout_secs, 41);
794 assert_eq!(rust_config.ws_max_subscriptions, 512);
795 assert_eq!(
796 rust_config
797 .proxy_url
798 .as_ref()
799 .map(|value| value.expose_secret()),
800 Some("http://proxy.example:18085")
801 );
802 assert!(!rust_config.resolve_poll_enabled);
803 assert_eq!(rust_config.resolve_poll_interval_secs, 45);
804 assert_eq!(rust_config.resolve_poll_grace_secs, 12);
805 assert_eq!(rust_config.resolve_poll_max_wait_secs, 2400);
806 assert_eq!(rust_config.book_snapshot_timeout_secs, 42);
807 assert_eq!(rust_config.book_stale_check_interval_secs, 7);
808 assert_eq!(rust_config.book_stale_threshold_secs, 9);
809 assert_eq!(
810 rust_config
811 .api_key
812 .as_ref()
813 .map(|value| value.expose_secret()),
814 Some("namespace-key")
815 );
816 assert_eq!(
817 rust_config
818 .api_secret
819 .as_ref()
820 .map(|value| value.expose_secret()),
821 Some("bmFtZXNwYWNlLXNlY3JldA==")
822 );
823 assert_eq!(
824 rust_config
825 .passphrase
826 .as_ref()
827 .map(|value| value.expose_secret()),
828 Some("namespace-passphrase")
829 );
830 });
831 }
832
833 #[rstest]
834 fn data_config_py_constructor_accepts_book_sync_kwargs() {
835 Python::initialize();
836 Python::attach(|py| {
837 let kwargs = PyDict::new(py);
838 kwargs.set_item("book_snapshot_timeout_secs", 42).unwrap();
839 kwargs
840 .set_item("book_stale_check_interval_secs", 7)
841 .unwrap();
842 kwargs.set_item("book_stale_threshold_secs", 9).unwrap();
843
844 let config = py
845 .get_type::<PolymarketDataClientConfig>()
846 .call((), Some(&kwargs))
847 .expect("data config should accept book sync kwargs");
848
849 assert_eq!(
850 config
851 .getattr("book_snapshot_timeout_secs")
852 .expect("timeout getter")
853 .extract::<u64>()
854 .expect("u64 timeout"),
855 42
856 );
857 assert_eq!(
858 config
859 .getattr("book_stale_check_interval_secs")
860 .expect("interval getter")
861 .extract::<u64>()
862 .expect("u64 interval"),
863 7
864 );
865 assert_eq!(
866 config
867 .getattr("book_stale_threshold_secs")
868 .expect("threshold getter")
869 .extract::<u64>()
870 .expect("u64 threshold"),
871 9
872 );
873 });
874 }
875
876 #[rstest]
877 fn extract_data_config_rejects_python_callable_event_slug_builder() {
878 Python::initialize();
879 Python::attach(|py| {
880 let types = py.import("types").expect("types");
881 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
882
883 let instrument_kwargs = PyDict::new(py);
884 instrument_kwargs
885 .set_item("event_slug_builder", "pkg.module:build_event_slugs")
886 .unwrap();
887 let instrument_config = namespace
888 .call((), Some(&instrument_kwargs))
889 .expect("instrument namespace");
890
891 let config_kwargs = PyDict::new(py);
892 config_kwargs
893 .set_item("instrument_config", instrument_config)
894 .unwrap();
895 let config_obj = namespace
896 .call((), Some(&config_kwargs))
897 .expect("config namespace");
898
899 let err = extract_data_config_from_pyobject(py, &config_obj.unbind())
900 .expect_err("Python callable event_slug_builder should be rejected");
901
902 assert!(
903 err.to_string()
904 .contains("Python callable event_slug_builder is not supported")
905 );
906 });
907 }
908
909 #[rstest]
910 fn native_provider_config_rejects_invalid_gamma_filters_as_value_error() {
911 Python::initialize();
912 Python::attach(|py| {
913 let filters = PyDict::new(py);
914 filters.set_item("active", "yes").unwrap();
915 let kwargs = PyDict::new(py);
916 kwargs.set_item("filters", filters).unwrap();
917
918 let err = py
919 .get_type::<PolymarketInstrumentProviderConfig>()
920 .call((), Some(&kwargs))
921 .unwrap_err();
922
923 assert!(err.is_instance_of::<PyValueError>(py));
924 assert!(err.to_string().contains("must be true or false"));
925 });
926 }
927
928 #[rstest]
929 fn extract_data_config_accepts_v1_shaped_filter_values() {
930 Python::initialize();
931 Python::attach(|py| {
932 let types = py.import("types").expect("types");
933 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
934 let filters = PyDict::new(py);
935 filters.set_item("is_active", true).unwrap();
936 filters.set_item("id", vec![1, 2]).unwrap();
937 filters.set_item("volume_num_min", 1.25).unwrap();
938 filters.set_item("tag_id", py.None()).unwrap();
939 let instrument_kwargs = PyDict::new(py);
940 instrument_kwargs.set_item("filters", filters).unwrap();
941 let instrument_config = namespace
942 .call((), Some(&instrument_kwargs))
943 .expect("instrument namespace");
944 let config_kwargs = PyDict::new(py);
945 config_kwargs
946 .set_item("instrument_config", instrument_config)
947 .unwrap();
948 let config_obj = namespace
949 .call((), Some(&config_kwargs))
950 .expect("config namespace");
951
952 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
953 .expect("v1-shaped filters should convert");
954 let filters = rust_config
955 .instrument_config
956 .and_then(|config| config.filters)
957 .expect("filters should be extracted");
958
959 assert_eq!(filters.get("is_active").map(String::as_str), Some("true"));
960 assert_eq!(filters.get("id").map(String::as_str), Some("1,2"));
961 assert_eq!(
962 filters.get("volume_num_min").map(String::as_str),
963 Some("1.25")
964 );
965 assert!(!filters.contains_key("tag_id"));
966 });
967 }
968
969 #[rstest]
970 fn extract_data_config_rejects_non_finite_filter_as_value_error() {
971 Python::initialize();
972 Python::attach(|py| {
973 let types = py.import("types").expect("types");
974 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
975 let filters = PyDict::new(py);
976 filters.set_item("volume_num_min", f64::NAN).unwrap();
977 let instrument_kwargs = PyDict::new(py);
978 instrument_kwargs.set_item("filters", filters).unwrap();
979 let instrument_config = namespace
980 .call((), Some(&instrument_kwargs))
981 .expect("instrument namespace");
982 let config_kwargs = PyDict::new(py);
983 config_kwargs
984 .set_item("instrument_config", instrument_config)
985 .unwrap();
986 let config_obj = namespace
987 .call((), Some(&config_kwargs))
988 .expect("config namespace");
989
990 let err = extract_data_config_from_pyobject(py, &config_obj.unbind()).unwrap_err();
991
992 assert!(err.is_instance_of::<PyValueError>(py));
993 assert!(err.to_string().contains("must be finite"));
994 });
995 }
996
997 #[rstest]
998 fn extract_data_config_rejects_v1_shaped_unknown_filter_as_value_error() {
999 Python::initialize();
1000 Python::attach(|py| {
1001 let types = py.import("types").expect("types");
1002 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
1003 let filters = PyDict::new(py);
1004 filters.set_item("unsupported_filter", true).unwrap();
1005 let instrument_kwargs = PyDict::new(py);
1006 instrument_kwargs.set_item("filters", filters).unwrap();
1007 let instrument_config = namespace
1008 .call((), Some(&instrument_kwargs))
1009 .expect("instrument namespace");
1010 let config_kwargs = PyDict::new(py);
1011 config_kwargs
1012 .set_item("instrument_config", instrument_config)
1013 .unwrap();
1014 let config_obj = namespace
1015 .call((), Some(&config_kwargs))
1016 .expect("config namespace");
1017
1018 let err = extract_data_config_from_pyobject(py, &config_obj.unbind()).unwrap_err();
1019
1020 assert!(err.is_instance_of::<PyValueError>(py));
1021 assert!(err.to_string().contains("Unknown Gamma market filter key"));
1022 });
1023 }
1024
1025 #[rstest]
1026 fn extract_data_config_preserves_none_update_interval() {
1027 Python::initialize();
1028 Python::attach(|py| {
1029 let types = py.import("types").expect("types");
1030 let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
1031 let config_kwargs = PyDict::new(py);
1032 config_kwargs
1033 .set_item("update_instruments_interval_mins", py.None())
1034 .unwrap();
1035 let config_obj = namespace
1036 .call((), Some(&config_kwargs))
1037 .expect("config namespace");
1038
1039 let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
1040 .expect("extract rust config");
1041
1042 assert_eq!(rust_config.update_instruments_interval_mins, None);
1043 });
1044 }
1045
1046 #[rstest]
1047 fn custom_data_getter_unwraps_polybolt_payload_to_python_class() {
1048 Python::initialize();
1049 Python::attach(|py| {
1050 register_polymarket_custom_data();
1051 let _result = ensure_rust_extractor_registered::<PolymarketCryptoPrice>();
1052
1053 let mut metadata = Params::new();
1054 metadata.insert("symbol".to_string(), json!("btcusd"));
1055
1056 let payload = Arc::new(PolymarketCryptoPrice::new(
1057 "btcusd".to_string(),
1058 "chainlink".to_string(),
1059 dec!(67234.50),
1060 1_753_314_088_395,
1061 1_753_314_088_421,
1062 nautilus_core::UnixNanos::from_millis(1_753_314_088_395),
1063 nautilus_core::UnixNanos::from_millis(1_753_314_088_421),
1064 ));
1065 let custom = CustomData::new(
1066 payload,
1067 DataType::new(
1068 PolymarketCryptoPrice::type_name_static(),
1069 Some(metadata),
1070 None,
1071 ),
1072 );
1073
1074 let py_custom = Py::new(py, custom).expect("create Python CustomData");
1075 let py_payload = py_custom.bind(py).getattr("data").expect("CustomData.data");
1076
1077 assert_eq!(
1078 py_payload.get_type().name().expect("type name"),
1079 "PolymarketCryptoPrice"
1080 );
1081 assert_eq!(
1082 py_payload
1083 .getattr("symbol")
1084 .expect("symbol")
1085 .extract::<String>()
1086 .expect("extract symbol"),
1087 "btcusd"
1088 );
1089 assert_eq!(
1090 py_payload
1091 .getattr("value")
1092 .expect("value")
1093 .str()
1094 .expect("value str")
1095 .to_string(),
1096 "67234.50"
1097 );
1098 });
1099 }
1100}