1use 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#[derive(Debug, Clone)]
74pub struct PolymarketGammaRawHttpClient {
75 client: HttpClient,
76 base_url: String,
77}
78
79impl PolymarketGammaRawHttpClient {
80 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 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 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 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 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 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(¶ms)).await
203 }
204
205 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 pub async fn get_gamma_tags(&self) -> Result<Vec<GammaTag>> {
229 self.send_get::<(), _>("/tags", None::<&()>).await
230 }
231
232 pub async fn get_public_search(&self, params: GetSearchParams) -> Result<SearchResponse> {
234 self.send_get("/public-search", Some(¶ms)).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
353pub(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 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
408fn 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn request_tags(&self) -> anyhow::Result<Vec<GammaTag>> {
1011 Ok(self.inner.get_gamma_tags().await?)
1012 }
1013
1014 #[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}