Skip to main content

nautilus_polymarket/python/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings from `pyo3`.
17//!
18//! The Python v2 Polymarket boundary exposes configuration, factory registration,
19//! and a thin discovery and historical-data facade backed by Rust clients.
20
21#![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    /// Polymarket fee model for binary-option backtests.
68    ///
69    /// Taker fills pay the market's fee-equivalent amount. Maker fills receive a
70    /// per-fill approximation of the daily maker rebate by applying the market's
71    /// configured rebate rate to that fee-equivalent amount.
72    #[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/// Exposed through `nautilus_trader.adapters.polymarket`.
540#[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}