1#![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#[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}