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