Skip to main content

nautilus_polymarket/python/
loader.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Rust-backed Python discovery and historical data facade.
17
18use 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    /// Queries one Gamma market by slug.
66    #[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    /// Queries public CLOB market details by condition ID.
84    #[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    /// Queries one Gamma event by slug.
105    #[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    /// Lists Gamma markets with validated filters and keyset pagination.
123    #[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    /// Lists Gamma events with validated filters and keyset pagination.
145    #[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    /// Lists Gamma tags.
167    #[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    /// Searches Gamma markets and events.
182    #[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    /// Creates one loader from a Gamma market slug.
237    #[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    /// Creates one loader for every market in a Gamma event slug.
270    #[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    /// Returns the normalized binary option instrument.
327    #[getter]
328    fn instrument(&self) -> BinaryOption {
329        self.instrument.clone()
330    }
331
332    /// Returns the selected CLOB token ID.
333    #[getter]
334    fn token_id(&self) -> &str {
335        &self.token_id
336    }
337
338    /// Returns the on-chain condition ID.
339    #[getter]
340    fn condition_id(&self) -> &str {
341        &self.condition_id
342    }
343
344    /// Returns resolution lifecycle metadata retained separately from `instrument.info`.
345    ///
346    /// Shared market identity such as `resolutionSource` is also available from
347    /// `instrument.info["resolution_source"]`.
348    #[getter]
349    fn resolution_metadata(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
350        value_to_pyobject(py, &self.resolution_metadata)
351    }
352
353    /// Loads historical trades from the Rust Data API client.
354    #[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}