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 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    /// Queries one Gamma market by slug.
73    #[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    /// Queries public CLOB market details by condition ID.
91    #[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    /// Queries one Gamma event by slug.
112    #[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    /// Lists Gamma markets with validated filters and keyset pagination.
130    #[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    /// Lists Gamma events with validated filters and keyset pagination.
152    #[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    /// Lists Gamma tags.
174    #[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    /// Searches Gamma markets and events.
189    #[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    /// Creates one loader from a Gamma market slug.
244    #[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    /// Creates one loader for every market in a Gamma event slug.
277    #[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    /// Returns the normalized binary option instrument.
334    #[getter]
335    fn instrument(&self) -> BinaryOption {
336        self.instrument.clone()
337    }
338
339    /// Returns the selected CLOB token ID.
340    #[getter]
341    fn token_id(&self) -> &str {
342        &self.token_id
343    }
344
345    /// Returns the on-chain condition ID.
346    #[getter]
347    fn condition_id(&self) -> &str {
348        &self.condition_id
349    }
350
351    /// Returns resolution lifecycle metadata retained separately from `instrument.info`.
352    ///
353    /// Shared market identity such as `resolutionSource` is also available from
354    /// `instrument.info["resolution_source"]`.
355    #[getter]
356    fn resolution_metadata(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
357        value_to_pyobject(py, &self.resolution_metadata)
358    }
359
360    /// Loads historical trades from the Rust Data API client.
361    #[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    // Fetched snapshots can reveal outcomes that were unknown during the historical period
532    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}