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