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::{conversion::IntoPyObjectExt, prelude::*, types::PyList};
34use serde::Serialize;
35use serde_json::{Value, json};
36
37use super::extract_string_map;
38use crate::{
39 http::{
40 clob::PolymarketClobPublicClient,
41 data_api::PolymarketDataApiHttpClient,
42 error::Error as PolymarketHttpError,
43 gamma::PolymarketGammaHttpClient,
44 models::{ClobMarketResponse, GammaEvent, GammaMarket},
45 parse::{create_instrument_from_def, parse_gamma_market},
46 query::{GetGammaMarketsParams, GetSearchParams},
47 },
48 providers::{build_gamma_event_params_from_hashmap, build_gamma_params_from_hashmap},
49};
50
51#[pyclass(name = "PolymarketDataLoader", skip_from_py_object)]
52#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")]
53#[derive(Clone, Debug)]
54pub struct PyPolymarketDataLoader {
55 instrument: BinaryOption,
56 token_id: String,
57 condition_id: String,
58 resolution_metadata: Value,
59 data_api_client: PolymarketDataApiHttpClient,
60}
61
62#[pymethods]
63#[pyo3_stub_gen::derive::gen_stub_pymethods]
64impl PyPolymarketDataLoader {
65 #[staticmethod]
67 #[pyo3(signature = (slug, base_url_gamma=None, timeout_secs=10))]
68 fn query_market_by_slug<'py>(
69 py: Python<'py>,
70 slug: String,
71 base_url_gamma: Option<String>,
72 timeout_secs: u64,
73 ) -> PyResult<Bound<'py, PyAny>> {
74 validate_non_empty("slug", &slug)?;
75
76 pyo3_async_runtimes::tokio::future_into_py(py, async move {
77 let client = gamma_client(base_url_gamma, timeout_secs)?;
78 let market = request_market_by_slug(&client, &slug).await?;
79 Python::attach(|py| serialize_to_py(py, &market))
80 })
81 }
82
83 #[staticmethod]
85 #[pyo3(signature = (condition_id, base_url_http=None, timeout_secs=10))]
86 fn query_market_details<'py>(
87 py: Python<'py>,
88 condition_id: String,
89 base_url_http: Option<String>,
90 timeout_secs: u64,
91 ) -> PyResult<Bound<'py, PyAny>> {
92 validate_non_empty("condition_id", &condition_id)?;
93
94 pyo3_async_runtimes::tokio::future_into_py(py, async move {
95 let client = clob_client(base_url_http, timeout_secs)?;
96 let market = client
97 .get_market(&condition_id)
98 .await
99 .map_err(to_pyruntime_err)?;
100 Python::attach(|py| serialize_to_py(py, &market))
101 })
102 }
103
104 #[staticmethod]
106 #[pyo3(signature = (slug, base_url_gamma=None, timeout_secs=10))]
107 fn query_event_by_slug<'py>(
108 py: Python<'py>,
109 slug: String,
110 base_url_gamma: Option<String>,
111 timeout_secs: u64,
112 ) -> PyResult<Bound<'py, PyAny>> {
113 validate_non_empty("slug", &slug)?;
114
115 pyo3_async_runtimes::tokio::future_into_py(py, async move {
116 let client = gamma_client(base_url_gamma, timeout_secs)?;
117 let event = request_event_by_slug(&client, &slug).await?;
118 Python::attach(|py| serialize_to_py(py, &event))
119 })
120 }
121
122 #[staticmethod]
124 #[pyo3(signature = (filters=None, base_url_gamma=None, timeout_secs=10))]
125 fn query_markets<'py>(
126 py: Python<'py>,
127 filters: Option<&Bound<'py, PyAny>>,
128 base_url_gamma: Option<String>,
129 timeout_secs: u64,
130 ) -> PyResult<Bound<'py, PyAny>> {
131 let filters = extract_filters(filters)?;
132 let params = build_gamma_params_from_hashmap(&filters).map_err(to_pyvalue_err)?;
133
134 pyo3_async_runtimes::tokio::future_into_py(py, async move {
135 let client = gamma_client(base_url_gamma, timeout_secs)?;
136 let markets = client
137 .request_markets_by_params(params)
138 .await
139 .map_err(to_pyruntime_err)?;
140 Python::attach(|py| serialize_to_py(py, &markets))
141 })
142 }
143
144 #[staticmethod]
146 #[pyo3(signature = (filters=None, base_url_gamma=None, timeout_secs=10))]
147 fn query_events<'py>(
148 py: Python<'py>,
149 filters: Option<&Bound<'py, PyAny>>,
150 base_url_gamma: Option<String>,
151 timeout_secs: u64,
152 ) -> PyResult<Bound<'py, PyAny>> {
153 let filters = extract_filters(filters)?;
154 let params = build_gamma_event_params_from_hashmap(&filters).map_err(to_pyvalue_err)?;
155
156 pyo3_async_runtimes::tokio::future_into_py(py, async move {
157 let client = gamma_client(base_url_gamma, timeout_secs)?;
158 let events = client
159 .request_events_by_params(params)
160 .await
161 .map_err(to_pyruntime_err)?;
162 Python::attach(|py| serialize_to_py(py, &events))
163 })
164 }
165
166 #[staticmethod]
168 #[pyo3(signature = (base_url_gamma=None, timeout_secs=10))]
169 fn query_tags<'py>(
170 py: Python<'py>,
171 base_url_gamma: Option<String>,
172 timeout_secs: u64,
173 ) -> PyResult<Bound<'py, PyAny>> {
174 pyo3_async_runtimes::tokio::future_into_py(py, async move {
175 let client = gamma_client(base_url_gamma, timeout_secs)?;
176 let tags = client.request_tags().await.map_err(to_pyruntime_err)?;
177 Python::attach(|py| serialize_to_py(py, &tags))
178 })
179 }
180
181 #[staticmethod]
183 #[pyo3(signature = (
184 query,
185 events_status=None,
186 events_tag=None,
187 sort=None,
188 ascending=None,
189 limit_per_type=None,
190 page=None,
191 keep_closed_markets=None,
192 base_url_gamma=None,
193 timeout_secs=10,
194 ))]
195 #[expect(clippy::too_many_arguments)]
196 fn query_search<'py>(
197 py: Python<'py>,
198 query: String,
199 events_status: Option<String>,
200 events_tag: Option<String>,
201 sort: Option<String>,
202 ascending: Option<bool>,
203 limit_per_type: Option<u32>,
204 page: Option<u32>,
205 keep_closed_markets: Option<bool>,
206 base_url_gamma: Option<String>,
207 timeout_secs: u64,
208 ) -> PyResult<Bound<'py, PyAny>> {
209 validate_non_empty("query", &query)?;
210
211 if limit_per_type == Some(0) {
212 return Err(to_pyvalue_err("limit_per_type must be greater than zero"));
213 }
214 let params = GetSearchParams {
215 q: Some(query),
216 events_status,
217 events_tag,
218 sort,
219 ascending,
220 limit_per_type,
221 page,
222 keep_closed_markets,
223 };
224
225 pyo3_async_runtimes::tokio::future_into_py(py, async move {
226 let client = gamma_client(base_url_gamma, timeout_secs)?;
227 let response = client
228 .inner()
229 .get_public_search(params)
230 .await
231 .map_err(to_pyruntime_err)?;
232 Python::attach(|py| serialize_to_py(py, &response))
233 })
234 }
235
236 #[staticmethod]
238 #[pyo3(signature = (
239 slug,
240 token_index=0,
241 base_url_http=None,
242 base_url_gamma=None,
243 base_url_data_api=None,
244 timeout_secs=10,
245 ))]
246 fn from_market_slug<'py>(
247 py: Python<'py>,
248 slug: String,
249 token_index: isize,
250 base_url_http: Option<String>,
251 base_url_gamma: Option<String>,
252 base_url_data_api: Option<String>,
253 timeout_secs: u64,
254 ) -> PyResult<Bound<'py, PyAny>> {
255 validate_non_empty("slug", &slug)?;
256 validate_token_index(token_index)?;
257
258 pyo3_async_runtimes::tokio::future_into_py(py, async move {
259 let gamma = gamma_client(base_url_gamma, timeout_secs)?;
260 let clob = clob_client(base_url_http, timeout_secs)?;
261 let data_api = data_api_client(base_url_data_api, timeout_secs)?;
262 let market = request_market_by_slug(&gamma, &slug).await?;
263 let loader =
264 build_loader(market, token_index as usize, &gamma, &clob, data_api).await?;
265 Python::attach(|py| Py::new(py, loader).map(Py::into_any))
266 })
267 }
268
269 #[staticmethod]
271 #[pyo3(signature = (
272 slug,
273 token_index=0,
274 base_url_http=None,
275 base_url_gamma=None,
276 base_url_data_api=None,
277 timeout_secs=10,
278 ))]
279 fn from_event_slug<'py>(
280 py: Python<'py>,
281 slug: String,
282 token_index: isize,
283 base_url_http: Option<String>,
284 base_url_gamma: Option<String>,
285 base_url_data_api: Option<String>,
286 timeout_secs: u64,
287 ) -> PyResult<Bound<'py, PyAny>> {
288 validate_non_empty("slug", &slug)?;
289 validate_token_index(token_index)?;
290
291 pyo3_async_runtimes::tokio::future_into_py(py, async move {
292 let gamma = gamma_client(base_url_gamma, timeout_secs)?;
293 let clob = clob_client(base_url_http, timeout_secs)?;
294 let data_api = data_api_client(base_url_data_api, timeout_secs)?;
295 let event = request_event_by_slug(&gamma, &slug).await?;
296 if event.markets.is_empty() {
297 return Err(to_pyvalue_err(format!(
298 "No markets found in event '{slug}'"
299 )));
300 }
301
302 let mut loaders = Vec::with_capacity(event.markets.len());
303 for market in event.markets {
304 loaders.push(
305 build_loader(
306 market,
307 token_index as usize,
308 &gamma,
309 &clob,
310 data_api.clone(),
311 )
312 .await?,
313 );
314 }
315
316 Python::attach(|py| {
317 let loaders = loaders
318 .into_iter()
319 .map(|loader| Py::new(py, loader))
320 .collect::<PyResult<Vec<_>>>()?;
321 Ok(PyList::new(py, loaders)?.into_py_any_unwrap(py))
322 })
323 })
324 }
325
326 #[getter]
328 fn instrument(&self) -> BinaryOption {
329 self.instrument.clone()
330 }
331
332 #[getter]
334 fn token_id(&self) -> &str {
335 &self.token_id
336 }
337
338 #[getter]
340 fn condition_id(&self) -> &str {
341 &self.condition_id
342 }
343
344 #[getter]
349 fn resolution_metadata(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
350 value_to_pyobject(py, &self.resolution_metadata)
351 }
352
353 #[pyo3(signature = (start=None, end=None, limit=None))]
355 fn load_trades<'py>(
356 &self,
357 py: Python<'py>,
358 start: Option<Timestamp>,
359 end: Option<Timestamp>,
360 limit: Option<u32>,
361 ) -> PyResult<Bound<'py, PyAny>> {
362 if let (Some(start), Some(end)) = (start, end)
363 && start > end
364 {
365 return Err(to_pyvalue_err("start must not be later than end"));
366 }
367
368 if limit == Some(0) {
369 return Err(to_pyvalue_err("limit must be greater than zero"));
370 }
371
372 let client = self.data_api_client.clone();
373 let instrument = self.instrument.clone();
374 let condition_id = self.condition_id.clone();
375 let token_id = self.token_id.clone();
376 let start = datetime_to_unix_nanos(start);
377 let end = datetime_to_unix_nanos(end);
378
379 pyo3_async_runtimes::tokio::future_into_py(py, async move {
380 let trades = client
381 .request_trade_ticks(
382 instrument.id,
383 &condition_id,
384 &token_id,
385 instrument.price_precision,
386 instrument.size_precision,
387 start,
388 end,
389 limit,
390 )
391 .await
392 .map_err(to_pyruntime_err)?;
393 Python::attach(|py| trades_to_py(py, trades))
394 })
395 }
396}
397
398fn gamma_client(
399 base_url: Option<String>,
400 timeout_secs: u64,
401) -> PyResult<PolymarketGammaHttpClient> {
402 PolymarketGammaHttpClient::new(base_url, timeout_secs, RetryConfig::default())
403 .map_err(to_pyruntime_err)
404}
405
406fn clob_client(
407 base_url: Option<String>,
408 timeout_secs: u64,
409) -> PyResult<PolymarketClobPublicClient> {
410 PolymarketClobPublicClient::new(base_url, timeout_secs).map_err(to_pyruntime_err)
411}
412
413fn data_api_client(
414 base_url: Option<String>,
415 timeout_secs: u64,
416) -> PyResult<PolymarketDataApiHttpClient> {
417 PolymarketDataApiHttpClient::new(base_url, timeout_secs).map_err(to_pyruntime_err)
418}
419
420async fn request_market_by_slug(
421 client: &PolymarketGammaHttpClient,
422 slug: &str,
423) -> PyResult<GammaMarket> {
424 match client.inner().get_gamma_market_by_slug(slug).await {
425 Ok(market) => Ok(market),
426 Err(PolymarketHttpError::Http { status: 404, .. }) => Err(to_pyvalue_err(format!(
427 "Market with slug '{slug}' not found"
428 ))),
429 Err(e) => Err(to_pyruntime_err(e)),
430 }
431}
432
433async fn request_event_by_slug(
434 client: &PolymarketGammaHttpClient,
435 slug: &str,
436) -> PyResult<GammaEvent> {
437 client
438 .inner()
439 .get_gamma_events_by_slug(slug)
440 .await
441 .map_err(to_pyruntime_err)?
442 .into_iter()
443 .next()
444 .ok_or_else(|| to_pyvalue_err(format!("Event with slug '{slug}' not found")))
445}
446
447async fn build_loader(
448 mut market: GammaMarket,
449 token_index: usize,
450 gamma: &PolymarketGammaHttpClient,
451 clob: &PolymarketClobPublicClient,
452 data_api_client: PolymarketDataApiHttpClient,
453) -> PyResult<PyPolymarketDataLoader> {
454 if market.condition_id.trim().is_empty() {
455 return Err(to_pyvalue_err("Gamma market has an empty condition ID"));
456 }
457
458 if market.fee_schedule.is_none() {
459 market.fee_schedule = gamma
460 .request_markets_by_params(GetGammaMarketsParams {
461 condition_ids: Some(vec![market.condition_id.clone()]),
462 max_markets: Some(1),
463 ..Default::default()
464 })
465 .await
466 .map_err(to_pyruntime_err)?
467 .into_iter()
468 .find(|candidate| candidate.condition_id == market.condition_id)
469 .and_then(|candidate| candidate.fee_schedule);
470 }
471
472 let details = clob
473 .get_market(&market.condition_id)
474 .await
475 .map_err(to_pyruntime_err)?;
476 build_loader_from_details(market, &details, token_index, data_api_client)
477}
478
479fn build_loader_from_details(
480 mut market: GammaMarket,
481 details: &ClobMarketResponse,
482 token_index: usize,
483 data_api_client: PolymarketDataApiHttpClient,
484) -> PyResult<PyPolymarketDataLoader> {
485 validate_market_details(details, &market.condition_id, token_index)?;
486
487 market.clob_token_ids = serde_json::to_string(
488 &details
489 .tokens
490 .iter()
491 .map(|token| token.token_id.as_str())
492 .collect::<Vec<_>>(),
493 )
494 .map_err(to_pyruntime_err)?;
495 market.outcomes = serde_json::to_string(
496 &details
497 .tokens
498 .iter()
499 .map(|token| token.outcome.as_str())
500 .collect::<Vec<_>>(),
501 )
502 .map_err(to_pyruntime_err)?;
503
504 let def = parse_gamma_market(&market)
505 .map_err(to_pyvalue_err)?
506 .into_iter()
507 .nth(token_index)
508 .ok_or_else(|| to_pyvalue_err("Selected token has no instrument definition"))?;
509 let instrument =
510 match create_instrument_from_def(&def, get_atomic_clock_realtime().get_time_ns())
511 .map_err(to_pyvalue_err)?
512 {
513 InstrumentAny::BinaryOption(instrument) => instrument,
514 _ => return Err(to_pyruntime_err("Expected a BinaryOption instrument")),
515 };
516
517 let resolution_metadata = resolution_metadata(&market, details);
518 let token_id = details.tokens[token_index].token_id.clone();
519 let condition_id = market.condition_id;
520
521 Ok(PyPolymarketDataLoader {
522 instrument,
523 token_id,
524 condition_id,
525 resolution_metadata,
526 data_api_client,
527 })
528}
529
530fn validate_market_details(
531 details: &ClobMarketResponse,
532 condition_id: &str,
533 token_index: usize,
534) -> PyResult<()> {
535 if details.condition_id != condition_id {
536 return Err(to_pyvalue_err(format!(
537 "CLOB market condition ID '{}' does not match Gamma condition ID '{condition_id}'",
538 details.condition_id
539 )));
540 }
541
542 if details.tokens.is_empty() {
543 return Err(to_pyvalue_err(format!(
544 "No tokens found for market '{condition_id}'"
545 )));
546 }
547
548 if token_index >= details.tokens.len() {
549 return Err(to_pyvalue_err(format!(
550 "Token index {token_index} out of range for market '{condition_id}' with {} tokens",
551 details.tokens.len()
552 )));
553 }
554
555 if details.tokens[token_index].token_id.trim().is_empty() {
556 return Err(to_pyvalue_err(format!(
557 "Token index {token_index} for market '{condition_id}' has an empty token ID"
558 )));
559 }
560
561 if details.tokens[token_index].outcome.trim().is_empty() {
562 return Err(to_pyvalue_err(format!(
563 "Token index {token_index} for market '{condition_id}' has an empty outcome"
564 )));
565 }
566 Ok(())
567}
568
569fn resolution_metadata(market: &GammaMarket, details: &ClobMarketResponse) -> Value {
570 json!({
571 "closed": details.closed,
572 "closedTime": market.closed_time,
573 "umaResolutionStatus": market.uma_resolution_status,
574 "resolutionSource": market.resolution_source,
575 "tokens": details.tokens,
576 })
577}
578
579fn validate_non_empty(name: &str, value: &str) -> PyResult<()> {
580 if value.trim().is_empty() {
581 Err(to_pyvalue_err(format!("{name} cannot be empty")))
582 } else {
583 Ok(())
584 }
585}
586
587fn validate_token_index(token_index: isize) -> PyResult<()> {
588 if token_index < 0 {
589 Err(to_pyvalue_err(format!(
590 "Token index {token_index} cannot be negative"
591 )))
592 } else {
593 Ok(())
594 }
595}
596
597fn extract_filters(filters: Option<&Bound<'_, PyAny>>) -> PyResult<HashMap<String, String>> {
598 filters
599 .map(extract_string_map)
600 .transpose()
601 .map(Option::unwrap_or_default)
602}
603
604fn serialize_to_py<T: Serialize>(py: Python<'_>, value: &T) -> PyResult<Py<PyAny>> {
605 let value = serde_json::to_value(value).map_err(to_pyruntime_err)?;
606 value_to_pyobject(py, &value)
607}
608
609fn trades_to_py(py: Python<'_>, trades: Vec<TradeTick>) -> PyResult<Py<PyAny>> {
610 let trades = trades
611 .into_iter()
612 .map(|trade| trade.into_py_any(py))
613 .collect::<PyResult<Vec<_>>>()?;
614 Ok(PyList::new(py, trades)?.into_py_any_unwrap(py))
615}
616
617#[cfg(test)]
618mod tests {
619 use pyo3::exceptions::PyValueError;
620 use rstest::rstest;
621 use serde_json::json;
622
623 use super::*;
624
625 fn gamma_market() -> GammaMarket {
626 serde_json::from_value(json!({
627 "id": "100001",
628 "conditionId": "0xcondition",
629 "questionID": "0xquestion",
630 "clobTokenIds": "[]",
631 "outcomes": "[]",
632 "question": "Will the test pass?",
633 "description": "Test market",
634 "startDate": "2026-01-01T00:00:00Z",
635 "endDate": "2026-12-31T00:00:00Z",
636 "active": false,
637 "closed": true,
638 "closedTime": "2026-06-01T00:00:00Z",
639 "umaResolutionStatus": "resolved",
640 "resolutionSource": "https://example.com/result",
641 "acceptingOrders": false,
642 "enableOrderBook": true,
643 "orderPriceMinTickSize": 0.01,
644 "slug": "test-market",
645 "negRisk": false,
646 "feeSchedule": {
647 "exponent": 2.0,
648 "rate": 0.02,
649 "takerOnly": true,
650 "rebateRate": 0.0
651 },
652 "events": []
653 }))
654 .expect("valid Gamma market")
655 }
656
657 fn gamma_market_with_distinct_crypto_config() -> GammaMarket {
658 let mut value = serde_json::to_value(gamma_market()).expect("serializable Gamma market");
659 value["cryptoMarketConfig"] = json!({
660 "id": "eth-15m-twap-negative-37",
661 "asset": "eth",
662 "duration": "15m",
663 "twapEnabled": true,
664 "twapLookbackSeconds": -37,
665 });
666
667 serde_json::from_value(value).expect("valid distinct crypto market config")
668 }
669
670 fn clob_market() -> ClobMarketResponse {
671 serde_json::from_value(json!({
672 "condition_id": "0xcondition",
673 "closed": true,
674 "tokens": [
675 {"token_id": "yes-token", "outcome": "Yes", "winner": true},
676 {"token_id": "no-token", "outcome": "No", "winner": false}
677 ]
678 }))
679 .expect("valid CLOB market")
680 }
681
682 fn data_api() -> PolymarketDataApiHttpClient {
683 PolymarketDataApiHttpClient::new(Some("http://127.0.0.1:1".to_string()), 1)
684 .expect("valid test client")
685 }
686
687 #[rstest]
688 fn build_loader_selects_token_and_retains_resolution_lifecycle_metadata() {
689 let loader = build_loader_from_details(gamma_market(), &clob_market(), 1, data_api())
690 .expect("loader should build");
691 let info = loader.instrument.info.as_ref().expect("instrument info");
692
693 assert_eq!(loader.token_id, "no-token");
694 assert_eq!(loader.condition_id, "0xcondition");
695 assert_eq!(
696 loader.instrument.outcome.map(|value| value.to_string()),
697 Some("No".to_string())
698 );
699 assert_eq!(loader.instrument.taker_fee.to_string(), "0.02");
700 assert_eq!(loader.resolution_metadata["closed"], true);
701 assert_eq!(loader.resolution_metadata["tokens"][0]["winner"], true);
702 assert_eq!(
703 info.get_str("resolution_source"),
704 Some("https://example.com/result")
705 );
706 assert_eq!(info.get_str("description"), Some("Test market"));
707 assert!(!info.contains_key("closed"));
708 assert!(!info.contains_key("closedTime"));
709 assert!(!info.contains_key("umaResolutionStatus"));
710 assert!(!info.contains_key("resolutionSource"));
711 assert!(!info.contains_key("winner"));
712
713 Python::initialize();
714 Python::attach(|py| {
715 let py_loader = Py::new(py, loader).expect("Python loader");
716 assert_eq!(
717 py_loader
718 .getattr(py, "token_id")
719 .expect("token ID getter")
720 .extract::<String>(py)
721 .expect("token ID string"),
722 "no-token",
723 );
724 assert_eq!(
725 py_loader
726 .getattr(py, "condition_id")
727 .expect("condition ID getter")
728 .extract::<String>(py)
729 .expect("condition ID string"),
730 "0xcondition",
731 );
732 let metadata = py_loader
733 .getattr(py, "resolution_metadata")
734 .expect("resolution metadata getter");
735 assert!(
736 metadata
737 .bind(py)
738 .get_item("closed")
739 .expect("closed metadata")
740 .extract::<bool>()
741 .expect("closed bool"),
742 );
743 });
744 }
745
746 #[rstest]
747 fn build_loader_preserves_distinct_crypto_config_in_rust_and_python_instrument_info() {
748 let loader = build_loader_from_details(
749 gamma_market_with_distinct_crypto_config(),
750 &clob_market(),
751 0,
752 data_api(),
753 )
754 .expect("loader should build");
755 let info = loader.instrument.info.as_ref().expect("instrument info");
756 let expected = json!({
757 "id": "eth-15m-twap-negative-37",
758 "asset": "eth",
759 "duration": "15m",
760 "twapEnabled": true,
761 "twapLookbackSeconds": -37,
762 });
763
764 assert_eq!(info.get("crypto_market_config"), Some(&expected));
765
766 Python::initialize();
767 Python::attach(|py| {
768 let py_loader = Py::new(py, loader).expect("Python loader");
769 let instrument = py_loader
770 .getattr(py, "instrument")
771 .expect("instrument getter");
772 let info = instrument
773 .getattr(py, "info")
774 .expect("instrument info getter");
775 let config = info
776 .bind(py)
777 .get_item("crypto_market_config")
778 .expect("crypto market config");
779
780 assert_eq!(
781 config
782 .get_item("id")
783 .expect("config ID")
784 .extract::<String>()
785 .expect("config ID string"),
786 "eth-15m-twap-negative-37",
787 );
788 assert_eq!(
789 config
790 .get_item("asset")
791 .expect("config asset")
792 .extract::<String>()
793 .expect("config asset string"),
794 "eth",
795 );
796 assert_eq!(
797 config
798 .get_item("duration")
799 .expect("config duration")
800 .extract::<String>()
801 .expect("config duration string"),
802 "15m",
803 );
804 assert!(
805 config
806 .get_item("twapEnabled")
807 .expect("TWAP enabled")
808 .extract::<bool>()
809 .expect("TWAP enabled bool"),
810 );
811 assert_eq!(
812 config
813 .get_item("twapLookbackSeconds")
814 .expect("TWAP lookback")
815 .extract::<i64>()
816 .expect("TWAP lookback integer"),
817 -37,
818 );
819 });
820 }
821
822 #[rstest]
823 #[case(-1, "cannot be negative")]
824 fn validate_token_index_rejects_negative_values(#[case] index: isize, #[case] message: &str) {
825 let error = validate_token_index(index).expect_err("index should be rejected");
826
827 Python::initialize();
828 Python::attach(|py| assert!(error.is_instance_of::<PyValueError>(py)));
829 assert!(error.to_string().contains(message));
830 }
831
832 #[rstest]
833 fn build_loader_rejects_out_of_range_index() {
834 Python::initialize();
835
836 let error = build_loader_from_details(gamma_market(), &clob_market(), 2, data_api())
837 .expect_err("index should be rejected");
838
839 assert!(error.to_string().contains("Token index 2 out of range"));
840 }
841
842 #[rstest]
843 fn build_loader_rejects_empty_token_list() {
844 Python::initialize();
845
846 let mut details = clob_market();
847 details.tokens.clear();
848
849 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
850 .expect_err("empty tokens should be rejected");
851
852 assert!(error.to_string().contains("No tokens found"));
853 }
854
855 #[rstest]
856 fn build_loader_rejects_transient_empty_token_id() {
857 Python::initialize();
858
859 let mut details = clob_market();
860 details.tokens[0].token_id.clear();
861
862 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
863 .expect_err("empty token ID should be rejected");
864
865 assert!(error.to_string().contains("has an empty token ID"));
866 }
867
868 #[rstest]
869 fn build_loader_rejects_malformed_non_binary_token_payload() {
870 Python::initialize();
871
872 let mut details = clob_market();
873 details.tokens.truncate(1);
874
875 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
876 .expect_err("non-binary tokens should be rejected");
877
878 assert!(error.to_string().contains("Expected 2 token IDs"));
879 }
880
881 #[rstest]
882 fn build_loader_rejects_mismatched_condition_id() {
883 Python::initialize();
884
885 let mut details = clob_market();
886 details.condition_id = "0xdifferent".to_string();
887
888 let error = build_loader_from_details(gamma_market(), &details, 0, data_api())
889 .expect_err("condition mismatch should be rejected");
890
891 assert!(
892 error
893 .to_string()
894 .contains("does not match Gamma condition ID")
895 );
896 }
897}