1use std::collections::HashMap;
19
20use indexmap::IndexMap;
21use jiff::Timestamp;
22use nautilus_core::{
23 Params,
24 datetime::datetime_to_unix_nanos,
25 python::{
26 IntoPyObjectNautilusExt, params::value_to_pyobject, to_pyruntime_err, to_pyvalue_err,
27 },
28 time::get_atomic_clock_realtime,
29};
30use nautilus_model::{
31 data::TradeTick,
32 instruments::{BinaryOption, InstrumentAny},
33};
34use nautilus_network::retry::RetryConfig;
35use pyo3::{
36 conversion::IntoPyObjectExt,
37 prelude::*,
38 types::{PyDict, PyList},
39};
40use serde::Serialize;
41use serde_json::{Value, json};
42
43use super::extract_string_map;
44use crate::{
45 common::parse::validate_market_version,
46 http::{
47 clob::PolymarketClobPublicClient,
48 data_api::PolymarketDataApiHttpClient,
49 error::Error as PolymarketHttpError,
50 gamma::{PolymarketGammaHttpClient, flatten_event_markets},
51 models::{ClobMarketResponse, GammaEvent, GammaMarket},
52 parse::{create_instrument_from_def, enrich_market_fee_schedule, parse_gamma_market},
53 query::{GetGammaMarketsParams, GetSearchParams},
54 },
55 providers::{build_gamma_event_params_from_hashmap, build_gamma_params_from_hashmap},
56};
57
58#[pyclass(name = "PolymarketDataLoader")]
59#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")]
60#[derive(Clone, Debug)]
61pub struct PyPolymarketDataLoader {
62 instrument: BinaryOption,
63 token_id: String,
64 condition_id: String,
65 resolution_metadata: Value,
66 data_api_client: PolymarketDataApiHttpClient,
67}
68
69#[pymethods]
70#[pyo3_stub_gen::derive::gen_stub_pymethods]
71impl PyPolymarketDataLoader {
72 #[staticmethod]
74 #[pyo3(signature = (slug, base_url_gamma=None, timeout_secs=10))]
75 fn query_market_by_slug<'py>(
76 py: Python<'py>,
77 slug: String,
78 base_url_gamma: Option<String>,
79 timeout_secs: u64,
80 ) -> PyResult<Bound<'py, PyAny>> {
81 validate_non_empty("slug", &slug)?;
82
83 pyo3_async_runtimes::tokio::future_into_py(py, async move {
84 let client = gamma_client(base_url_gamma, timeout_secs)?;
85 let market = request_market_by_slug(&client, &slug).await?;
86 Python::attach(|py| serialize_to_py(py, &market))
87 })
88 }
89
90 #[staticmethod]
92 #[pyo3(signature = (condition_id, base_url_http=None, timeout_secs=10))]
93 fn query_market_details<'py>(
94 py: Python<'py>,
95 condition_id: String,
96 base_url_http: Option<String>,
97 timeout_secs: u64,
98 ) -> PyResult<Bound<'py, PyAny>> {
99 validate_non_empty("condition_id", &condition_id)?;
100
101 pyo3_async_runtimes::tokio::future_into_py(py, async move {
102 let client = clob_client(base_url_http, timeout_secs)?;
103 let market = client
104 .get_market(&condition_id)
105 .await
106 .map_err(to_pyruntime_err)?;
107 Python::attach(|py| serialize_to_py(py, &market))
108 })
109 }
110
111 #[staticmethod]
113 #[pyo3(signature = (slug, base_url_gamma=None, timeout_secs=10))]
114 fn query_event_by_slug<'py>(
115 py: Python<'py>,
116 slug: String,
117 base_url_gamma: Option<String>,
118 timeout_secs: u64,
119 ) -> PyResult<Bound<'py, PyAny>> {
120 validate_non_empty("slug", &slug)?;
121
122 pyo3_async_runtimes::tokio::future_into_py(py, async move {
123 let client = gamma_client(base_url_gamma, timeout_secs)?;
124 let event = request_event_by_slug(&client, &slug).await?;
125 Python::attach(|py| serialize_to_py(py, &event))
126 })
127 }
128
129 #[staticmethod]
131 #[pyo3(signature = (filters=None, base_url_gamma=None, timeout_secs=10))]
132 fn query_markets<'py>(
133 py: Python<'py>,
134 filters: Option<&Bound<'py, PyAny>>,
135 base_url_gamma: Option<String>,
136 timeout_secs: u64,
137 ) -> PyResult<Bound<'py, PyAny>> {
138 let filters = extract_filters(filters)?;
139 let params = build_gamma_params_from_hashmap(&filters).map_err(to_pyvalue_err)?;
140
141 pyo3_async_runtimes::tokio::future_into_py(py, async move {
142 let client = gamma_client(base_url_gamma, timeout_secs)?;
143 let markets = client
144 .request_markets_by_params(params)
145 .await
146 .map_err(to_pyruntime_err)?;
147 Python::attach(|py| serialize_to_py(py, &markets))
148 })
149 }
150
151 #[staticmethod]
153 #[pyo3(signature = (filters=None, base_url_gamma=None, timeout_secs=10))]
154 fn query_events<'py>(
155 py: Python<'py>,
156 filters: Option<&Bound<'py, PyAny>>,
157 base_url_gamma: Option<String>,
158 timeout_secs: u64,
159 ) -> PyResult<Bound<'py, PyAny>> {
160 let filters = extract_filters(filters)?;
161 let params = build_gamma_event_params_from_hashmap(&filters).map_err(to_pyvalue_err)?;
162
163 pyo3_async_runtimes::tokio::future_into_py(py, async move {
164 let client = gamma_client(base_url_gamma, timeout_secs)?;
165 let events = client
166 .request_events_by_params(params)
167 .await
168 .map_err(to_pyruntime_err)?;
169 Python::attach(|py| serialize_to_py(py, &events))
170 })
171 }
172
173 #[staticmethod]
175 #[pyo3(signature = (base_url_gamma=None, timeout_secs=10))]
176 fn query_tags<'py>(
177 py: Python<'py>,
178 base_url_gamma: Option<String>,
179 timeout_secs: u64,
180 ) -> PyResult<Bound<'py, PyAny>> {
181 pyo3_async_runtimes::tokio::future_into_py(py, async move {
182 let client = gamma_client(base_url_gamma, timeout_secs)?;
183 let tags = client.request_tags().await.map_err(to_pyruntime_err)?;
184 Python::attach(|py| serialize_to_py(py, &tags))
185 })
186 }
187
188 #[staticmethod]
190 #[pyo3(signature = (
191 query,
192 events_status=None,
193 events_tag=None,
194 sort=None,
195 ascending=None,
196 limit_per_type=None,
197 page=None,
198 keep_closed_markets=None,
199 base_url_gamma=None,
200 timeout_secs=10,
201 ))]
202 #[expect(clippy::too_many_arguments)]
203 fn query_search<'py>(
204 py: Python<'py>,
205 query: String,
206 events_status: Option<String>,
207 events_tag: Option<String>,
208 sort: Option<String>,
209 ascending: Option<bool>,
210 limit_per_type: Option<u32>,
211 page: Option<u32>,
212 keep_closed_markets: Option<bool>,
213 base_url_gamma: Option<String>,
214 timeout_secs: u64,
215 ) -> PyResult<Bound<'py, PyAny>> {
216 validate_non_empty("query", &query)?;
217
218 if limit_per_type == Some(0) {
219 return Err(to_pyvalue_err("limit_per_type must be greater than zero"));
220 }
221 let params = GetSearchParams {
222 q: Some(query),
223 events_status,
224 events_tag,
225 sort,
226 ascending,
227 limit_per_type,
228 page,
229 keep_closed_markets,
230 };
231
232 pyo3_async_runtimes::tokio::future_into_py(py, async move {
233 let client = gamma_client(base_url_gamma, timeout_secs)?;
234 let response = client
235 .inner()
236 .get_public_search(params)
237 .await
238 .map_err(to_pyruntime_err)?;
239 Python::attach(|py| serialize_to_py(py, &response))
240 })
241 }
242
243 #[staticmethod]
245 #[pyo3(signature = (
246 slug,
247 token_index=0,
248 base_url_http=None,
249 base_url_gamma=None,
250 base_url_data_api=None,
251 timeout_secs=10,
252 ))]
253 fn from_market_slug<'py>(
254 py: Python<'py>,
255 slug: String,
256 token_index: isize,
257 base_url_http: Option<String>,
258 base_url_gamma: Option<String>,
259 base_url_data_api: Option<String>,
260 timeout_secs: u64,
261 ) -> PyResult<Bound<'py, PyAny>> {
262 validate_non_empty("slug", &slug)?;
263 validate_token_index(token_index)?;
264
265 pyo3_async_runtimes::tokio::future_into_py(py, async move {
266 let gamma = gamma_client(base_url_gamma, timeout_secs)?;
267 let clob = clob_client(base_url_http, timeout_secs)?;
268 let data_api = data_api_client(base_url_data_api, timeout_secs)?;
269 let market = request_market_by_slug(&gamma, &slug).await?;
270 let loader =
271 build_loader(market, token_index as usize, &gamma, &clob, data_api).await?;
272 Python::attach(|py| Py::new(py, loader).map(Py::into_any))
273 })
274 }
275
276 #[staticmethod]
278 #[pyo3(signature = (
279 slug,
280 token_index=0,
281 base_url_http=None,
282 base_url_gamma=None,
283 base_url_data_api=None,
284 timeout_secs=10,
285 ))]
286 fn from_event_slug<'py>(
287 py: Python<'py>,
288 slug: String,
289 token_index: isize,
290 base_url_http: Option<String>,
291 base_url_gamma: Option<String>,
292 base_url_data_api: Option<String>,
293 timeout_secs: u64,
294 ) -> PyResult<Bound<'py, PyAny>> {
295 validate_non_empty("slug", &slug)?;
296 validate_token_index(token_index)?;
297
298 pyo3_async_runtimes::tokio::future_into_py(py, async move {
299 let gamma = gamma_client(base_url_gamma, timeout_secs)?;
300 let clob = clob_client(base_url_http, timeout_secs)?;
301 let data_api = data_api_client(base_url_data_api, timeout_secs)?;
302 let event = request_event_by_slug(&gamma, &slug).await?;
303 if event.markets.is_empty() {
304 return Err(to_pyvalue_err(format!(
305 "No markets found in event '{slug}'"
306 )));
307 }
308
309 let mut loaders = Vec::with_capacity(event.markets.len());
310 for market in flatten_event_markets(vec![event]) {
311 loaders.push(
312 build_loader(
313 market,
314 token_index as usize,
315 &gamma,
316 &clob,
317 data_api.clone(),
318 )
319 .await?,
320 );
321 }
322
323 Python::attach(|py| {
324 let loaders = loaders
325 .into_iter()
326 .map(|loader| Py::new(py, loader))
327 .collect::<PyResult<Vec<_>>>()?;
328 Ok(PyList::new(py, loaders)?.into_py_any_unwrap(py))
329 })
330 })
331 }
332
333 #[getter]
335 fn instrument(&self) -> BinaryOption {
336 self.instrument.clone()
337 }
338
339 #[getter]
341 fn token_id(&self) -> &str {
342 &self.token_id
343 }
344
345 #[getter]
347 fn condition_id(&self) -> &str {
348 &self.condition_id
349 }
350
351 #[getter]
356 fn resolution_metadata(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
357 value_to_pyobject(py, &self.resolution_metadata)
358 }
359
360 #[pyo3(signature = (start=None, end=None, limit=None))]
362 fn load_trades<'py>(
363 &self,
364 py: Python<'py>,
365 start: Option<Timestamp>,
366 end: Option<Timestamp>,
367 limit: Option<u32>,
368 ) -> PyResult<Bound<'py, PyAny>> {
369 if let (Some(start), Some(end)) = (start, end)
370 && start > end
371 {
372 return Err(to_pyvalue_err("start must not be later than end"));
373 }
374
375 if limit == Some(0) {
376 return Err(to_pyvalue_err("limit must be greater than zero"));
377 }
378
379 let client = self.data_api_client.clone();
380 let instrument = self.instrument.clone();
381 let condition_id = self.condition_id.clone();
382 let token_id = self.token_id.clone();
383 let start = datetime_to_unix_nanos(start);
384 let end = datetime_to_unix_nanos(end);
385
386 pyo3_async_runtimes::tokio::future_into_py(py, async move {
387 let trades = client
388 .request_trade_ticks(
389 instrument.id,
390 &condition_id,
391 &token_id,
392 instrument.price_precision,
393 instrument.size_precision,
394 start,
395 end,
396 limit,
397 )
398 .await
399 .map_err(to_pyruntime_err)?;
400 Python::attach(|py| trades_to_py(py, trades))
401 })
402 }
403}
404
405fn gamma_client(
406 base_url: Option<String>,
407 timeout_secs: u64,
408) -> PyResult<PolymarketGammaHttpClient> {
409 PolymarketGammaHttpClient::new(base_url, timeout_secs, RetryConfig::default())
410 .map_err(to_pyruntime_err)
411}
412
413fn clob_client(
414 base_url: Option<String>,
415 timeout_secs: u64,
416) -> PyResult<PolymarketClobPublicClient> {
417 PolymarketClobPublicClient::new(base_url, timeout_secs).map_err(to_pyruntime_err)
418}
419
420fn data_api_client(
421 base_url: Option<String>,
422 timeout_secs: u64,
423) -> PyResult<PolymarketDataApiHttpClient> {
424 PolymarketDataApiHttpClient::new(base_url, timeout_secs).map_err(to_pyruntime_err)
425}
426
427async fn request_market_by_slug(
428 client: &PolymarketGammaHttpClient,
429 slug: &str,
430) -> PyResult<GammaMarket> {
431 match client.inner().get_gamma_market_by_slug(slug).await {
432 Ok(market) => Ok(market),
433 Err(PolymarketHttpError::Http { status: 404, .. }) => Err(to_pyvalue_err(format!(
434 "Market with slug '{slug}' not found"
435 ))),
436 Err(e) => Err(to_pyruntime_err(e)),
437 }
438}
439
440async fn request_event_by_slug(
441 client: &PolymarketGammaHttpClient,
442 slug: &str,
443) -> PyResult<GammaEvent> {
444 client
445 .inner()
446 .get_gamma_events_by_slug(slug)
447 .await
448 .map_err(to_pyruntime_err)?
449 .into_iter()
450 .next()
451 .ok_or_else(|| to_pyvalue_err(format!("Event with slug '{slug}' not found")))
452}
453
454async fn build_loader(
455 mut market: GammaMarket,
456 token_index: usize,
457 gamma: &PolymarketGammaHttpClient,
458 clob: &PolymarketClobPublicClient,
459 data_api_client: PolymarketDataApiHttpClient,
460) -> PyResult<PyPolymarketDataLoader> {
461 validate_market_version(market.version.as_deref()).map_err(to_pyvalue_err)?;
462 if market.condition_id.trim().is_empty() {
463 return Err(to_pyvalue_err("Gamma market has an empty condition ID"));
464 }
465
466 if market.fee_schedule.is_none() {
467 market.fee_schedule = gamma
468 .request_markets_by_params(GetGammaMarketsParams {
469 condition_ids: Some(vec![market.condition_id.clone()]),
470 max_markets: Some(1),
471 ..Default::default()
472 })
473 .await
474 .map_err(to_pyruntime_err)?
475 .into_iter()
476 .find(|candidate| candidate.condition_id == market.condition_id)
477 .and_then(|candidate| candidate.fee_schedule);
478 }
479
480 enrich_market_fee_schedule(&mut market);
481
482 let details = clob
483 .get_market(&market.condition_id)
484 .await
485 .map_err(to_pyruntime_err)?;
486
487 build_loader_from_details(market, &details, token_index, data_api_client)
488}
489
490fn build_loader_from_details(
491 mut market: GammaMarket,
492 details: &ClobMarketResponse,
493 token_index: usize,
494 data_api_client: PolymarketDataApiHttpClient,
495) -> PyResult<PyPolymarketDataLoader> {
496 validate_market_version(market.version.as_deref()).map_err(to_pyvalue_err)?;
497 validate_market_version(details.version.as_deref()).map_err(to_pyvalue_err)?;
498 validate_market_details(details, &market.condition_id, token_index)?;
499
500 market.clob_token_ids = serde_json::to_string(
501 &details
502 .tokens
503 .iter()
504 .map(|token| token.token_id.as_str())
505 .collect::<Vec<_>>(),
506 )
507 .map_err(to_pyruntime_err)?;
508 market.outcomes = serde_json::to_string(
509 &details
510 .tokens
511 .iter()
512 .map(|token| token.outcome.as_str())
513 .collect::<Vec<_>>(),
514 )
515 .map_err(to_pyruntime_err)?;
516
517 let def = parse_gamma_market(&market)
518 .map_err(to_pyvalue_err)?
519 .into_iter()
520 .nth(token_index)
521 .ok_or_else(|| to_pyvalue_err("Selected token has no instrument definition"))?;
522
523 let mut instrument =
524 match create_instrument_from_def(&def, get_atomic_clock_realtime().get_time_ns())
525 .map_err(to_pyvalue_err)?
526 {
527 InstrumentAny::BinaryOption(instrument) => instrument,
528 _ => return Err(to_pyruntime_err("Expected a BinaryOption instrument")),
529 };
530
531 if let Some(info) = instrument.info.as_ref() {
533 let mut info = IndexMap::clone(info);
534 info.shift_remove("gamma_market");
535 info.shift_remove("gamma_event");
536 instrument.info = Some(Params::new(info));
537 }
538
539 let resolution_metadata = resolution_metadata(&market, details);
540 let token_id = details.tokens[token_index].token_id.clone();
541 let condition_id = market.condition_id;
542
543 Ok(PyPolymarketDataLoader {
544 instrument,
545 token_id,
546 condition_id,
547 resolution_metadata,
548 data_api_client,
549 })
550}
551
552fn validate_market_details(
553 details: &ClobMarketResponse,
554 condition_id: &str,
555 token_index: usize,
556) -> PyResult<()> {
557 if details.condition_id != condition_id {
558 return Err(to_pyvalue_err(format!(
559 "CLOB market condition ID '{}' does not match Gamma condition ID '{condition_id}'",
560 details.condition_id
561 )));
562 }
563
564 if details.tokens.is_empty() {
565 return Err(to_pyvalue_err(format!(
566 "No tokens found for market '{condition_id}'"
567 )));
568 }
569
570 if token_index >= details.tokens.len() {
571 return Err(to_pyvalue_err(format!(
572 "Token index {token_index} out of range for market '{condition_id}' with {} tokens",
573 details.tokens.len()
574 )));
575 }
576
577 if details.tokens[token_index].token_id.trim().is_empty() {
578 return Err(to_pyvalue_err(format!(
579 "Token index {token_index} for market '{condition_id}' has an empty token ID"
580 )));
581 }
582
583 if details.tokens[token_index].outcome.trim().is_empty() {
584 return Err(to_pyvalue_err(format!(
585 "Token index {token_index} for market '{condition_id}' has an empty outcome"
586 )));
587 }
588 Ok(())
589}
590
591fn resolution_metadata(market: &GammaMarket, details: &ClobMarketResponse) -> Value {
592 json!({
593 "gamma_market": market.raw,
594 "gamma_event": market.parent_event.as_ref().map(|event| &event.raw),
595 "closed": details.closed,
596 "closedTime": market.closed_time,
597 "umaResolutionStatus": market.uma_resolution_status,
598 "resolutionSource": market.resolution_source,
599 "tokens": details.tokens,
600 })
601}
602
603fn validate_non_empty(name: &str, value: &str) -> PyResult<()> {
604 if value.trim().is_empty() {
605 Err(to_pyvalue_err(format!("{name} cannot be empty")))
606 } else {
607 Ok(())
608 }
609}
610
611fn validate_token_index(token_index: isize) -> PyResult<()> {
612 if token_index < 0 {
613 Err(to_pyvalue_err(format!(
614 "Token index {token_index} cannot be negative"
615 )))
616 } else {
617 Ok(())
618 }
619}
620
621fn extract_filters(filters: Option<&Bound<'_, PyAny>>) -> PyResult<HashMap<String, String>> {
622 filters
623 .map(extract_string_map)
624 .transpose()
625 .map(Option::unwrap_or_default)
626}
627
628fn serialize_to_py<T: Serialize>(py: Python<'_>, value: &T) -> PyResult<Py<PyAny>> {
629 let encoded = serde_json::to_string(value).map_err(to_pyruntime_err)?;
630 let kwargs = PyDict::new(py);
631 kwargs.set_item(
632 "parse_float",
633 PyModule::import(py, "decimal")?.getattr("Decimal")?,
634 )?;
635 Ok(PyModule::import(py, "json")?
636 .call_method("loads", (encoded,), Some(&kwargs))?
637 .unbind())
638}
639
640fn trades_to_py(py: Python<'_>, trades: Vec<TradeTick>) -> PyResult<Py<PyAny>> {
641 let trades = trades
642 .into_iter()
643 .map(|trade| trade.into_py_any(py))
644 .collect::<PyResult<Vec<_>>>()?;
645 Ok(PyList::new(py, trades)?.into_py_any_unwrap(py))
646}
647
648#[cfg(test)]
649mod tests {
650 use std::sync::{
651 Arc,
652 atomic::{AtomicUsize, Ordering},
653 };
654
655 use pyo3::exceptions::PyValueError;
656 use rstest::rstest;
657 use serde_json::json;
658
659 use super::*;
660
661 #[rstest]
662 fn test_discovery_python_decimal_precision() {
663 let market: GammaMarket = serde_json::from_str(include_str!(
664 "../../test_data/decimal_precision_market.json"
665 ))
666 .unwrap();
667 Python::initialize();
668 Python::attach(|py| {
669 let output = serialize_to_py(py, &vec![market]).unwrap();
670 let market = output.bind(py).get_item(0).unwrap();
671 let decimal = py.import("decimal").unwrap().getattr("Decimal").unwrap();
672
673 for (field, expected) in [
674 ("bestBid", "0.1234567890123456789012345678"),
675 ("bestAsk", "0.2345678901234567890123456789"),
676 ("liquidityNum", "12345678901.123456"),
677 ("volumeNum", "12345678901.123457"),
678 ] {
679 let actual = market.get_item(field).unwrap();
680 assert!(actual.is_instance(&decimal).unwrap());
681 assert_eq!(actual.str().unwrap().to_str().unwrap(), expected);
682 }
683 let fee = market.get_item("feeSchedule").unwrap();
684 assert_eq!(
685 fee.get_item("exponent")
686 .unwrap()
687 .str()
688 .unwrap()
689 .to_str()
690 .unwrap(),
691 "1.234567890123456789012345678"
692 );
693 assert!(
694 fee.get_item("takerOnly")
695 .unwrap()
696 .extract::<bool>()
697 .unwrap()
698 );
699 });
700 }
701
702 #[rstest]
703 fn test_nested_discovery_python_decimal_precision() {
704 let market: GammaMarket = serde_json::from_str(include_str!(
705 "../../test_data/decimal_precision_market.json"
706 ))
707 .unwrap();
708 let mut event: GammaEvent =
709 serde_json::from_str(include_str!("../../test_data/decimal_precision_event.json"))
710 .unwrap();
711 event.markets = vec![market.clone()];
712 let search = crate::http::models::SearchResponse {
713 markets: Some(vec![market]),
714 events: Some(vec![event]),
715 };
716 let clob_raw = include_str!("../../test_data/clob_market_response.json")
717 .replace(
718 "\"max_spread\": 4.5",
719 "\"max_spread\": 0.1234567890123456789012345678",
720 )
721 .replace(
722 "\"price\": 0.715",
723 "\"price\": 0.2345678901234567890123456789",
724 );
725 let clob: ClobMarketResponse = serde_json::from_str(&clob_raw).unwrap();
726 Python::initialize();
727 Python::attach(|py| {
728 let result = serialize_to_py(py, &search).unwrap();
729 let event = result
730 .bind(py)
731 .get_item("events")
732 .unwrap()
733 .get_item(0)
734 .unwrap();
735 let market = event.get_item("markets").unwrap().get_item(0).unwrap();
736 let details = serialize_to_py(py, &clob).unwrap();
737 let decimal = py.import("decimal").unwrap().getattr("Decimal").unwrap();
738 for (actual, expected) in [
739 (event.get_item("volume").unwrap(), "12345678901.123457"),
740 (
741 market.get_item("bestAsk").unwrap(),
742 "0.2345678901234567890123456789",
743 ),
744 (
745 market
746 .get_item("feeSchedule")
747 .unwrap()
748 .get_item("rate")
749 .unwrap(),
750 "0.1234567890123456789012345678",
751 ),
752 (
753 details
754 .bind(py)
755 .get_item("rewards")
756 .unwrap()
757 .get_item("max_spread")
758 .unwrap(),
759 "0.1234567890123456789012345678",
760 ),
761 (
762 details
763 .bind(py)
764 .get_item("tokens")
765 .unwrap()
766 .get_item(0)
767 .unwrap()
768 .get_item("price")
769 .unwrap(),
770 "0.2345678901234567890123456789",
771 ),
772 ] {
773 assert!(actual.is_instance(&decimal).unwrap());
774 assert_eq!(actual.str().unwrap().to_str().unwrap(), expected);
775 }
776 assert_eq!(
777 details
778 .bind(py)
779 .get_item("seconds_delay")
780 .unwrap()
781 .extract::<i64>()
782 .unwrap(),
783 1
784 );
785 assert!(market.get_item("closed").unwrap().is_none());
786 assert_eq!(
787 market.get_item("id").unwrap().extract::<String>().unwrap(),
788 "precision-market"
789 );
790 });
791 }
792
793 fn gamma_market() -> GammaMarket {
794 serde_json::from_value(json!({
795 "id": "100001",
796 "conditionId": "0xcondition",
797 "questionID": "0xquestion",
798 "version": "v1",
799 "clobTokenIds": "[]",
800 "outcomes": "[]",
801 "question": "Will the test pass?",
802 "description": "Test market",
803 "startDate": "2026-01-01T00:00:00Z",
804 "endDate": "2026-12-31T00:00:00Z",
805 "active": false,
806 "closed": true,
807 "closedTime": "2026-06-01T00:00:00Z",
808 "umaResolutionStatus": "resolved",
809 "resolutionSource": "https://example.com/result",
810 "acceptingOrders": false,
811 "enableOrderBook": true,
812 "orderPriceMinTickSize": 0.01,
813 "slug": "test-market",
814 "negRisk": false,
815 "feeSchedule": {
816 "exponent": 2.0,
817 "rate": 0.02,
818 "takerOnly": true,
819 "rebateRate": 0.0
820 },
821 "events": []
822 }))
823 .expect("valid Gamma market")
824 }
825
826 fn gamma_market_with_distinct_crypto_config() -> GammaMarket {
827 let mut value = serde_json::to_value(gamma_market()).expect("serializable Gamma market");
828 value["cryptoMarketConfig"] = json!({
829 "id": "eth-15m-twap-negative-37",
830 "asset": "eth",
831 "duration": "15m",
832 "twapEnabled": true,
833 "twapLookbackSeconds": -37,
834 });
835
836 serde_json::from_value(value).expect("valid distinct crypto market config")
837 }
838
839 fn clob_market() -> ClobMarketResponse {
840 serde_json::from_value(json!({
841 "version": "v1",
842 "condition_id": "0xcondition",
843 "closed": true,
844 "tokens": [
845 {"token_id": "yes-token", "outcome": "Yes", "winner": true},
846 {"token_id": "no-token", "outcome": "No", "winner": false}
847 ]
848 }))
849 .expect("valid CLOB market")
850 }
851
852 fn data_api() -> PolymarketDataApiHttpClient {
853 PolymarketDataApiHttpClient::new(Some("http://127.0.0.1:1".to_string()), 1)
854 .expect("valid test client")
855 }
856
857 #[rstest]
858 #[case(0, "Yes")]
859 #[case(1, "No")]
860 fn build_loader_retains_parent_event_without_exposing_raw_snapshots(
861 #[case] token_index: usize,
862 #[case] outcome: &str,
863 ) {
864 let events: Vec<GammaEvent> =
865 serde_json::from_str(include_str!("../../test_data/gamma_event.json")).unwrap();
866 let markets = flatten_event_markets(events);
867 assert_eq!(markets.len(), 2);
868
869 for market in markets {
870 let parent = market.parent_event.as_ref().unwrap();
871 let expected_id = parent.id.clone();
872 let expected_event = parent.raw.clone();
873 let expected_market = market.raw.clone();
874 let mut details = clob_market();
875 details.condition_id.clone_from(&market.condition_id);
876 let loader =
877 build_loader_from_details(market, &details, token_index, data_api()).unwrap();
878 let info = loader.instrument.info.as_ref().unwrap();
879
880 assert_eq!(loader.instrument.event_id.unwrap().as_str(), expected_id);
881 assert_eq!(loader.instrument.outcome.unwrap().as_str(), outcome);
882 assert_eq!(info.get_str("event_id"), Some(expected_id.as_str()));
883 assert!(!info.contains_key("gamma_market"));
884 assert!(!info.contains_key("gamma_event"));
885 assert_eq!(loader.resolution_metadata["gamma_market"], expected_market);
886 assert_eq!(loader.resolution_metadata["gamma_event"], expected_event);
887 }
888 }
889
890 #[rstest]
891 fn build_loader_selects_token_and_retains_resolution_lifecycle_metadata() {
892 let loader = build_loader_from_details(gamma_market(), &clob_market(), 1, data_api())
893 .expect("loader should build");
894 let info = loader.instrument.info.as_ref().expect("instrument info");
895
896 assert_eq!(loader.token_id, "no-token");
897 assert_eq!(loader.condition_id, "0xcondition");
898 assert_eq!(
899 loader.instrument.outcome.map(|value| value.to_string()),
900 Some("No".to_string())
901 );
902 assert_eq!(loader.resolution_metadata["closed"], true);
903 assert_eq!(loader.resolution_metadata["tokens"][0]["winner"], true);
904 assert_eq!(
905 info.get_str("resolution_source"),
906 Some("https://example.com/result")
907 );
908 assert_eq!(info.get_str("description"), Some("Test market"));
909 assert!(!info.contains_key("gamma_market"));
910 assert_eq!(
911 loader.resolution_metadata["gamma_market"],
912 gamma_market().raw
913 );
914 assert!(!info.contains_key("closed"));
915 assert!(!info.contains_key("closedTime"));
916 assert!(!info.contains_key("umaResolutionStatus"));
917 assert!(!info.contains_key("resolutionSource"));
918 assert!(!info.contains_key("winner"));
919
920 Python::initialize();
921 Python::attach(|py| {
922 let py_loader = Py::new(py, loader).expect("Python loader");
923 assert_eq!(
924 py_loader
925 .getattr(py, "token_id")
926 .expect("token ID getter")
927 .extract::<String>(py)
928 .expect("token ID string"),
929 "no-token",
930 );
931 assert_eq!(
932 py_loader
933 .getattr(py, "condition_id")
934 .expect("condition ID getter")
935 .extract::<String>(py)
936 .expect("condition ID string"),
937 "0xcondition",
938 );
939 let metadata = py_loader
940 .getattr(py, "resolution_metadata")
941 .expect("resolution metadata getter");
942 assert!(
943 metadata
944 .bind(py)
945 .get_item("closed")
946 .expect("closed metadata")
947 .extract::<bool>()
948 .expect("closed bool"),
949 );
950 });
951 }
952
953 #[rstest]
954 fn build_loader_preserves_distinct_crypto_config_in_rust_and_python_instrument_info() {
955 let loader = build_loader_from_details(
956 gamma_market_with_distinct_crypto_config(),
957 &clob_market(),
958 0,
959 data_api(),
960 )
961 .expect("loader should build");
962 let info = loader.instrument.info.as_ref().expect("instrument info");
963 let expected = json!({
964 "id": "eth-15m-twap-negative-37",
965 "asset": "eth",
966 "duration": "15m",
967 "twapEnabled": true,
968 "twapLookbackSeconds": -37,
969 });
970
971 assert_eq!(info.get("crypto_market_config"), Some(&expected));
972
973 Python::initialize();
974 Python::attach(|py| {
975 let py_loader = Py::new(py, loader).expect("Python loader");
976 let instrument = py_loader
977 .getattr(py, "instrument")
978 .expect("instrument getter");
979 let info = instrument
980 .getattr(py, "info")
981 .expect("instrument info getter");
982 let config = info
983 .bind(py)
984 .get_item("crypto_market_config")
985 .expect("crypto market config");
986
987 assert_eq!(
988 config
989 .get_item("id")
990 .expect("config ID")
991 .extract::<String>()
992 .expect("config ID string"),
993 "eth-15m-twap-negative-37",
994 );
995 assert_eq!(
996 config
997 .get_item("asset")
998 .expect("config asset")
999 .extract::<String>()
1000 .expect("config asset string"),
1001 "eth",
1002 );
1003 assert_eq!(
1004 config
1005 .get_item("duration")
1006 .expect("config duration")
1007 .extract::<String>()
1008 .expect("config duration string"),
1009 "15m",
1010 );
1011 assert!(
1012 config
1013 .get_item("twapEnabled")
1014 .expect("TWAP enabled")
1015 .extract::<bool>()
1016 .expect("TWAP enabled bool"),
1017 );
1018 assert_eq!(
1019 config
1020 .get_item("twapLookbackSeconds")
1021 .expect("TWAP lookback")
1022 .extract::<i64>()
1023 .expect("TWAP lookback integer"),
1024 -37,
1025 );
1026 });
1027 }
1028
1029 #[rstest]
1030 #[case(-1, "cannot be negative")]
1031 fn validate_token_index_rejects_negative_values(#[case] index: isize, #[case] message: &str) {
1032 let error = validate_token_index(index).expect_err("index should be rejected");
1033
1034 Python::initialize();
1035 Python::attach(|py| assert!(error.is_instance_of::<PyValueError>(py)));
1036 assert!(error.to_string().contains(message));
1037 }
1038
1039 #[rstest]
1040 #[case(Some("v2"))]
1041 #[case(None)]
1042 #[case(Some("unknown"))]
1043 fn build_loader_rejects_unsupported_protocol(
1044 #[case] version: Option<&str>,
1045 #[values(false, true)] clob_unsupported: bool,
1046 ) {
1047 Python::initialize();
1048 let mut market = gamma_market();
1049 let mut details = clob_market();
1050 if clob_unsupported {
1051 details.version = version.map(str::to_string);
1052 } else {
1053 market.version = version.map(str::to_string);
1054 }
1055
1056 let error = build_loader_from_details(market, &details, 0, data_api()).unwrap_err();
1057 assert!(error.to_string().contains(&format!(
1058 "Unsupported Polymarket market protocol {}; only v1 CTF markets are supported",
1059 version.unwrap_or("missing"),
1060 )));
1061 }
1062
1063 #[rstest]
1064 fn build_loader_rejects_out_of_range_index() {
1065 Python::initialize();
1066
1067 let error = build_loader_from_details(gamma_market(), &clob_market(), 2, data_api())
1068 .expect_err("index should be rejected");
1069
1070 assert!(error.to_string().contains("Token index 2 out of range"));
1071 }
1072
1073 #[rstest]
1074 fn build_loader_rejects_empty_token_list() {
1075 Python::initialize();
1076
1077 let mut details = clob_market();
1078 details.tokens.clear();
1079
1080 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
1081 .expect_err("empty tokens should be rejected");
1082
1083 assert!(error.to_string().contains("No tokens found"));
1084 }
1085
1086 #[rstest]
1087 fn build_loader_rejects_transient_empty_token_id() {
1088 Python::initialize();
1089
1090 let mut details = clob_market();
1091 details.tokens[0].token_id.clear();
1092
1093 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
1094 .expect_err("empty token ID should be rejected");
1095
1096 assert!(error.to_string().contains("has an empty token ID"));
1097 }
1098
1099 #[rstest]
1100 fn build_loader_rejects_malformed_non_binary_token_payload() {
1101 Python::initialize();
1102
1103 let mut details = clob_market();
1104 details.tokens.truncate(1);
1105
1106 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
1107 .expect_err("non-binary tokens should be rejected");
1108
1109 assert!(error.to_string().contains("Expected 2 token IDs"));
1110 }
1111
1112 #[rstest]
1113 fn build_loader_rejects_mismatched_condition_id() {
1114 Python::initialize();
1115
1116 let mut details = clob_market();
1117 details.condition_id = "0xdifferent".to_string();
1118
1119 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
1120 .expect_err("condition mismatch should be rejected");
1121
1122 assert!(
1123 error
1124 .to_string()
1125 .contains("does not match Gamma condition ID")
1126 );
1127 }
1128
1129 #[rstest]
1130 #[case(false, rust_decimal::Decimal::ZERO)]
1131 #[case(true, rust_decimal::Decimal::ZERO)]
1132 #[case(true, rust_decimal::Decimal::new(4, 2))]
1133 #[tokio::test]
1134 async fn build_loader_uses_gamma_taker_rate_without_clob_fee_rate(
1135 #[case] missing_schedule: bool,
1136 #[case] expected_rate: rust_decimal::Decimal,
1137 ) {
1138 Python::initialize();
1139
1140 let calls = Arc::new(AtomicUsize::new(0));
1141 let asserted = Arc::clone(&calls);
1142 let details = serde_json::to_string(&clob_market()).unwrap();
1143
1144 let mut market = gamma_market();
1145 market.fees_enabled = Some(true);
1146 market.fee_type = Some("crypto_fees".to_string());
1147 market.fee_schedule = Some(crate::http::models::FeeSchedule {
1148 exponent: rust_decimal::Decimal::ONE,
1149 rate: expected_rate,
1150 taker_only: true,
1151 rebate_rate: rust_decimal::Decimal::ZERO,
1152 });
1153
1154 let recovery = serde_json::to_string(&json!({"markets": [market.clone()]})).unwrap();
1155
1156 if missing_schedule {
1157 market.fee_schedule = None;
1158 }
1159
1160 let router = axum::Router::new()
1161 .route(
1162 "/markets/keyset",
1163 axum::routing::get(move || {
1164 let recovery = recovery.clone();
1165 async move { recovery }
1166 }),
1167 )
1168 .route(
1169 "/markets/0xcondition",
1170 axum::routing::get(move || {
1171 let details = details.clone();
1172
1173 async move { details }
1174 }),
1175 )
1176 .route(
1177 "/fee-rate",
1178 axum::routing::get(move || {
1179 let calls = Arc::clone(&calls);
1180
1181 async move {
1182 calls.fetch_add(1, Ordering::SeqCst);
1183 include_str!("../../test_data/clob_fee_rate_response_nonzero.json")
1184 .to_string()
1185 }
1186 }),
1187 );
1188
1189 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1190 let address = listener.local_addr().unwrap();
1191 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1192
1193 let gamma = PolymarketGammaHttpClient::new(
1194 Some(format!("http://{address}")),
1195 5,
1196 RetryConfig::default(),
1197 )
1198 .expect("valid test client");
1199 let clob = PolymarketClobPublicClient::new(Some(format!("http://{address}")), 5).unwrap();
1200
1201 let loader = build_loader(market, 0, &gamma, &clob, data_api())
1202 .await
1203 .expect("loader should build");
1204 server.abort();
1205
1206 assert_eq!(asserted.load(Ordering::SeqCst), 0);
1207 assert_eq!(loader.token_id, "yes-token");
1208
1209 let info = loader.instrument.info.as_ref().unwrap();
1210 let schedule: crate::http::models::FeeSchedule =
1211 serde_json::from_value(info.get("fee_schedule").unwrap().clone()).unwrap();
1212 assert_eq!(schedule.rate, expected_rate);
1213 assert_eq!(schedule.exponent, rust_decimal::Decimal::ONE);
1214 assert!(schedule.taker_only);
1215 assert_eq!(schedule.rebate_rate, rust_decimal::Decimal::new(2, 1));
1216 }
1217}