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 is configuration and factory registration.
19//! Provider, data, and execution operations stay in Rust. Add Python here only
20//! to expose Rust adapter types or register factories, not to run adapter logic.
21
22#![expect(
23    clippy::missing_errors_doc,
24    reason = "errors documented on underlying Rust methods"
25)]
26
27pub mod config;
28pub mod factories;
29pub mod sort;
30
31use nautilus_common::factories::{ClientConfig, DataClientFactory, ExecutionClientFactory};
32use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
33use nautilus_model::{data::ensure_rust_extractor_registered, identifiers::InstrumentId};
34use nautilus_system::get_global_pyo3_registry;
35use pyo3::{prelude::*, types::PyDict};
36
37use crate::{
38    common::consts::POLYMARKET,
39    config::{
40        PolymarketDataClientConfig, PolymarketExecClientConfig, PolymarketInstrumentProviderConfig,
41        PolymarketUpDownEventSlugConfig,
42    },
43    data_types::{
44        PolymarketRtdsCryptoPrice, PolymarketRtdsEquityPrice, register_polymarket_custom_data,
45    },
46    factories::{PolymarketDataClientFactory, PolymarketExecutionClientFactory},
47};
48
49fn getattr_optional<'py>(
50    obj: &Bound<'py, PyAny>,
51    name: &str,
52) -> PyResult<Option<Bound<'py, PyAny>>> {
53    if !obj.hasattr(name)? {
54        return Ok(None);
55    }
56
57    let value = obj.getattr(name)?;
58    if value.is_none() {
59        Ok(None)
60    } else {
61        Ok(Some(value))
62    }
63}
64
65fn getattr_optional_option_u64(
66    obj: &Bound<'_, PyAny>,
67    name: &str,
68    default: Option<u64>,
69) -> PyResult<Option<u64>> {
70    if !obj.hasattr(name)? {
71        return Ok(default);
72    }
73
74    let value = obj.getattr(name)?;
75    if value.is_none() {
76        Ok(None)
77    } else {
78        value.extract::<u64>().map(Some)
79    }
80}
81
82fn py_scalar_to_string(value: &Bound<'_, PyAny>) -> PyResult<String> {
83    if let Ok(v) = value.extract::<bool>() {
84        return Ok(v.to_string().to_lowercase());
85    }
86
87    if let Ok(v) = value.extract::<i64>() {
88        return Ok(v.to_string());
89    }
90
91    if let Ok(v) = value.extract::<u64>() {
92        return Ok(v.to_string());
93    }
94
95    if let Ok(v) = value.extract::<f64>() {
96        return Ok(v.to_string());
97    }
98
99    if let Ok(v) = value.extract::<String>() {
100        return Ok(v);
101    }
102
103    value.str()?.extract()
104}
105
106fn extract_string_map(
107    value: &Bound<'_, PyAny>,
108) -> PyResult<std::collections::HashMap<String, String>> {
109    let dict = value.cast::<PyDict>()?;
110    let mut map = std::collections::HashMap::with_capacity(dict.len());
111    for (key, value) in dict.iter() {
112        map.insert(key.extract::<String>()?, py_scalar_to_string(&value)?);
113    }
114    Ok(map)
115}
116
117fn extract_event_slug_builder(
118    value: &Bound<'_, PyAny>,
119) -> PyResult<PolymarketUpDownEventSlugConfig> {
120    if let Ok(builder) = value.extract::<PolymarketUpDownEventSlugConfig>() {
121        return Ok(builder);
122    }
123
124    if value.extract::<String>().is_ok() {
125        return Err(to_pyvalue_err(
126            "Python callable event_slug_builder is not supported by the Rust Polymarket adapter; \
127             pass event_slugs, market_slugs, or PolymarketUpDownEventSlugConfig",
128        ));
129    }
130
131    Err(to_pyvalue_err(
132        "event_slug_builder must be PolymarketUpDownEventSlugConfig",
133    ))
134}
135
136fn extract_provider_config_from_pyobject(
137    obj: &Bound<'_, PyAny>,
138) -> PyResult<PolymarketInstrumentProviderConfig> {
139    if let Ok(config) = obj.extract::<PolymarketInstrumentProviderConfig>() {
140        return Ok(config);
141    }
142
143    let default = PolymarketInstrumentProviderConfig::default();
144    let load_all = getattr_optional(obj, "load_all")?
145        .map(|value| value.extract::<bool>())
146        .transpose()?
147        .unwrap_or(default.load_all);
148    let load_ids = getattr_optional(obj, "load_ids")?
149        .map(|value| value.extract::<Vec<InstrumentId>>())
150        .transpose()?;
151    let filters = getattr_optional(obj, "filters")?
152        .map(|value| extract_string_map(&value))
153        .transpose()?;
154    let event_slugs = getattr_optional(obj, "event_slugs")?
155        .map(|value| value.extract::<Vec<String>>())
156        .transpose()?;
157    let market_slugs = getattr_optional(obj, "market_slugs")?
158        .map(|value| value.extract::<Vec<String>>())
159        .transpose()?;
160    let event_slug_builder = getattr_optional(obj, "event_slug_builder")?
161        .map(|value| extract_event_slug_builder(&value))
162        .transpose()?;
163    let log_warnings = getattr_optional(obj, "log_warnings")?
164        .map(|value| value.extract::<bool>())
165        .transpose()?
166        .unwrap_or(default.log_warnings);
167    let use_gamma_markets = getattr_optional(obj, "use_gamma_markets")?
168        .map(|value| value.extract::<bool>())
169        .transpose()?
170        .unwrap_or(default.use_gamma_markets);
171
172    Ok(PolymarketInstrumentProviderConfig {
173        load_all: load_all || event_slug_builder.is_some(),
174        load_ids,
175        filters,
176        event_slugs,
177        market_slugs,
178        event_slug_builder,
179        log_warnings,
180        use_gamma_markets,
181    })
182}
183
184fn extract_data_config_from_pyobject(
185    py: Python<'_>,
186    config: &Py<PyAny>,
187) -> PyResult<PolymarketDataClientConfig> {
188    if let Ok(config) = config.extract::<PolymarketDataClientConfig>(py) {
189        return Ok(config);
190    }
191
192    let obj = config.bind(py);
193    let default = PolymarketDataClientConfig::default();
194    let instrument_config = getattr_optional(obj, "instrument_config")?
195        .map(|value| extract_provider_config_from_pyobject(&value))
196        .transpose()?;
197    let base_url_http = getattr_optional(obj, "base_url_http")?
198        .map(|value| value.extract::<String>())
199        .transpose()?;
200    let base_url_ws = getattr_optional(obj, "base_url_ws")?
201        .map(|value| value.extract::<String>())
202        .transpose()?;
203    let base_url_rtds = getattr_optional(obj, "base_url_rtds")?
204        .map(|value| value.extract::<String>())
205        .transpose()?;
206    let base_url_gamma = getattr_optional(obj, "base_url_gamma")?
207        .map(|value| value.extract::<String>())
208        .transpose()?;
209    let base_url_data_api = getattr_optional(obj, "base_url_data_api")?
210        .map(|value| value.extract::<String>())
211        .transpose()?;
212    let http_timeout_secs = getattr_optional(obj, "http_timeout_secs")?
213        .map(|value| value.extract::<u64>())
214        .transpose()?
215        .unwrap_or(default.http_timeout_secs);
216    let ws_timeout_secs = getattr_optional(obj, "ws_timeout_secs")?
217        .map(|value| value.extract::<u64>())
218        .transpose()?
219        .unwrap_or(default.ws_timeout_secs);
220    let ws_max_subscriptions = getattr_optional(obj, "ws_max_subscriptions")?
221        .map(|value| value.extract::<usize>())
222        .transpose()?
223        .unwrap_or(default.ws_max_subscriptions);
224    let update_instruments_interval_mins = getattr_optional_option_u64(
225        obj,
226        "update_instruments_interval_mins",
227        default.update_instruments_interval_mins,
228    )?;
229    let subscribe_new_markets = getattr_optional(obj, "subscribe_new_markets")?
230        .map(|value| value.extract::<bool>())
231        .transpose()?
232        .unwrap_or(default.subscribe_new_markets);
233    let new_market_fetch_max_concurrency =
234        getattr_optional(obj, "new_market_fetch_max_concurrency")?
235            .map(|value| value.extract::<usize>())
236            .transpose()?
237            .unwrap_or(default.new_market_fetch_max_concurrency);
238    let auto_load_missing_instruments = getattr_optional(obj, "auto_load_missing_instruments")?
239        .map(|value| value.extract::<bool>())
240        .transpose()?
241        .unwrap_or(default.auto_load_missing_instruments);
242    let auto_load_debounce_ms = getattr_optional(obj, "auto_load_debounce_ms")?
243        .map(|value| value.extract::<u64>())
244        .transpose()?
245        .unwrap_or(default.auto_load_debounce_ms);
246    let auto_load_max_retries = getattr_optional(obj, "auto_load_max_retries")?
247        .map(|value| value.extract::<u32>())
248        .transpose()?
249        .unwrap_or(default.auto_load_max_retries);
250    let auto_load_retry_delay_initial_secs =
251        getattr_optional(obj, "auto_load_retry_delay_initial_secs")?
252            .map(|value| value.extract::<f64>())
253            .transpose()?
254            .unwrap_or(default.auto_load_retry_delay_initial_secs);
255    let auto_load_retry_delay_max_secs = getattr_optional(obj, "auto_load_retry_delay_max_secs")?
256        .map(|value| value.extract::<f64>())
257        .transpose()?
258        .unwrap_or(default.auto_load_retry_delay_max_secs);
259    let resolve_poll_enabled = getattr_optional(obj, "resolve_poll_enabled")?
260        .map(|value| value.extract::<bool>())
261        .transpose()?
262        .unwrap_or(default.resolve_poll_enabled);
263    let resolve_poll_interval_secs = getattr_optional(obj, "resolve_poll_interval_secs")?
264        .map(|value| value.extract::<u64>())
265        .transpose()?
266        .unwrap_or(default.resolve_poll_interval_secs);
267    let resolve_poll_grace_secs = getattr_optional(obj, "resolve_poll_grace_secs")?
268        .map(|value| value.extract::<u64>())
269        .transpose()?
270        .unwrap_or(default.resolve_poll_grace_secs);
271    let resolve_poll_max_wait_secs = getattr_optional(obj, "resolve_poll_max_wait_secs")?
272        .map(|value| value.extract::<u64>())
273        .transpose()?
274        .unwrap_or(default.resolve_poll_max_wait_secs);
275    Ok(PolymarketDataClientConfig {
276        instrument_config,
277        base_url_http,
278        base_url_ws,
279        base_url_rtds,
280        base_url_gamma,
281        base_url_data_api,
282        http_timeout_secs,
283        ws_timeout_secs,
284        ws_max_subscriptions,
285        update_instruments_interval_mins,
286        subscribe_new_markets,
287        new_market_fetch_max_concurrency,
288        auto_load_missing_instruments,
289        auto_load_debounce_ms,
290        auto_load_max_retries,
291        auto_load_retry_delay_initial_secs,
292        auto_load_retry_delay_max_secs,
293        resolve_poll_enabled,
294        resolve_poll_interval_secs,
295        resolve_poll_grace_secs,
296        resolve_poll_max_wait_secs,
297        filters: Vec::new(),
298        new_market_filter: None,
299        transport_backend: default.transport_backend,
300    })
301}
302
303#[expect(clippy::needless_pass_by_value)]
304fn extract_polymarket_data_factory(
305    py: Python<'_>,
306    factory: Py<PyAny>,
307) -> PyResult<Box<dyn DataClientFactory>> {
308    match factory.extract::<PolymarketDataClientFactory>(py) {
309        Ok(f) => Ok(Box::new(f)),
310        Err(e) => Err(to_pyvalue_err(format!(
311            "Failed to extract PolymarketDataClientFactory: {e}"
312        ))),
313    }
314}
315
316#[expect(clippy::needless_pass_by_value)]
317fn extract_polymarket_exec_factory(
318    py: Python<'_>,
319    factory: Py<PyAny>,
320) -> PyResult<Box<dyn ExecutionClientFactory>> {
321    match factory.extract::<PolymarketExecutionClientFactory>(py) {
322        Ok(f) => Ok(Box::new(f)),
323        Err(e) => Err(to_pyvalue_err(format!(
324            "Failed to extract PolymarketExecutionClientFactory: {e}"
325        ))),
326    }
327}
328
329#[expect(clippy::needless_pass_by_value)]
330fn extract_polymarket_data_config(
331    py: Python<'_>,
332    config: Py<PyAny>,
333) -> PyResult<Box<dyn ClientConfig>> {
334    match extract_data_config_from_pyobject(py, &config) {
335        Ok(c) => Ok(Box::new(c)),
336        Err(e) => Err(to_pyvalue_err(format!(
337            "Failed to extract PolymarketDataClientConfig: {e}"
338        ))),
339    }
340}
341
342#[expect(clippy::needless_pass_by_value)]
343fn extract_polymarket_exec_config(
344    py: Python<'_>,
345    config: Py<PyAny>,
346) -> PyResult<Box<dyn ClientConfig>> {
347    match config.extract::<PolymarketExecClientConfig>(py) {
348        Ok(c) => Ok(Box::new(c)),
349        Err(e) => Err(to_pyvalue_err(format!(
350            "Failed to extract PolymarketExecClientConfig: {e}"
351        ))),
352    }
353}
354
355/// Loaded as `nautilus_pyo3.polymarket`.
356#[pymodule]
357pub fn polymarket(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
358    m.add_class::<crate::common::enums::SignatureType>()?;
359    m.add_class::<PolymarketUpDownEventSlugConfig>()?;
360    m.add_class::<PolymarketInstrumentProviderConfig>()?;
361    m.add_class::<PolymarketDataClientConfig>()?;
362    m.add_class::<PolymarketExecClientConfig>()?;
363    m.add_class::<PolymarketDataClientFactory>()?;
364    m.add_class::<PolymarketExecutionClientFactory>()?;
365    m.add_class::<PolymarketRtdsCryptoPrice>()?;
366    m.add_class::<PolymarketRtdsEquityPrice>()?;
367    m.add_function(pyo3::wrap_pyfunction!(
368        sort::py_polymarket_trade_sort_key,
369        m
370    )?)?;
371    m.add_function(pyo3::wrap_pyfunction!(sort::py_polymarket_trade_id, m)?)?;
372
373    register_polymarket_custom_data();
374    let _result = ensure_rust_extractor_registered::<PolymarketRtdsCryptoPrice>();
375    let _result = ensure_rust_extractor_registered::<PolymarketRtdsEquityPrice>();
376
377    let registry = get_global_pyo3_registry();
378
379    if let Err(e) =
380        registry.register_factory_extractor(POLYMARKET.to_string(), extract_polymarket_data_factory)
381    {
382        return Err(to_pyruntime_err(format!(
383            "Failed to register Polymarket data factory extractor: {e}"
384        )));
385    }
386
387    if let Err(e) = registry
388        .register_exec_factory_extractor(POLYMARKET.to_string(), extract_polymarket_exec_factory)
389    {
390        return Err(to_pyruntime_err(format!(
391            "Failed to register Polymarket exec factory extractor: {e}"
392        )));
393    }
394
395    if let Err(e) = registry.register_config_extractor(
396        "PolymarketDataClientConfig".to_string(),
397        extract_polymarket_data_config,
398    ) {
399        return Err(to_pyruntime_err(format!(
400            "Failed to register Polymarket data config extractor: {e}"
401        )));
402    }
403
404    if let Err(e) = registry.register_config_extractor(
405        "PolymarketExecClientConfig".to_string(),
406        extract_polymarket_exec_config,
407    ) {
408        return Err(to_pyruntime_err(format!(
409            "Failed to register Polymarket exec config extractor: {e}"
410        )));
411    }
412
413    Ok(())
414}
415
416#[cfg(all(test, feature = "python"))]
417mod tests {
418    use std::sync::Arc;
419
420    use nautilus_core::Params;
421    use nautilus_model::{
422        data::{CustomData, DataType, custom::CustomDataTrait, ensure_rust_extractor_registered},
423        types::Price,
424    };
425    use pyo3::{prelude::*, types::PyDict};
426    use rstest::rstest;
427    use serde_json::json;
428
429    use super::extract_data_config_from_pyobject;
430    use crate::{
431        config::PolymarketUpDownEventSlugConfig,
432        data_types::{PolymarketRtdsCryptoPrice, register_polymarket_custom_data},
433    };
434
435    #[rstest]
436    fn extract_data_config_supports_python_style_namespace() {
437        Python::initialize();
438        Python::attach(|py| {
439            let types = py.import("types").expect("types");
440            let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
441            let event_slug_builder = Py::new(
442                py,
443                PolymarketUpDownEventSlugConfig {
444                    assets: vec!["btc".to_string(), "eth".to_string()],
445                    interval_mins: 5,
446                    periods: 2,
447                    start_offset_periods: 0,
448                },
449            )
450            .expect("event slug builder should convert to Python object");
451
452            let instrument_kwargs = PyDict::new(py);
453            instrument_kwargs
454                .set_item("event_slug_builder", event_slug_builder)
455                .unwrap();
456            instrument_kwargs
457                .set_item("event_slugs", vec!["event-a", "event-b"])
458                .unwrap();
459            instrument_kwargs
460                .set_item("market_slugs", vec!["market-a"])
461                .unwrap();
462            instrument_kwargs.set_item("load_all", false).unwrap();
463            instrument_kwargs.set_item("log_warnings", false).unwrap();
464            let instrument_config = namespace
465                .call((), Some(&instrument_kwargs))
466                .expect("instrument namespace");
467
468            let config_kwargs = PyDict::new(py);
469            config_kwargs
470                .set_item("instrument_config", instrument_config)
471                .unwrap();
472            config_kwargs
473                .set_item("update_instruments_interval_mins", 1)
474                .unwrap();
475            config_kwargs
476                .set_item("subscribe_new_markets", false)
477                .unwrap();
478            config_kwargs
479                .set_item("new_market_fetch_max_concurrency", 13)
480                .unwrap();
481            config_kwargs
482                .set_item("base_url_gamma", "https://gamma.example")
483                .unwrap();
484            config_kwargs
485                .set_item("base_url_rtds", "wss://ws-live-data.example")
486                .unwrap();
487            config_kwargs
488                .set_item("base_url_data_api", "https://data.example")
489                .unwrap();
490            config_kwargs.set_item("ws_timeout_secs", 41).unwrap();
491            config_kwargs.set_item("ws_max_subscriptions", 512).unwrap();
492            config_kwargs
493                .set_item("auto_load_missing_instruments", true)
494                .unwrap();
495            config_kwargs
496                .set_item("auto_load_debounce_ms", 100)
497                .unwrap();
498            config_kwargs.set_item("auto_load_max_retries", 12).unwrap();
499            config_kwargs
500                .set_item("auto_load_retry_delay_initial_secs", 5.0)
501                .unwrap();
502            config_kwargs
503                .set_item("auto_load_retry_delay_max_secs", 15.0)
504                .unwrap();
505            config_kwargs
506                .set_item("resolve_poll_enabled", false)
507                .unwrap();
508            config_kwargs
509                .set_item("resolve_poll_interval_secs", 45)
510                .unwrap();
511            config_kwargs
512                .set_item("resolve_poll_grace_secs", 12)
513                .unwrap();
514            config_kwargs
515                .set_item("resolve_poll_max_wait_secs", 2400)
516                .unwrap();
517            let config_obj = namespace
518                .call((), Some(&config_kwargs))
519                .expect("config namespace");
520
521            let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
522                .expect("extract rust config");
523            let instrument_config = rust_config
524                .instrument_config
525                .expect("instrument_config should be extracted");
526
527            assert!(
528                instrument_config.load_all,
529                "event_slug_builder should imply scoped load_all bootstrap"
530            );
531            let event_slug_builder = instrument_config
532                .event_slug_builder
533                .expect("event_slug_builder should be extracted");
534            assert_eq!(
535                event_slug_builder.assets,
536                ["btc".to_string(), "eth".to_string()]
537            );
538            assert_eq!(event_slug_builder.interval_mins, 5);
539            assert_eq!(event_slug_builder.periods, 2);
540            assert_eq!(event_slug_builder.start_offset_periods, 0);
541            assert_eq!(
542                instrument_config.event_slugs.as_deref(),
543                Some(&["event-a".to_string(), "event-b".to_string()][..])
544            );
545            assert_eq!(
546                instrument_config.market_slugs.as_deref(),
547                Some(&["market-a".to_string()][..])
548            );
549            assert!(!instrument_config.log_warnings);
550            assert_eq!(rust_config.update_instruments_interval_mins, Some(1));
551            assert!(!rust_config.subscribe_new_markets);
552            assert_eq!(rust_config.new_market_fetch_max_concurrency, 13);
553            assert_eq!(
554                rust_config.base_url_gamma.as_deref(),
555                Some("https://gamma.example")
556            );
557            assert_eq!(
558                rust_config.base_url_rtds.as_deref(),
559                Some("wss://ws-live-data.example")
560            );
561            assert_eq!(
562                rust_config.base_url_data_api.as_deref(),
563                Some("https://data.example")
564            );
565            assert_eq!(rust_config.ws_timeout_secs, 41);
566            assert_eq!(rust_config.ws_max_subscriptions, 512);
567            assert!(!rust_config.resolve_poll_enabled);
568            assert_eq!(rust_config.resolve_poll_interval_secs, 45);
569            assert_eq!(rust_config.resolve_poll_grace_secs, 12);
570            assert_eq!(rust_config.resolve_poll_max_wait_secs, 2400);
571        });
572    }
573
574    #[rstest]
575    fn extract_data_config_rejects_python_callable_event_slug_builder() {
576        Python::initialize();
577        Python::attach(|py| {
578            let types = py.import("types").expect("types");
579            let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
580
581            let instrument_kwargs = PyDict::new(py);
582            instrument_kwargs
583                .set_item("event_slug_builder", "pkg.module:build_event_slugs")
584                .unwrap();
585            let instrument_config = namespace
586                .call((), Some(&instrument_kwargs))
587                .expect("instrument namespace");
588
589            let config_kwargs = PyDict::new(py);
590            config_kwargs
591                .set_item("instrument_config", instrument_config)
592                .unwrap();
593            let config_obj = namespace
594                .call((), Some(&config_kwargs))
595                .expect("config namespace");
596
597            let err = extract_data_config_from_pyobject(py, &config_obj.unbind())
598                .expect_err("Python callable event_slug_builder should be rejected");
599
600            assert!(
601                err.to_string()
602                    .contains("Python callable event_slug_builder is not supported")
603            );
604        });
605    }
606
607    #[rstest]
608    fn extract_data_config_preserves_none_update_interval() {
609        Python::initialize();
610        Python::attach(|py| {
611            let types = py.import("types").expect("types");
612            let namespace = types.getattr("SimpleNamespace").expect("SimpleNamespace");
613            let config_kwargs = PyDict::new(py);
614            config_kwargs
615                .set_item("update_instruments_interval_mins", py.None())
616                .unwrap();
617            let config_obj = namespace
618                .call((), Some(&config_kwargs))
619                .expect("config namespace");
620
621            let rust_config = extract_data_config_from_pyobject(py, &config_obj.unbind())
622                .expect("extract rust config");
623
624            assert_eq!(rust_config.update_instruments_interval_mins, None);
625        });
626    }
627
628    #[rstest]
629    fn custom_data_getter_unwraps_rtds_payload_to_python_class() {
630        Python::initialize();
631        Python::attach(|py| {
632            register_polymarket_custom_data();
633            let _result = ensure_rust_extractor_registered::<PolymarketRtdsCryptoPrice>();
634
635            let mut metadata = Params::new();
636            metadata.insert("symbol".to_string(), json!("btcusdt"));
637            let payload = Arc::new(PolymarketRtdsCryptoPrice::new(
638                "btcusdt".to_string(),
639                Price::from("67234.50"),
640                1_753_314_088_395,
641                1_753_314_088_421,
642                nautilus_core::UnixNanos::from_millis(1_753_314_088_395),
643                nautilus_core::UnixNanos::from_millis(1_753_314_088_421),
644            ));
645            let custom = CustomData::new(
646                payload,
647                DataType::new(
648                    PolymarketRtdsCryptoPrice::type_name_static(),
649                    Some(metadata),
650                    None,
651                ),
652            );
653
654            let py_custom = Py::new(py, custom).expect("create Python CustomData");
655            let py_payload = py_custom.bind(py).getattr("data").expect("CustomData.data");
656
657            assert_eq!(
658                py_payload.get_type().name().expect("type name"),
659                "PolymarketRtdsCryptoPrice"
660            );
661            assert_eq!(
662                py_payload
663                    .getattr("symbol")
664                    .expect("symbol")
665                    .extract::<String>()
666                    .expect("extract symbol"),
667                "btcusdt"
668            );
669            assert_eq!(
670                py_payload
671                    .getattr("value")
672                    .expect("value")
673                    .str()
674                    .expect("value str")
675                    .to_string(),
676                "67234.50"
677            );
678        });
679    }
680}