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 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    /// Polymarket fee model for binary-option backtests.
63    ///
64    /// Taker fills pay the market's fee-equivalent amount. Maker fills receive a
65    /// per-fill approximation of the daily maker rebate by applying the market's
66    /// configured rebate rate to that fee-equivalent amount.
67    #[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/// Exposed through `nautilus_trader.adapters.polymarket`.
502#[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}