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