Skip to main content

nautilus_polymarket/http/
gamma.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//! Provides the HTTP client for the Polymarket Gamma API.
17//!
18//! Gamma keyset constraints honored by the paginators and `load_ids` chunker:
19//!
20//! - `/markets/keyset` accepts at most 100 items per page.
21//! - `/events/keyset` accepts at most 500 items per page.
22//! - Keyset endpoints reject `offset`; the paginators apply a requested initial
23//!   offset locally for compatibility.
24//! - `next_cursor` is absent on the final page.
25//! - `condition_ids=` accepts at most 100 IDs per request, so `load_ids` for
26//!   larger sets chunks the request and unions the responses.
27
28use std::{collections::HashMap, result::Result as StdResult, sync::Arc};
29
30use ahash::AHashMap;
31use nautilus_core::{
32    UnixNanos,
33    time::{AtomicTime, get_atomic_clock_realtime},
34};
35use nautilus_model::instruments::InstrumentAny;
36use nautilus_network::{
37    http::{HttpClient, HttpClientError, Method, create_standard_nautilus_headers},
38    retry::{RetryConfig, RetryManager},
39    websocket::proxy::ProxyUrl,
40};
41use rust_decimal::Decimal;
42use serde::{Deserialize, Serialize, de::DeserializeOwned};
43use serde_json::{Value, value::RawValue};
44
45use crate::{
46    common::{parse::validate_market_version, urls::gamma_api_url},
47    filters::set_market_closed,
48    http::{
49        error::{Error, Result, decode_response},
50        models::{GammaEvent, GammaMarket, GammaTag, SearchResponse},
51        pagination::{Completion, CursorProtocol, FetchOutcome, Paginator, WindowedCollect},
52        parse::{
53            create_instrument_from_def, enrich_market_fee_schedule,
54            parse_gamma_market_with_event_cache,
55        },
56        query::{GetGammaEventsParams, GetGammaMarketsParams, GetSearchParams},
57        rate_limits::POLYMARKET_GAMMA_REST_QUOTA,
58    },
59};
60
61const GAMMA_MARKETS_KEYSET_PAGE_LIMIT: u32 = 100;
62const GAMMA_EVENTS_KEYSET_PAGE_LIMIT: u32 = 500;
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65enum GammaStop {
66    CallerCapped,
67}
68
69/// Provides a raw HTTP client for the Polymarket Gamma API.
70///
71/// Handles HTTP transport for fetching market data from the public Gamma API.
72/// No authentication is required.
73#[derive(Debug, Clone)]
74pub struct PolymarketGammaRawHttpClient {
75    client: HttpClient,
76    base_url: String,
77}
78
79impl PolymarketGammaRawHttpClient {
80    /// Creates a new [`PolymarketGammaRawHttpClient`].
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the HTTP client cannot be created.
85    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
86        Self::new_with_proxy(base_url, timeout_secs, None)
87    }
88
89    /// Creates a new raw client with an optional validated proxy URL.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the HTTP client cannot be created.
94    pub fn new_with_proxy(
95        base_url: Option<String>,
96        timeout_secs: u64,
97        proxy_url: Option<ProxyUrl>,
98    ) -> StdResult<Self, HttpClientError> {
99        Ok(Self {
100            client: HttpClient::builder()
101                .headers(Self::default_headers())
102                .default_quota(*POLYMARKET_GAMMA_REST_QUOTA)
103                .timeout_secs(timeout_secs)
104                .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
105                .build()?,
106            base_url: base_url
107                .unwrap_or_else(|| gamma_api_url().to_string())
108                .trim_end_matches('/')
109                .to_string(),
110        })
111    }
112
113    fn default_headers() -> HashMap<String, String> {
114        let mut headers: HashMap<String, String> =
115            create_standard_nautilus_headers().into_iter().collect();
116        headers.insert("Content-Type".to_string(), "application/json".to_string());
117        headers
118    }
119
120    fn url(&self, path: &str) -> String {
121        format!("{}{path}", self.base_url)
122    }
123
124    async fn send_get<P: Serialize, T: DeserializeOwned>(
125        &self,
126        path: &str,
127        params: Option<&P>,
128    ) -> Result<T> {
129        let url = self.url(path);
130        let response = self
131            .client
132            .request_with_params(Method::GET, url, params, None, None, None, None)
133            .await
134            .map_err(Error::from_http_client)?;
135
136        decode_response(&response)
137    }
138
139    async fn send_get_query_map<T: DeserializeOwned>(
140        &self,
141        path: &str,
142        params: Option<&HashMap<String, Vec<String>>>,
143    ) -> Result<T> {
144        let url = self.url(path);
145        let response = self
146            .client
147            .request(Method::GET, url, params, None, None, None, None)
148            .await
149            .map_err(Error::from_http_client)?;
150
151        decode_response(&response)
152    }
153
154    /// Fetches markets from the Gamma API.
155    ///
156    /// Handles both bare array and `{"data": [...]}` response schemas.
157    pub async fn get_gamma_markets(
158        &self,
159        params: GetGammaMarketsParams,
160    ) -> Result<Vec<GammaMarket>> {
161        let query_params = gamma_markets_query_params(params)?;
162        let raw: Box<RawValue> = self
163            .send_get_query_map("/markets", Some(&query_params))
164            .await?;
165        parse_gamma_markets_response(&raw)
166    }
167
168    async fn get_gamma_markets_keyset(
169        &self,
170        mut params: GetGammaMarketsParams,
171        after_cursor: Option<&str>,
172    ) -> Result<GammaMarketsKeysetResponse> {
173        params.validate_keyset().map_err(Error::decode)?;
174        params.offset = None;
175        let mut query_params = gamma_markets_query_params(params)?;
176        if let Some(after_cursor) = after_cursor {
177            query_params.insert("after_cursor".to_string(), vec![after_cursor.to_string()]);
178        }
179        self.send_get_query_map("/markets/keyset", Some(&query_params))
180            .await
181    }
182
183    /// Fetches a single market by ID from the Gamma API.
184    pub async fn get_gamma_market(&self, market_id: &str) -> Result<GammaMarket> {
185        let path = format!("/markets/{market_id}");
186        self.send_get::<(), _>(&path, None::<&()>).await
187    }
188
189    /// Fetches a market from the Gamma API `GET /markets/slug/{slug}`.
190    pub async fn get_gamma_market_by_slug(&self, slug: &str) -> Result<GammaMarket> {
191        let path = format!("/markets/slug/{slug}");
192        self.send_get::<(), _>(&path, None::<&()>).await
193    }
194
195    /// Fetches events from the Gamma API `GET /events?slug=`.
196    pub async fn get_gamma_events_by_slug(&self, slug: &str) -> Result<Vec<GammaEvent>> {
197        #[derive(Serialize)]
198        struct EventSlugParams<'a> {
199            slug: &'a str,
200        }
201        let params = EventSlugParams { slug };
202        self.send_get("/events", Some(&params)).await
203    }
204
205    /// Fetches events from the Gamma API `GET /events` with full query params.
206    pub async fn get_gamma_events(&self, params: GetGammaEventsParams) -> Result<Vec<GammaEvent>> {
207        let query_params = gamma_events_query_params(params)?;
208        self.send_get_query_map("/events", Some(&query_params))
209            .await
210    }
211
212    async fn get_gamma_events_keyset(
213        &self,
214        mut params: GetGammaEventsParams,
215        after_cursor: Option<&str>,
216    ) -> Result<GammaEventsKeysetResponse> {
217        params.validate_keyset().map_err(Error::decode)?;
218        params.offset = None;
219        let mut query_params = gamma_events_query_params(params)?;
220        if let Some(after_cursor) = after_cursor {
221            query_params.insert("after_cursor".to_string(), vec![after_cursor.to_string()]);
222        }
223        self.send_get_query_map("/events/keyset", Some(&query_params))
224            .await
225    }
226
227    /// Fetches available tags from the Gamma API `GET /tags`.
228    pub async fn get_gamma_tags(&self) -> Result<Vec<GammaTag>> {
229        self.send_get::<(), _>("/tags", None::<&()>).await
230    }
231
232    /// Searches the Gamma API via `GET /public-search`.
233    pub async fn get_public_search(&self, params: GetSearchParams) -> Result<SearchResponse> {
234        self.send_get("/public-search", Some(&params)).await
235    }
236}
237
238#[derive(Debug, Deserialize)]
239struct GammaMarketsKeysetResponse {
240    markets: Vec<GammaMarket>,
241    next_cursor: Option<String>,
242}
243
244#[derive(Debug, Deserialize)]
245struct GammaEventsKeysetResponse {
246    events: Vec<GammaEvent>,
247    next_cursor: Option<String>,
248}
249
250fn gamma_markets_query_params(
251    params: GetGammaMarketsParams,
252) -> Result<HashMap<String, Vec<String>>> {
253    let mut scalar_params = params;
254    let id = scalar_params.id.take();
255    let slug = scalar_params.slug.take();
256    let clob_token_ids = scalar_params.clob_token_ids.take();
257    let condition_ids = scalar_params.condition_ids.take();
258    let question_ids = scalar_params.question_ids.take();
259    let market_maker_address = scalar_params.market_maker_address.take();
260    let tag_id = scalar_params.tag_id.take();
261    let sports_market_types = scalar_params.sports_market_types.take();
262    let value = serde_json::to_value(&scalar_params).map_err(Error::Serde)?;
263    let fields = value
264        .as_object()
265        .ok_or_else(|| Error::decode("Gamma markets params must encode to an object"))?;
266    let mut params = HashMap::with_capacity(fields.len());
267
268    for (key, value) in fields {
269        if let Some(value) = gamma_query_value(value)? {
270            params.insert(key.clone(), vec![value]);
271        }
272    }
273
274    insert_repeated_param(&mut params, "id", id);
275    insert_repeated_param(&mut params, "slug", slug);
276    insert_repeated_param(&mut params, "clob_token_ids", clob_token_ids);
277    insert_repeated_param(&mut params, "condition_ids", condition_ids);
278    insert_repeated_param(&mut params, "question_ids", question_ids);
279    insert_repeated_param(&mut params, "market_maker_address", market_maker_address);
280    insert_repeated_param(&mut params, "tag_id", tag_id);
281    insert_repeated_param(&mut params, "sports_market_types", sports_market_types);
282
283    Ok(params)
284}
285
286fn gamma_events_query_params(params: GetGammaEventsParams) -> Result<HashMap<String, Vec<String>>> {
287    let mut scalar_params = params;
288    let id = scalar_params.id.take();
289    let slug = scalar_params.slug.take();
290    let tag_id = scalar_params.tag_id.take();
291    let exclude_tag_id = scalar_params.exclude_tag_id.take();
292    let series_id = scalar_params.series_id.take();
293    let game_id = scalar_params.game_id.take();
294    let created_by = scalar_params.created_by.take();
295    let value = serde_json::to_value(&scalar_params).map_err(Error::Serde)?;
296    let fields = value
297        .as_object()
298        .ok_or_else(|| Error::decode("Gamma events params must encode to an object"))?;
299    let mut params = HashMap::with_capacity(fields.len());
300
301    for (key, value) in fields {
302        if let Some(value) = gamma_query_value(value)? {
303            params.insert(key.clone(), vec![value]);
304        }
305    }
306
307    insert_repeated_param(&mut params, "id", id);
308    insert_repeated_param(&mut params, "slug", slug);
309    insert_repeated_param(&mut params, "tag_id", tag_id);
310    insert_repeated_param(&mut params, "exclude_tag_id", exclude_tag_id);
311    insert_repeated_param(&mut params, "series_id", series_id);
312    insert_repeated_param(&mut params, "game_id", game_id);
313    insert_repeated_param(&mut params, "created_by", created_by);
314
315    Ok(params)
316}
317
318fn insert_repeated_param<T: ToString>(
319    params: &mut HashMap<String, Vec<String>>,
320    key: &str,
321    values: Option<Vec<T>>,
322) {
323    let Some(values) = values else {
324        return;
325    };
326
327    params.insert(
328        key.to_string(),
329        values
330            .into_iter()
331            .map(|value| value.to_string().trim().to_string())
332            .collect(),
333    );
334}
335
336fn gamma_query_value(value: &Value) -> Result<Option<String>> {
337    match value {
338        Value::Null => Ok(None),
339        Value::String(value) => Ok(Some(value.clone())),
340        Value::Bool(value) => Ok(Some(value.to_string())),
341        Value::Number(value) => Ok(Some(value.to_string())),
342        other => Err(Error::decode(format!(
343            "Unsupported Gamma query value: {other}"
344        ))),
345    }
346}
347
348fn parse_markets_to_instruments(markets: &[GammaMarket], ts_init: UnixNanos) -> Vec<InstrumentAny> {
349    let (instruments, _transient) = parse_markets_with_transient(markets, ts_init);
350    instruments
351}
352
353// Returns parsed instruments alongside condition IDs of markets still in the
354// CLOB hydration window (empty or empty-entry `clob_token_ids`), so callers
355// can retry rather than treating them as terminal.
356//
357// This is the single funnel through which live instruments reach the client caches, so Gamma's
358// `closed` state is recorded here rather than in `create_instrument_from_def`. Historical loader
359// instruments share that constructor and must not carry terminal state in `info`; they expose it
360// through `resolution_metadata` instead.
361pub(crate) fn parse_markets_with_transient(
362    markets: &[GammaMarket],
363    ts_init: UnixNanos,
364) -> (Vec<InstrumentAny>, Vec<String>) {
365    let mut instruments = Vec::new();
366    let mut transient = Vec::new();
367
368    // Parents outlive this batch; pointer keys keep distinct discovery snapshots separate
369    let mut event_metadata = AHashMap::new();
370
371    for market in markets {
372        if let Err(e) = validate_market_version(market.version.as_deref()) {
373            log::warn!("Skipping Gamma market {}: {e}", market.condition_id);
374            continue;
375        }
376
377        if is_transient_clob_token_ids(&market.clob_token_ids) {
378            transient.push(market.condition_id.clone());
379            continue;
380        }
381
382        match parse_gamma_market_with_event_cache(market, &mut event_metadata) {
383            Ok(defs) => {
384                for def in defs {
385                    match create_instrument_from_def(&def, ts_init) {
386                        Ok(InstrumentAny::BinaryOption(mut binary)) => {
387                            set_market_closed(&mut binary, def.closed);
388                            instruments.push(InstrumentAny::BinaryOption(binary));
389                        }
390                        Ok(other) => instruments.push(other),
391                        Err(e) => log::warn!("Failed to create instrument: {e}"),
392                    }
393                }
394            }
395            Err(e) => log::warn!("Failed to parse gamma market: {e}"),
396        }
397    }
398
399    if !transient.is_empty() {
400        log::debug!(
401            "{} market(s) without usable clob_token_ids deferred as transient (CLOB hydration)",
402            transient.len(),
403        );
404    }
405    (instruments, transient)
406}
407
408// Treats bare empty string, encoded empty array, and arrays with empty entries
409// as transient. Unparsable payloads fall through to `parse_gamma_market` so
410// real schema errors still surface.
411fn is_transient_clob_token_ids(raw: &str) -> bool {
412    if raw.is_empty() {
413        return true;
414    }
415
416    match serde_json::from_str::<Vec<String>>(raw) {
417        Ok(ids) => ids.is_empty() || ids.iter().any(|t| t.is_empty()),
418        Err(_) => false,
419    }
420}
421
422pub(crate) fn flatten_event_markets(events: Vec<GammaEvent>) -> Vec<GammaMarket> {
423    events
424        .into_iter()
425        .flat_map(|mut event| {
426            let markets = std::mem::take(&mut event.markets);
427            let event = Arc::new(event);
428
429            markets.into_iter().map(move |mut market| {
430                if market.game_id.is_none() {
431                    market.game_id.clone_from(&event.game_id);
432                }
433
434                market.parent_event = Some(event.clone());
435                market
436            })
437        })
438        .collect()
439}
440
441/// Provides a domain HTTP client for Polymarket instrument fetching.
442///
443/// Wraps [`PolymarketGammaRawHttpClient`] with instrument parsing: fetch from
444/// the Gamma API and parse into Nautilus types. Stateless with respect to
445/// instrument storage; caching is handled by the instrument provider.
446#[derive(Debug, Clone)]
447pub struct PolymarketGammaHttpClient {
448    inner: Arc<PolymarketGammaRawHttpClient>,
449    clock: &'static AtomicTime,
450    retry_manager: Arc<RetryManager<Error>>,
451}
452
453impl PolymarketGammaHttpClient {
454    /// Creates a new [`PolymarketGammaHttpClient`].
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if the underlying HTTP client cannot be created.
459    pub fn new(
460        gamma_base_url: Option<String>,
461        timeout_secs: u64,
462        retry_config: RetryConfig,
463    ) -> StdResult<Self, HttpClientError> {
464        Self::new_with_proxy(gamma_base_url, timeout_secs, retry_config, None)
465    }
466
467    /// Creates a new domain client with an optional validated proxy URL.
468    ///
469    /// # Errors
470    ///
471    /// Returns an error if the underlying HTTP client cannot be created.
472    pub fn new_with_proxy(
473        gamma_base_url: Option<String>,
474        timeout_secs: u64,
475        retry_config: RetryConfig,
476        proxy_url: Option<ProxyUrl>,
477    ) -> StdResult<Self, HttpClientError> {
478        Ok(Self {
479            inner: Arc::new(PolymarketGammaRawHttpClient::new_with_proxy(
480                gamma_base_url,
481                timeout_secs,
482                proxy_url,
483            )?),
484            clock: get_atomic_clock_realtime(),
485            retry_manager: Arc::new(RetryManager::new(retry_config)),
486        })
487    }
488
489    /// Fetches markets from the Gamma API with the given base params, paginating automatically.
490    async fn fetch_gamma_markets_paginated(
491        &self,
492        base_params: GetGammaMarketsParams,
493    ) -> anyhow::Result<Vec<GammaMarket>> {
494        let page_size = base_params
495            .limit
496            .unwrap_or(GAMMA_MARKETS_KEYSET_PAGE_LIMIT)
497            .min(GAMMA_MARKETS_KEYSET_PAGE_LIMIT);
498        let protocol = CursorProtocol::<GammaStop>::gamma("Gamma market");
499        let reducer = WindowedCollect::new(
500            base_params.offset.unwrap_or(0) as usize,
501            base_params.max_markets.map(|value| value as usize),
502            GammaStop::CallerCapped,
503        );
504        let paginator = Paginator::new("Gamma market", protocol, reducer);
505        let completed = paginator
506            .run(
507                |position| {
508                    let after_cursor = position.map(|cursor| cursor.as_ref().to_string());
509                    let params = GetGammaMarketsParams {
510                        limit: Some(page_size),
511                        offset: None,
512                        ..base_params.clone()
513                    };
514                    async move {
515                        let response = self
516                            .inner
517                            .get_gamma_markets_keyset(params, after_cursor.as_deref())
518                            .await?;
519                        Ok::<_, anyhow::Error>(FetchOutcome::Page {
520                            rows: response.markets,
521                            wire: response.next_cursor,
522                        })
523                    }
524                },
525                anyhow::Error::new,
526            )
527            .await?;
528
529        match completed.completion {
530            Completion::WireExhausted | Completion::Stopped(GammaStop::CallerCapped) => {
531                Ok(completed.output)
532            }
533        }
534    }
535
536    /// Fetches all active markets from the Gamma API, paginating automatically.
537    async fn fetch_all_gamma_markets(&self) -> anyhow::Result<Vec<GammaMarket>> {
538        self.fetch_gamma_markets_paginated(GetGammaMarketsParams {
539            active: Some(true),
540            closed: Some(false),
541            ..Default::default()
542        })
543        .await
544    }
545
546    /// Fetches instruments from the Gamma API and returns Nautilus domain types.
547    ///
548    /// # Errors
549    ///
550    /// Returns an error if the HTTP request or parsing fails.
551    pub async fn request_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
552        let mut markets = self.fetch_all_gamma_markets().await?;
553        enrich_markets(&mut markets);
554        let ts_init = self.clock.get_time_ns();
555        let instruments = parse_markets_to_instruments(&markets, ts_init);
556        log::debug!("Parsed {} instruments from Gamma API", instruments.len());
557        Ok(instruments)
558    }
559
560    /// Fetches instruments for the given slugs concurrently.
561    ///
562    /// Each slug is queried individually via the Gamma API. Missing or
563    /// unparsable slugs are logged and skipped.
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if all slug requests fail. Individual slug failures
568    /// are warned and skipped when at least one slug succeeds.
569    pub async fn request_instruments_by_slugs(
570        &self,
571        slugs: Vec<String>,
572    ) -> anyhow::Result<Vec<InstrumentAny>> {
573        let ts_init = self.clock.get_time_ns();
574
575        let futures = slugs.into_iter().map(|slug| {
576            let inner = Arc::clone(&self.inner);
577            async move {
578                let params = GetGammaMarketsParams {
579                    slug: Some(vec![slug.clone()]),
580                    ..Default::default()
581                };
582
583                match inner.get_gamma_markets(params).await {
584                    Ok(markets) => Some((slug, markets)),
585                    Err(e) => {
586                        log::warn!("Failed to fetch slug '{slug}': {e}");
587                        None
588                    }
589                }
590            }
591        });
592
593        let results = futures_util::future::join_all(futures).await;
594
595        let total_slugs = results.len();
596        let succeeded = results.iter().filter(|r| r.is_some()).count();
597        let mut instruments = Vec::new();
598
599        for result in results.into_iter().flatten() {
600            let (slug, mut markets) = result;
601            if markets.is_empty() {
602                log::debug!("No markets found for slug '{slug}'");
603                continue;
604            }
605
606            enrich_markets(&mut markets);
607            instruments.extend(parse_markets_to_instruments(&markets, ts_init));
608        }
609
610        if succeeded == 0 && total_slugs > 0 {
611            anyhow::bail!("All {total_slugs} slug requests failed");
612        }
613
614        log::debug!("Parsed {} instruments from slug queries", instruments.len());
615        Ok(instruments)
616    }
617
618    /// Fetches instruments for the given slugs with retry on empty results.
619    ///
620    /// Uses the client's [`RetryManager`] with exponential backoff. Gamma API
621    /// may not have indexed a newly created market yet, so empty results are
622    /// treated as retryable (indexing lag). HTTP errors are also retried per
623    /// the standard `is_retryable()` classification.
624    pub async fn request_instruments_by_slugs_with_retry(
625        &self,
626        slugs: Vec<String>,
627    ) -> anyhow::Result<Vec<InstrumentAny>> {
628        let inner = Arc::clone(&self.inner);
629        let ts_init = self.clock.get_time_ns();
630
631        let mut markets: Vec<GammaMarket> = self
632            .retry_manager
633            .invocation(
634                "gamma_fetch_by_slugs",
635                || {
636                    let inner = Arc::clone(&inner);
637                    let slugs = slugs.clone();
638                    async move {
639                        let futures = slugs.into_iter().map(|slug| {
640                            let inner = Arc::clone(&inner);
641                            async move {
642                                let params = GetGammaMarketsParams {
643                                    slug: Some(vec![slug.clone()]),
644                                    ..Default::default()
645                                };
646                                inner
647                                    .get_gamma_markets(params)
648                                    .await
649                                    .map(|markets| (slug, markets))
650                            }
651                        });
652
653                        let results: Vec<_> = futures_util::future::join_all(futures)
654                            .await
655                            .into_iter()
656                            .collect::<StdResult<Vec<_>, _>>()?;
657
658                        let markets: Vec<GammaMarket> = results
659                            .into_iter()
660                            .flat_map(|(_, markets)| markets)
661                            .collect();
662
663                        if parse_markets_to_instruments(&markets, ts_init).is_empty()
664                            && (markets.is_empty()
665                                || markets.iter().any(|market| {
666                                    validate_market_version(market.version.as_deref()).is_ok()
667                                }))
668                        {
669                            return Err(Error::transport(
670                                "Gamma returned no instruments (indexing lag)",
671                            ));
672                        }
673
674                        Ok(markets)
675                    }
676                },
677                |e| e.is_retryable(),
678                |e| Error::transport(e.to_string()),
679            )
680            .execute()
681            .await
682            .map_err(|e| anyhow::anyhow!("{e}"))?;
683
684        enrich_markets(&mut markets);
685        Ok(parse_markets_to_instruments(&markets, ts_init))
686    }
687
688    /// Fetches instruments from event slugs concurrently.
689    ///
690    /// Each slug queries `GET /events?slug=`, extracts the markets array from
691    /// the first matching event, and parses each market into instruments.
692    pub async fn request_instruments_by_event_slugs(
693        &self,
694        event_slugs: Vec<String>,
695    ) -> anyhow::Result<Vec<InstrumentAny>> {
696        let ts_init = self.clock.get_time_ns();
697
698        let futures = event_slugs.into_iter().map(|slug| {
699            let inner = Arc::clone(&self.inner);
700            async move {
701                match inner.get_gamma_events_by_slug(&slug).await {
702                    Ok(events) => Some((slug, events)),
703                    Err(e) => {
704                        log::warn!("Failed to fetch event slug '{slug}': {e}");
705                        None
706                    }
707                }
708            }
709        });
710
711        let results = futures_util::future::join_all(futures).await;
712
713        let total = results.len();
714        let succeeded = results.iter().filter(|r| r.is_some()).count();
715        let mut instruments = Vec::new();
716
717        for result in results.into_iter().flatten() {
718            let (slug, events) = result;
719            let mut markets = flatten_event_markets(events);
720            if markets.is_empty() {
721                log::warn!("No markets found in event slug '{slug}'");
722                continue;
723            }
724
725            enrich_markets(&mut markets);
726            instruments.extend(parse_markets_to_instruments(&markets, ts_init));
727        }
728
729        if succeeded == 0 && total > 0 {
730            anyhow::bail!("All {total} event slug requests failed");
731        }
732
733        log::debug!(
734            "Parsed {} instruments from event slug queries",
735            instruments.len()
736        );
737        Ok(instruments)
738    }
739
740    /// Fetches instruments using arbitrary Gamma API query params with auto-pagination.
741    pub async fn request_instruments_by_params(
742        &self,
743        base_params: GetGammaMarketsParams,
744    ) -> anyhow::Result<Vec<InstrumentAny>> {
745        let mut markets = self.fetch_gamma_markets_paginated(base_params).await?;
746        enrich_markets(&mut markets);
747        let ts_init = self.clock.get_time_ns();
748        let instruments = parse_markets_to_instruments(&markets, ts_init);
749        log::debug!("Parsed {} instruments from params query", instruments.len());
750        Ok(instruments)
751    }
752
753    /// Fetches supported instruments and condition IDs awaiting CLOB token IDs.
754    ///
755    /// Only market protocol `v1` enters the transient list. Unlike
756    /// [`Self::request_instruments_by_params`], this method errors when every returned
757    /// market uses an unsupported protocol.
758    ///
759    /// # Errors
760    ///
761    /// Returns an error if the request fails or every returned market is unsupported.
762    pub async fn request_instruments_by_params_with_transient(
763        &self,
764        base_params: GetGammaMarketsParams,
765    ) -> anyhow::Result<(Vec<InstrumentAny>, Vec<String>)> {
766        let mut markets = self.fetch_gamma_markets_paginated(base_params).await?;
767        if !markets.is_empty()
768            && markets
769                .iter()
770                .all(|market| validate_market_version(market.version.as_deref()).is_err())
771        {
772            validate_market_version(markets[0].version.as_deref())?;
773        }
774
775        enrich_markets(&mut markets);
776        let ts_init = self.clock.get_time_ns();
777        let (instruments, transient) = parse_markets_with_transient(&markets, ts_init);
778        log::debug!(
779            "Parsed {} instruments and {} transient condition_id(s) from params query",
780            instruments.len(),
781            transient.len(),
782        );
783        Ok((instruments, transient))
784    }
785
786    /// Fetches raw Gamma markets using arbitrary query params with auto-pagination.
787    pub async fn request_markets_by_params(
788        &self,
789        base_params: GetGammaMarketsParams,
790    ) -> anyhow::Result<Vec<GammaMarket>> {
791        self.fetch_gamma_markets_paginated(base_params).await
792    }
793
794    /// Fetches instruments from an event's embedded markets with client-side sorting and limiting.
795    ///
796    /// Only these `GetGammaMarketsParams` fields are supported:
797    /// - `order`: `"liquidity"`, `"volume"`, `"volume24hr"`, `"competitive"`,
798    ///   `"spread"`, `"best_bid"`, `"one_day_price_change"`, or `"volume_1wk"`.
799    /// - `ascending`: sort direction (default: descending); unused without `order`.
800    /// - `max_markets`: truncate after sorting; without `order`, retain event order.
801    ///
802    /// # Errors
803    ///
804    /// Returns an error if:
805    /// - A parameter or order field is unsupported, rejected before fetching.
806    /// - The event request fails.
807    pub async fn request_instruments_by_event_query(
808        &self,
809        event_slug: &str,
810        params: GetGammaMarketsParams,
811    ) -> anyhow::Result<Vec<InstrumentAny>> {
812        let unsupported = GetGammaMarketsParams {
813            order: None,
814            ascending: None,
815            max_markets: None,
816            ..params.clone()
817        };
818
819        let unsupported = serde_json::to_value(unsupported)?;
820        if let Some(field) = unsupported
821            .as_object()
822            .and_then(|fields| fields.keys().next())
823        {
824            anyhow::bail!("Unsupported event query parameter '{field}'");
825        }
826
827        let compare: Option<fn(&GammaMarket, &GammaMarket) -> std::cmp::Ordering> =
828            if let Some(order_field) = params.order.as_deref() {
829                Some(match order_field {
830                    "liquidity" => |a, b| {
831                        a.liquidity_num
832                            .unwrap_or(Decimal::ZERO)
833                            .cmp(&b.liquidity_num.unwrap_or(Decimal::ZERO))
834                    },
835                    "volume" => |a, b| {
836                        a.volume_num
837                            .unwrap_or(Decimal::ZERO)
838                            .cmp(&b.volume_num.unwrap_or(Decimal::ZERO))
839                    },
840                    "volume24hr" => |a, b| {
841                        a.volume_24hr
842                            .unwrap_or(Decimal::ZERO)
843                            .cmp(&b.volume_24hr.unwrap_or(Decimal::ZERO))
844                    },
845                    "competitive" => |a, b| {
846                        a.competitive
847                            .unwrap_or(0.0)
848                            .partial_cmp(&b.competitive.unwrap_or(0.0))
849                            .unwrap_or(std::cmp::Ordering::Equal)
850                    },
851                    "spread" => |a, b| {
852                        a.spread
853                            .unwrap_or(Decimal::MAX)
854                            .cmp(&b.spread.unwrap_or(Decimal::MAX))
855                    },
856                    "best_bid" => |a, b| {
857                        a.best_bid
858                            .unwrap_or(Decimal::ZERO)
859                            .cmp(&b.best_bid.unwrap_or(Decimal::ZERO))
860                    },
861                    "one_day_price_change" => |a, b| {
862                        a.one_day_price_change
863                            .unwrap_or(Decimal::ZERO)
864                            .cmp(&b.one_day_price_change.unwrap_or(Decimal::ZERO))
865                    },
866                    "volume_1wk" => |a, b| {
867                        a.volume_1wk
868                            .unwrap_or(Decimal::ZERO)
869                            .cmp(&b.volume_1wk.unwrap_or(Decimal::ZERO))
870                    },
871                    _ => anyhow::bail!("Unsupported event query order field '{order_field}'"),
872                })
873            } else {
874                None
875            };
876
877        let events = self.inner.get_gamma_events_by_slug(event_slug).await?;
878        let mut markets = flatten_event_markets(events);
879
880        if markets.is_empty() {
881            log::warn!("No markets found in event slug '{event_slug}'");
882            return Ok(Vec::new());
883        }
884
885        log::debug!("Event '{event_slug}' returned {} markets", markets.len());
886
887        if let Some(compare) = compare {
888            let ascending = params.ascending.unwrap_or(false);
889            markets.sort_by(|a, b| {
890                let cmp = compare(a, b);
891                if ascending { cmp } else { cmp.reverse() }
892            });
893        }
894
895        if let Some(cap) = params.max_markets {
896            markets.truncate(cap as usize);
897        }
898
899        enrich_markets(&mut markets);
900        let ts_init = self.clock.get_time_ns();
901        let instruments = parse_markets_to_instruments(&markets, ts_init);
902        log::debug!(
903            "Parsed {} instruments from event query '{event_slug}'",
904            instruments.len()
905        );
906        Ok(instruments)
907    }
908
909    /// Fetches events from the Gamma API with the given base params, paginating automatically.
910    async fn fetch_gamma_events_paginated(
911        &self,
912        base_params: GetGammaEventsParams,
913    ) -> anyhow::Result<Vec<GammaEvent>> {
914        let page_size = base_params
915            .limit
916            .unwrap_or(GAMMA_EVENTS_KEYSET_PAGE_LIMIT)
917            .min(GAMMA_EVENTS_KEYSET_PAGE_LIMIT);
918        let protocol = CursorProtocol::<GammaStop>::gamma("Gamma event");
919        let reducer = WindowedCollect::new(
920            base_params.offset.unwrap_or(0) as usize,
921            base_params.max_events.map(|value| value as usize),
922            GammaStop::CallerCapped,
923        );
924        let paginator = Paginator::new("Gamma event", protocol, reducer);
925        let completed = paginator
926            .run(
927                |position| {
928                    let after_cursor = position.map(|cursor| cursor.as_ref().to_string());
929                    let params = GetGammaEventsParams {
930                        limit: Some(page_size),
931                        offset: None,
932                        ..base_params.clone()
933                    };
934                    async move {
935                        let response = self
936                            .inner
937                            .get_gamma_events_keyset(params, after_cursor.as_deref())
938                            .await?;
939                        Ok::<_, anyhow::Error>(FetchOutcome::Page {
940                            rows: response.events,
941                            wire: response.next_cursor,
942                        })
943                    }
944                },
945                anyhow::Error::new,
946            )
947            .await?;
948
949        match completed.completion {
950            Completion::WireExhausted | Completion::Stopped(GammaStop::CallerCapped) => {
951                Ok(completed.output)
952            }
953        }
954    }
955
956    /// Fetches instruments from events matching full query params (paginated).
957    pub async fn request_instruments_by_event_params(
958        &self,
959        params: GetGammaEventsParams,
960    ) -> anyhow::Result<Vec<InstrumentAny>> {
961        let events = self.fetch_gamma_events_paginated(params).await?;
962        let ts_init = self.clock.get_time_ns();
963        let total_events = events.len();
964        let mut markets = flatten_event_markets(events);
965        let total_markets = markets.len();
966        enrich_markets(&mut markets);
967        let instruments = parse_markets_to_instruments(&markets, ts_init);
968        log::debug!(
969            "Parsed {} instruments from {total_events} events ({total_markets} markets)",
970            instruments.len(),
971        );
972        Ok(instruments)
973    }
974
975    /// Fetches raw Gamma events using arbitrary query params with auto-pagination.
976    pub async fn request_events_by_params(
977        &self,
978        params: GetGammaEventsParams,
979    ) -> anyhow::Result<Vec<GammaEvent>> {
980        self.fetch_gamma_events_paginated(params).await
981    }
982
983    /// Searches for instruments via the Gamma public search endpoint.
984    pub async fn request_instruments_by_search(
985        &self,
986        params: GetSearchParams,
987    ) -> anyhow::Result<Vec<InstrumentAny>> {
988        let response = self.inner.get_public_search(params).await?;
989        let ts_init = self.clock.get_time_ns();
990
991        let mut instruments = Vec::new();
992
993        if let Some(markets) = response.markets {
994            let mut markets = markets;
995            enrich_markets(&mut markets);
996            instruments.extend(parse_markets_to_instruments(&markets, ts_init));
997        }
998
999        if let Some(events) = &response.events {
1000            let mut event_markets = flatten_event_markets(events.clone());
1001            enrich_markets(&mut event_markets);
1002            instruments.extend(parse_markets_to_instruments(&event_markets, ts_init));
1003        }
1004
1005        log::debug!("Parsed {} instruments from search query", instruments.len());
1006        Ok(instruments)
1007    }
1008
1009    /// Fetches available tags from the Gamma API.
1010    pub async fn request_tags(&self) -> anyhow::Result<Vec<GammaTag>> {
1011        Ok(self.inner.get_gamma_tags().await?)
1012    }
1013
1014    /// Returns a reference to the underlying raw HTTP client.
1015    #[must_use]
1016    pub fn inner(&self) -> &Arc<PolymarketGammaRawHttpClient> {
1017        &self.inner
1018    }
1019}
1020
1021fn enrich_markets(markets: &mut [GammaMarket]) {
1022    for market in markets {
1023        enrich_market_fee_schedule(market);
1024    }
1025}
1026
1027fn parse_gamma_markets_response(raw: &RawValue) -> Result<Vec<GammaMarket>> {
1028    #[derive(Deserialize)]
1029    struct MarketsEnvelope {
1030        data: Vec<GammaMarket>,
1031    }
1032
1033    if raw.get().starts_with('[') {
1034        return serde_json::from_str(raw.get()).map_err(Error::Serde);
1035    }
1036    serde_json::from_str::<MarketsEnvelope>(raw.get())
1037        .map(|envelope| envelope.data)
1038        .map_err(Error::Serde)
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use nautilus_common::cache::Cache;
1044    use nautilus_model::instruments::Instrument;
1045    use rstest::rstest;
1046    use rust_decimal_macros::dec;
1047
1048    use super::*;
1049    use crate::http::{parse::parse_gamma_market, query::GetGammaMarketsParamsBuilder};
1050
1051    fn load_fee_market(filename: &str) -> GammaMarket {
1052        let path = format!("test_data/{filename}");
1053        let content = std::fs::read_to_string(path).unwrap();
1054        serde_json::from_str(&content).unwrap()
1055    }
1056
1057    fn instrument_fee_schedule(instrument: &InstrumentAny) -> crate::http::models::FeeSchedule {
1058        let InstrumentAny::BinaryOption(binary) = instrument else {
1059            panic!("expected a binary option instrument");
1060        };
1061
1062        let info = binary.info.as_ref().unwrap();
1063        let value = info.get("fee_schedule").unwrap();
1064        serde_json::from_value(value.clone()).unwrap()
1065    }
1066
1067    #[rstest]
1068    #[case(Some("v1"), true)]
1069    #[case(Some("v2"), false)]
1070    #[case(None, false)]
1071    #[case(Some("v3"), false)]
1072    #[case(Some("V1"), false)]
1073    fn test_market_protocol_controls_discovery(
1074        #[case] version: Option<&str>,
1075        #[case] supported: bool,
1076        #[values("gamma_market_protocol_v1.json", "gamma_market_protocol_v2.json")] fixture: &str,
1077        #[values(false, true)] empty_ids: bool,
1078    ) {
1079        let mut market = load_fee_market(fixture);
1080        market.version = version.map(str::to_string);
1081        if empty_ids {
1082            market.clob_token_ids = "[]".into();
1083        }
1084
1085        let condition_id = market.condition_id.clone();
1086        let (instruments, transient) = parse_markets_with_transient(&[market.clone()], 1.into());
1087        assert_eq!(
1088            instruments.len(),
1089            if supported && !empty_ids { 2 } else { 0 }
1090        );
1091        assert_eq!(
1092            transient,
1093            if supported && empty_ids {
1094                vec![condition_id]
1095            } else {
1096                vec![]
1097            }
1098        );
1099
1100        if supported && !empty_ids {
1101            for instrument in instruments {
1102                let InstrumentAny::BinaryOption(binary) = instrument else {
1103                    panic!("expected binary option")
1104                };
1105
1106                assert_eq!(
1107                    binary.info.as_ref().unwrap().get_str("market_version"),
1108                    Some("v1")
1109                );
1110            }
1111        } else if !supported {
1112            let error = crate::http::parse::parse_gamma_market(&market).unwrap_err();
1113            assert_eq!(
1114                error.to_string(),
1115                format!(
1116                    "Unsupported Polymarket market protocol {}; only v1 CTF markets are supported",
1117                    version.unwrap_or("missing"),
1118                )
1119            );
1120        }
1121    }
1122
1123    #[rstest]
1124    fn test_live_instrument_funnel_retains_gamma_metadata() {
1125        let raw = include_str!("../../test_data/gamma_market_metadata.json");
1126        let market: GammaMarket = serde_json::from_str(raw).unwrap();
1127        let expected = raw.trim();
1128        let (instruments, transient) = parse_markets_with_transient(&[market], 1.into());
1129        assert_eq!(instruments.len(), 2);
1130        assert_eq!(transient, Vec::<String>::new());
1131
1132        for instrument in instruments {
1133            let InstrumentAny::BinaryOption(binary) = instrument else {
1134                unreachable!()
1135            };
1136
1137            assert_eq!(binary.event_id.map(|id| id.as_str()), Some("event-456"));
1138            let info = binary.info.unwrap();
1139            assert_eq!(info.get_str("gamma_market"), Some(expected));
1140            assert_eq!(info.get_bool("closed"), Some(false));
1141        }
1142    }
1143
1144    #[rstest]
1145    fn test_event_discovery_retains_parent_metadata() {
1146        let raw = include_str!("../../test_data/gamma_event.json");
1147        let events: Vec<GammaEvent> = serde_json::from_str(raw).unwrap();
1148        let expected: Vec<Value> = serde_json::from_str(raw).unwrap();
1149        let markets = flatten_event_markets(events);
1150        assert_eq!(markets.len(), 2);
1151
1152        for market in &markets {
1153            let parent = market.parent_event.as_ref().unwrap();
1154            let expected_event = expected
1155                .iter()
1156                .find(|event| event["id"] == parent.id)
1157                .unwrap();
1158            let expected_market = expected_event["markets"]
1159                .as_array()
1160                .unwrap()
1161                .iter()
1162                .find(|raw| raw["id"] == market.id)
1163                .unwrap();
1164            assert_eq!(
1165                serde_json::from_str::<Value>(&parent.raw).unwrap(),
1166                *expected_event
1167            );
1168            assert_eq!(
1169                serde_json::from_str::<Value>(&market.raw).unwrap(),
1170                *expected_market
1171            );
1172            let defs = parse_gamma_market(market).unwrap();
1173            for def in defs {
1174                assert_eq!(def.event_id.unwrap().as_str(), parent.id);
1175                assert_eq!(
1176                    def.gamma_event.as_ref().unwrap().as_str(),
1177                    Some(parent.raw.as_str())
1178                );
1179                assert_eq!(def.gamma_market, market.raw);
1180                let instrument = create_instrument_from_def(&def, 1.into()).unwrap();
1181
1182                let InstrumentAny::BinaryOption(binary) = instrument else {
1183                    unreachable!()
1184                };
1185
1186                let info = binary.info.unwrap();
1187                assert_eq!(binary.event_id.unwrap().as_str(), parent.id);
1188                assert_eq!(info.get_str("gamma_event"), Some(parent.raw.as_str()));
1189                assert_eq!(info.get_str("gamma_market"), Some(market.raw.as_str()));
1190            }
1191        }
1192    }
1193
1194    #[rstest]
1195    #[case(1)]
1196    #[case(8)]
1197    #[case(32)]
1198    fn test_event_metadata_shares_storage_across_instruments_and_clones(
1199        #[case] market_count: usize,
1200    ) {
1201        let mut events: Vec<GammaEvent> =
1202            serde_json::from_str(include_str!("../../test_data/gamma_event.json")).unwrap();
1203        let mut event = events.remove(0);
1204        let market = event.markets[0].clone();
1205        event.markets = (0..market_count)
1206            .map(|index| {
1207                let mut market = market.clone();
1208                market.id = format!("market-{index}");
1209                market.condition_id = format!("condition-{index}");
1210                market.clob_token_ids = serde_json::to_string(&[
1211                    format!("{}", 2 * index + 1),
1212                    format!("{}", 2 * index + 2),
1213                ])
1214                .unwrap();
1215                market
1216            })
1217            .collect();
1218
1219        let expected = event.raw.clone();
1220        let markets = flatten_event_markets(vec![event]);
1221        let (instruments, transient) = parse_markets_with_transient(&markets, 1.into());
1222        let event = instruments[0].info().unwrap().get("gamma_event").unwrap();
1223        let mut pointers = std::collections::HashSet::new();
1224        let mut cache = Cache::default();
1225
1226        assert_eq!(instruments.len(), 2 * market_count);
1227        assert_eq!(transient, Vec::<String>::new());
1228        assert_eq!(event.as_str(), Some(expected.as_str()));
1229
1230        for instrument in &instruments {
1231            cache.add_instrument(instrument.clone()).unwrap();
1232            let cloned = cache.instrument(&instrument.id()).unwrap().clone();
1233            for instrument in [instrument, &cloned] {
1234                let info = instrument.info().unwrap();
1235                let value = info.get("gamma_event").unwrap();
1236                pointers.insert(value.as_str().unwrap().as_ptr());
1237                assert!(std::ptr::eq(value, event));
1238                assert_eq!(info.get_bool("closed"), Some(false));
1239            }
1240        }
1241
1242        assert_eq!(pointers.len(), 1);
1243    }
1244
1245    #[rstest]
1246    fn test_event_metadata_keeps_distinct_snapshots_with_the_same_id() {
1247        let mut events: Vec<GammaEvent> =
1248            serde_json::from_str(include_str!("../../test_data/gamma_event.json")).unwrap();
1249        let event = events.remove(0);
1250        let mut updated = event.clone();
1251        let mut value: Value = serde_json::from_str(&updated.raw).unwrap();
1252        value["snapshot"] = Value::String("updated".to_string());
1253        updated.raw = serde_json::to_string(&value).unwrap();
1254        let original_raw = event.raw.clone();
1255        let updated_raw = updated.raw.clone();
1256        let markets_per_event = event.markets.len();
1257        let markets = flatten_event_markets(vec![event, updated]);
1258        let (instruments, transient) = parse_markets_with_transient(&markets, 1.into());
1259        let snapshots = instruments
1260            .chunks(2 * markets_per_event)
1261            .collect::<Vec<_>>();
1262
1263        assert_eq!(transient, Vec::<String>::new());
1264        assert_eq!(snapshots.len(), 2);
1265
1266        for (snapshot, expected) in snapshots.iter().zip([original_raw, updated_raw]) {
1267            let event = snapshot[0].info().unwrap().get("gamma_event").unwrap();
1268            assert_eq!(event.as_str(), Some(expected.as_str()));
1269
1270            for instrument in *snapshot {
1271                assert!(std::ptr::eq(
1272                    instrument.info().unwrap().get("gamma_event").unwrap(),
1273                    event
1274                ));
1275            }
1276        }
1277
1278        assert!(!std::ptr::eq(
1279            snapshots[0][0].info().unwrap().get("gamma_event").unwrap(),
1280            snapshots[1][0].info().unwrap().get("gamma_event").unwrap()
1281        ));
1282    }
1283
1284    #[rstest]
1285    #[case(GetGammaMarketsParamsBuilder::default().active(false).build().unwrap(), "active")]
1286    #[case(GetGammaMarketsParamsBuilder::default().closed(false).build().unwrap(), "closed")]
1287    #[case(GetGammaMarketsParamsBuilder::default().archived(false).build().unwrap(), "archived")]
1288    #[case(GetGammaMarketsParamsBuilder::default().id(vec![42]).build().unwrap(), "id")]
1289    #[case(GetGammaMarketsParamsBuilder::default().limit(1_u32).build().unwrap(), "limit")]
1290    #[case(GetGammaMarketsParamsBuilder::default().offset(0_u32).build().unwrap(), "offset")]
1291    #[case(GetGammaMarketsParamsBuilder::default().slug(vec!["other".into()]).build().unwrap(), "slug")]
1292    #[case(GetGammaMarketsParamsBuilder::default().clob_token_ids(vec![]).build().unwrap(), "clob_token_ids")]
1293    #[case(GetGammaMarketsParamsBuilder::default().condition_ids(vec!["condition".into()]).build().unwrap(), "condition_ids")]
1294    #[case(GetGammaMarketsParamsBuilder::default().question_ids(vec!["question".into()]).build().unwrap(), "question_ids")]
1295    #[case(GetGammaMarketsParamsBuilder::default().market_maker_address(vec!["maker".into()]).build().unwrap(), "market_maker_address")]
1296    #[case(GetGammaMarketsParamsBuilder::default().liquidity_num_min(dec!(1.25)).build().unwrap(), "liquidity_num_min")]
1297    #[case(GetGammaMarketsParamsBuilder::default().liquidity_num_max(dec!(8.75)).build().unwrap(), "liquidity_num_max")]
1298    #[case(GetGammaMarketsParamsBuilder::default().volume_num_min(dec!(2.5)).build().unwrap(), "volume_num_min")]
1299    #[case(GetGammaMarketsParamsBuilder::default().volume_num_max(dec!(9.5)).build().unwrap(), "volume_num_max")]
1300    #[case(GetGammaMarketsParamsBuilder::default().start_date_min("2026-01-01").build().unwrap(), "start_date_min")]
1301    #[case(GetGammaMarketsParamsBuilder::default().start_date_max("2026-12-31").build().unwrap(), "start_date_max")]
1302    #[case(GetGammaMarketsParamsBuilder::default().end_date_min("2027-01-01").build().unwrap(), "end_date_min")]
1303    #[case(GetGammaMarketsParamsBuilder::default().end_date_max("2027-12-31").build().unwrap(), "end_date_max")]
1304    #[case(GetGammaMarketsParamsBuilder::default().tag_id(vec![42]).build().unwrap(), "tag_id")]
1305    #[case(GetGammaMarketsParamsBuilder::default().related_tags(false).build().unwrap(), "related_tags")]
1306    #[case(GetGammaMarketsParamsBuilder::default().tag_match("any").build().unwrap(), "tag_match")]
1307    #[case(GetGammaMarketsParamsBuilder::default().decimalized(false).build().unwrap(), "decimalized")]
1308    #[case(GetGammaMarketsParamsBuilder::default().cyom(false).build().unwrap(), "cyom")]
1309    #[case(GetGammaMarketsParamsBuilder::default().rfq_enabled(false).build().unwrap(), "rfq_enabled")]
1310    #[case(GetGammaMarketsParamsBuilder::default().uma_resolution_status("resolved").build().unwrap(), "uma_resolution_status")]
1311    #[case(GetGammaMarketsParamsBuilder::default().game_id("game").build().unwrap(), "game_id")]
1312    #[case(GetGammaMarketsParamsBuilder::default().sports_market_types(vec!["moneyline".into()]).build().unwrap(), "sports_market_types")]
1313    #[case(GetGammaMarketsParamsBuilder::default().include_tag(false).build().unwrap(), "include_tag")]
1314    #[case(GetGammaMarketsParamsBuilder::default().locale("en").build().unwrap(), "locale")]
1315    #[tokio::test]
1316    async fn test_event_query_rejects_unsupported_params_before_http(
1317        #[case] params: GetGammaMarketsParams,
1318        #[case] field: &str,
1319    ) {
1320        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1321        listener.set_nonblocking(true).unwrap();
1322        let address = listener.local_addr().unwrap();
1323        let client = PolymarketGammaHttpClient::new(
1324            Some(format!("http://{address}")),
1325            1,
1326            RetryConfig::default(),
1327        )
1328        .unwrap();
1329        let error = client
1330            .request_instruments_by_event_query("unused", params)
1331            .await
1332            .unwrap_err();
1333
1334        assert_eq!(
1335            error.to_string(),
1336            format!("Unsupported event query parameter '{field}'")
1337        );
1338        assert!(matches!(
1339            listener.accept(),
1340            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock
1341        ));
1342    }
1343
1344    #[rstest]
1345    #[case("volumeNum")]
1346    #[case("liquidityNum")]
1347    #[case("unknown")]
1348    #[case("")]
1349    #[tokio::test]
1350    async fn test_event_query_rejects_unsupported_order_before_http(#[case] order: &str) {
1351        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1352        listener.set_nonblocking(true).unwrap();
1353        let address = listener.local_addr().unwrap();
1354        let client = PolymarketGammaHttpClient::new(
1355            Some(format!("http://{address}")),
1356            1,
1357            RetryConfig::default(),
1358        )
1359        .unwrap();
1360        let params = GetGammaMarketsParamsBuilder::default()
1361            .order(order)
1362            .max_markets(1_u32)
1363            .build()
1364            .unwrap();
1365        let error = client
1366            .request_instruments_by_event_query("unused", params)
1367            .await
1368            .unwrap_err();
1369
1370        assert_eq!(
1371            error.to_string(),
1372            format!("Unsupported event query order field '{order}'")
1373        );
1374        assert!(matches!(
1375            listener.accept(),
1376            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock
1377        ));
1378    }
1379
1380    #[rstest]
1381    #[case("liquidity")]
1382    #[case("volume")]
1383    #[case("volume24hr")]
1384    #[case("competitive")]
1385    #[case("spread")]
1386    #[case("best_bid")]
1387    #[case("one_day_price_change")]
1388    #[case("volume_1wk")]
1389    #[tokio::test]
1390    async fn test_event_sort_preserves_adjacent_decimal_values(
1391        #[case] field: &str,
1392        #[values(None, Some(true), Some(false))] ascending: Option<bool>,
1393    ) {
1394        let mut lower: GammaMarket =
1395            serde_json::from_str(include_str!("../../test_data/gamma_market.json")).unwrap();
1396        lower.clob_token_ids = serde_json::to_string(&["1", "2"]).unwrap();
1397        let mut higher = lower.clone();
1398        higher.clob_token_ids = serde_json::to_string(&["3", "4"]).unwrap();
1399
1400        for (market, value) in [
1401            (&mut lower, dec!(0.1234567890123456789012345678)),
1402            (&mut higher, dec!(0.1234567890123456789012345679)),
1403        ] {
1404            match field {
1405                "liquidity" => market.liquidity_num = Some(value),
1406                "volume" => market.volume_num = Some(value),
1407                "volume24hr" => market.volume_24hr = Some(value),
1408                "competitive" => {}
1409                "spread" => market.spread = Some(value),
1410                "best_bid" => market.best_bid = Some(value),
1411                "one_day_price_change" => market.one_day_price_change = Some(value),
1412                "volume_1wk" => market.volume_1wk = Some(value),
1413                _ => unreachable!(),
1414            }
1415        }
1416
1417        lower.competitive = Some(0.25);
1418        higher.competitive = Some(0.75);
1419        let mut event: GammaEvent =
1420            serde_json::from_str(include_str!("../../test_data/decimal_precision_event.json"))
1421                .unwrap();
1422
1423        event.markets = if ascending == Some(true) {
1424            vec![higher, lower]
1425        } else {
1426            vec![lower, higher]
1427        };
1428
1429        let response = serde_json::to_string(&vec![event]).unwrap();
1430        let router = axum::Router::new().route(
1431            "/events",
1432            axum::routing::get(move || {
1433                let response = response.clone();
1434                async move { response }
1435            }),
1436        );
1437        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1438        let address = listener.local_addr().unwrap();
1439        let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1440        let client = PolymarketGammaHttpClient::new(
1441            Some(format!("http://{address}")),
1442            5,
1443            RetryConfig::default(),
1444        )
1445        .unwrap();
1446        let instruments = client
1447            .request_instruments_by_event_query(
1448                "precision",
1449                GetGammaMarketsParams {
1450                    order: Some(field.into()),
1451                    ascending,
1452                    max_markets: Some(1),
1453                    ..Default::default()
1454                },
1455            )
1456            .await
1457            .unwrap();
1458        server.abort();
1459        assert_eq!(instruments.len(), 2);
1460
1461        let expected = if ascending == Some(true) {
1462            ["1", "2"]
1463        } else {
1464            ["3", "4"]
1465        };
1466
1467        assert_eq!(instruments[0].raw_symbol().as_str(), expected[0]);
1468        assert_eq!(instruments[1].raw_symbol().as_str(), expected[1]);
1469    }
1470
1471    #[rstest]
1472    #[case(false)]
1473    #[case(true)]
1474    fn test_markets_response_preserves_decimal_precision(#[case] enveloped: bool) {
1475        let market = include_str!("../../test_data/decimal_precision_market.json");
1476        let array = format!("[{market}]");
1477        let raw = if enveloped {
1478            format!("{{\"data\":{array}}}")
1479        } else {
1480            array
1481        };
1482        let markets =
1483            parse_gamma_markets_response(&serde_json::from_str::<Box<RawValue>>(&raw).unwrap())
1484                .unwrap();
1485        assert_eq!(markets.len(), 1);
1486        assert_eq!(
1487            markets[0].best_bid,
1488            Some(dec!(0.1234567890123456789012345678))
1489        );
1490        assert_eq!(markets[0].volume_num, Some(dec!(12345678901.123457)));
1491        assert_eq!(
1492            markets[0].fee_schedule.as_ref().unwrap().rate,
1493            dec!(0.1234567890123456789012345678)
1494        );
1495    }
1496
1497    #[rstest]
1498    #[case("gamma_market_fee_crypto.json", false, dec!(0.07), dec!(0.20))]
1499    #[case("gamma_market_fee_sports.json", false, dec!(0.05), dec!(0.15))]
1500    #[case("gamma_market_fee_politics.json", false, dec!(0.04), dec!(0.25))]
1501    #[case("gamma_market_fee_zero_rate.json", false, Decimal::ZERO, dec!(0.20))]
1502    #[case("gamma_market_fee_crypto.json", true, Decimal::ZERO, dec!(0.20))]
1503    #[tokio::test]
1504    async fn test_request_instruments_by_slugs_enriches_fee_schedules(
1505        #[case] filename: &str,
1506        #[case] missing_schedule: bool,
1507        #[case] rate: Decimal,
1508        #[case] rebate: Decimal,
1509    ) {
1510        let mut market = load_fee_market(filename);
1511        if missing_schedule {
1512            market.fee_schedule = None;
1513        }
1514
1515        let response = serde_json::to_string(&vec![market]).unwrap();
1516        let router = axum::Router::new().route(
1517            "/markets",
1518            axum::routing::get(move || {
1519                let response = response.clone();
1520                async move { response }
1521            }),
1522        );
1523
1524        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1525        let address = listener.local_addr().unwrap();
1526        let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1527        let client = PolymarketGammaHttpClient::new(
1528            Some(format!("http://{address}")),
1529            5,
1530            RetryConfig::default(),
1531        )
1532        .unwrap();
1533        let instruments = client
1534            .request_instruments_by_slugs(vec!["fee-market".to_string()])
1535            .await
1536            .unwrap();
1537        server.abort();
1538
1539        assert_eq!(instruments.len(), 2);
1540
1541        for instrument in &instruments {
1542            let schedule = instrument_fee_schedule(instrument);
1543            assert_eq!(schedule.rate, rate);
1544            assert_eq!(schedule.rebate_rate, rebate);
1545            assert_eq!(schedule.exponent, Decimal::ONE);
1546            assert!(schedule.taker_only);
1547        }
1548    }
1549}