Skip to main content

polymarket_bindings/gamma/
market.rs

1use polymarket_types::{CtfConditionId, DecimalString, EventId, EvmAddress, MarketId, TokenId};
2use serde::{Deserialize, Serialize};
3use std::str::FromStr;
4
5use crate::de::{
6    deserialize_decimalish, deserialize_empty_string_as_none, deserialize_string_array,
7};
8
9/// Reference to an event embedded in a market.
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct MarketEventRef {
12    pub id: polymarket_types::EventId,
13    pub slug: Option<String>,
14    pub title: Option<String>,
15}
16
17/// Tag reference attached to a market or event.
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19pub struct TagReference {
20    pub id: String,
21    pub slug: Option<String>,
22    pub label: Option<String>,
23}
24
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26pub struct MarketState {
27    pub active: Option<bool>,
28    pub closed: Option<bool>,
29    pub archived: Option<bool>,
30    pub accepting_orders: Option<bool>,
31    pub enable_order_book: Option<bool>,
32    pub neg_risk: Option<bool>,
33    pub start_date: Option<String>,
34    pub end_date: Option<String>,
35    pub closed_time: Option<String>,
36}
37
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39pub struct MarketOutcome {
40    pub label: String,
41    pub token_id: Option<TokenId>,
42    pub price: Option<DecimalString>,
43}
44
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct MarketOutcomes {
47    pub yes: MarketOutcome,
48    pub no: MarketOutcome,
49}
50
51#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub struct MarketMetrics {
53    pub volume: Option<DecimalString>,
54    pub volume_num: Option<DecimalString>,
55    pub volume24hr: Option<DecimalString>,
56    pub liquidity: Option<DecimalString>,
57    pub liquidity_num: Option<DecimalString>,
58}
59
60#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
61pub struct MarketPrices {
62    pub best_bid: Option<DecimalString>,
63    pub best_ask: Option<DecimalString>,
64    pub last_trade_price: Option<DecimalString>,
65    pub spread: Option<DecimalString>,
66}
67
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
69pub struct MarketTrading {
70    pub minimum_order_size: Option<DecimalString>,
71    pub minimum_tick_size: Option<DecimalString>,
72    pub seconds_delay: Option<i64>,
73    pub fees_enabled: Option<bool>,
74}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77pub struct MarketResolution {
78    pub question_id: Option<String>,
79    pub uma_resolution_status: Option<String>,
80    pub source: Option<String>,
81    pub resolved_by: Option<EvmAddress>,
82}
83
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct Market {
86    pub id: MarketId,
87    pub slug: Option<String>,
88    pub condition_id: Option<CtfConditionId>,
89    pub question: Option<String>,
90    pub description: Option<String>,
91    pub category: Option<String>,
92    pub image: Option<String>,
93    pub icon: Option<String>,
94    pub state: MarketState,
95    pub outcomes: MarketOutcomes,
96    pub metrics: MarketMetrics,
97    pub prices: MarketPrices,
98    pub trading: MarketTrading,
99    pub resolution: MarketResolution,
100    pub events: Vec<MarketEventRef>,
101    pub tags: Vec<TagReference>,
102}
103
104/// Raw Gamma API market payload (snake_case fields).
105#[derive(Debug, Deserialize)]
106pub struct GammaMarket {
107    pub id: String,
108    pub slug: Option<String>,
109    #[serde(rename = "conditionId")]
110    pub condition_id: Option<String>,
111    pub question: Option<String>,
112    pub description: Option<String>,
113    pub category: Option<String>,
114    pub image: Option<String>,
115    pub icon: Option<String>,
116    pub active: Option<bool>,
117    pub closed: Option<bool>,
118    pub archived: Option<bool>,
119    #[serde(rename = "acceptingOrders")]
120    pub accepting_orders: Option<bool>,
121    #[serde(rename = "enableOrderBook")]
122    pub enable_order_book: Option<bool>,
123    #[serde(rename = "negRisk")]
124    pub neg_risk: Option<bool>,
125    #[serde(rename = "startDate")]
126    pub start_date: Option<String>,
127    #[serde(rename = "endDate")]
128    pub end_date: Option<String>,
129    #[serde(rename = "closedTime")]
130    pub closed_time: Option<String>,
131    #[serde(default, deserialize_with = "deserialize_string_array")]
132    pub outcomes: Vec<String>,
133    #[serde(
134        default,
135        rename = "outcomePrices",
136        deserialize_with = "deserialize_string_array"
137    )]
138    pub outcome_prices: Vec<String>,
139    #[serde(default, deserialize_with = "deserialize_decimalish")]
140    pub volume: Option<String>,
141    #[serde(
142        default,
143        rename = "volumeNum",
144        deserialize_with = "deserialize_decimalish"
145    )]
146    pub volume_num: Option<String>,
147    #[serde(
148        default,
149        rename = "volume24hr",
150        deserialize_with = "deserialize_decimalish"
151    )]
152    pub volume24hr: Option<String>,
153    #[serde(default, deserialize_with = "deserialize_decimalish")]
154    pub liquidity: Option<String>,
155    #[serde(
156        default,
157        rename = "liquidityNum",
158        deserialize_with = "deserialize_decimalish"
159    )]
160    pub liquidity_num: Option<String>,
161    #[serde(
162        default,
163        rename = "bestBid",
164        deserialize_with = "deserialize_decimalish"
165    )]
166    pub best_bid: Option<String>,
167    #[serde(
168        default,
169        rename = "bestAsk",
170        deserialize_with = "deserialize_decimalish"
171    )]
172    pub best_ask: Option<String>,
173    #[serde(
174        default,
175        rename = "lastTradePrice",
176        deserialize_with = "deserialize_decimalish"
177    )]
178    pub last_trade_price: Option<String>,
179    #[serde(default, deserialize_with = "deserialize_decimalish")]
180    pub spread: Option<String>,
181    #[serde(
182        default,
183        rename = "orderMinSize",
184        deserialize_with = "deserialize_decimalish"
185    )]
186    pub order_min_size: Option<String>,
187    #[serde(
188        default,
189        rename = "orderPriceMinTickSize",
190        deserialize_with = "deserialize_decimalish"
191    )]
192    pub order_price_min_tick_size: Option<String>,
193    #[serde(rename = "secondsDelay")]
194    pub seconds_delay: Option<i64>,
195    #[serde(rename = "feesEnabled")]
196    pub fees_enabled: Option<bool>,
197    #[serde(rename = "questionID")]
198    pub question_id: Option<String>,
199    #[serde(rename = "umaResolutionStatus")]
200    pub uma_resolution_status: Option<String>,
201    #[serde(rename = "resolutionSource")]
202    pub resolution_source: Option<String>,
203    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
204    pub resolved_by: Option<String>,
205    #[serde(
206        default,
207        rename = "clobTokenIds",
208        deserialize_with = "deserialize_string_array"
209    )]
210    pub clob_token_ids: Vec<String>,
211    #[serde(default)]
212    pub(crate) events: Vec<GammaMarketEvent>,
213    #[serde(default)]
214    pub tags: Vec<TagReference>,
215}
216
217#[derive(Debug, Deserialize)]
218pub(crate) struct GammaMarketEvent {
219    id: String,
220    slug: Option<String>,
221    title: Option<String>,
222}
223
224pub const fn has_binary_outcomes(market: &GammaMarket) -> bool {
225    market.outcomes.len() == 2
226}
227
228pub fn try_normalize_market(raw: GammaMarket) -> Result<Market, String> {
229    if !has_binary_outcomes(&raw) {
230        return Err(format!(
231            "expected binary market outcomes, received {}",
232            raw.outcomes.len()
233        ));
234    }
235
236    let id = MarketId::parse(raw.id).map_err(|e| e.message)?;
237    let condition_id = raw
238        .condition_id
239        .as_deref()
240        .map(CtfConditionId::parse)
241        .transpose()
242        .map_err(|e| e.message)?;
243
244    let resolved_by = raw
245        .resolved_by
246        .as_deref()
247        .map(EvmAddress::from_str)
248        .transpose()
249        .map_err(|e| e.message)?;
250
251    let parse_decimal = |s: Option<String>| {
252        s.filter(|v| !v.is_empty())
253            .and_then(|v| DecimalString::parse(v).ok())
254    };
255
256    let yes_label = raw.outcomes[0].clone();
257    let no_label = raw.outcomes[1].clone();
258
259    Ok(Market {
260        id,
261        slug: raw.slug,
262        condition_id,
263        question: raw.question,
264        description: raw.description,
265        category: raw.category,
266        image: raw.image,
267        icon: raw.icon,
268        state: MarketState {
269            active: raw.active,
270            closed: raw.closed,
271            archived: raw.archived,
272            accepting_orders: raw.accepting_orders,
273            enable_order_book: raw.enable_order_book,
274            neg_risk: raw.neg_risk,
275            start_date: raw.start_date,
276            end_date: raw.end_date,
277            closed_time: raw.closed_time,
278        },
279        outcomes: MarketOutcomes {
280            yes: MarketOutcome {
281                label: yes_label,
282                token_id: raw
283                    .clob_token_ids
284                    .first()
285                    .and_then(|t| TokenId::parse(t.clone()).ok()),
286                price: raw
287                    .outcome_prices
288                    .first()
289                    .and_then(|p| DecimalString::parse(p.clone()).ok()),
290            },
291            no: MarketOutcome {
292                label: no_label,
293                token_id: raw
294                    .clob_token_ids
295                    .get(1)
296                    .and_then(|t| TokenId::parse(t.clone()).ok()),
297                price: raw
298                    .outcome_prices
299                    .get(1)
300                    .and_then(|p| DecimalString::parse(p.clone()).ok()),
301            },
302        },
303        metrics: MarketMetrics {
304            volume: parse_decimal(raw.volume),
305            volume_num: parse_decimal(raw.volume_num),
306            volume24hr: parse_decimal(raw.volume24hr),
307            liquidity: parse_decimal(raw.liquidity),
308            liquidity_num: parse_decimal(raw.liquidity_num),
309        },
310        prices: MarketPrices {
311            best_bid: parse_decimal(raw.best_bid),
312            best_ask: parse_decimal(raw.best_ask),
313            last_trade_price: parse_decimal(raw.last_trade_price),
314            spread: parse_decimal(raw.spread),
315        },
316        trading: MarketTrading {
317            minimum_order_size: parse_decimal(raw.order_min_size),
318            minimum_tick_size: parse_decimal(raw.order_price_min_tick_size),
319            seconds_delay: raw.seconds_delay,
320            fees_enabled: raw.fees_enabled,
321        },
322        resolution: MarketResolution {
323            question_id: raw.question_id,
324            uma_resolution_status: raw.uma_resolution_status,
325            source: raw.resolution_source,
326            resolved_by,
327        },
328        events: raw
329            .events
330            .into_iter()
331            .filter_map(|e| {
332                Some(MarketEventRef {
333                    id: EventId::parse(e.id).ok()?,
334                    slug: e.slug,
335                    title: e.title,
336                })
337            })
338            .collect(),
339        tags: raw.tags,
340    })
341}
342
343pub fn normalize_market(raw: GammaMarket) -> Market {
344    try_normalize_market(raw).expect("binary market normalization should succeed")
345}
346
347#[derive(Debug, Deserialize)]
348pub struct ListMarketsKeysetRaw {
349    pub markets: Vec<GammaMarket>,
350    pub next_cursor: Option<String>,
351}
352
353#[derive(Debug)]
354pub struct ListMarketsKeysetResponse {
355    pub items: Vec<Market>,
356    pub next_cursor: Option<polymarket_types::PaginationCursor>,
357}
358
359impl ListMarketsKeysetResponse {
360    pub fn from_raw(raw: ListMarketsKeysetRaw) -> Self {
361        let items = raw
362            .markets
363            .into_iter()
364            .filter_map(|m| try_normalize_market(m).ok())
365            .collect();
366        let next_cursor = raw
367            .next_cursor
368            .and_then(|c| polymarket_types::PaginationCursor::parse(c).ok());
369        Self { items, next_cursor }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn normalizes_binary_market() {
379        let raw = GammaMarket {
380            id: "123".into(),
381            slug: Some("test-market".into()),
382            condition_id: Some(
383                "0x4cd77d456c83e7d8c569a8fb8f6396c3f40154f657e6d970733e2b1b6a7110ff".into(),
384            ),
385            question: Some("Will it happen?".into()),
386            description: None,
387            category: Some("politics".into()),
388            image: None,
389            icon: None,
390            active: Some(true),
391            closed: Some(false),
392            archived: Some(false),
393            accepting_orders: Some(true),
394            enable_order_book: Some(true),
395            neg_risk: Some(false),
396            start_date: None,
397            end_date: None,
398            closed_time: None,
399            outcomes: vec!["Yes".into(), "No".into()],
400            outcome_prices: vec!["0.52".into(), "0.48".into()],
401            volume: Some("1000".into()),
402            volume_num: None,
403            volume24hr: None,
404            liquidity: None,
405            liquidity_num: None,
406            best_bid: Some("0.51".into()),
407            best_ask: Some("0.53".into()),
408            last_trade_price: Some("0.52".into()),
409            spread: Some("0.02".into()),
410            order_min_size: Some("5".into()),
411            order_price_min_tick_size: Some("0.01".into()),
412            seconds_delay: None,
413            fees_enabled: Some(false),
414            question_id: None,
415            uma_resolution_status: None,
416            resolution_source: None,
417            resolved_by: None,
418            clob_token_ids: vec!["token-yes".into(), "token-no".into()],
419            events: vec![],
420            tags: vec![],
421        };
422
423        let market = normalize_market(raw);
424        assert_eq!(market.id.as_str(), "123");
425        assert_eq!(market.outcomes.yes.label, "Yes");
426        assert_eq!(
427            market.outcomes.yes.token_id.as_ref().map(|t| t.as_str()),
428            Some("token-yes")
429        );
430    }
431
432    #[test]
433    fn skips_non_binary_in_list_response() {
434        let raw = ListMarketsKeysetRaw {
435            markets: vec![GammaMarket {
436                id: "1".into(),
437                slug: None,
438                condition_id: None,
439                question: None,
440                description: None,
441                category: None,
442                image: None,
443                icon: None,
444                active: None,
445                closed: None,
446                archived: None,
447                accepting_orders: None,
448                enable_order_book: None,
449                neg_risk: None,
450                start_date: None,
451                end_date: None,
452                closed_time: None,
453                outcomes: vec!["A".into(), "B".into(), "C".into()],
454                outcome_prices: vec![],
455                volume: None,
456                volume_num: None,
457                volume24hr: None,
458                liquidity: None,
459                liquidity_num: None,
460                best_bid: None,
461                best_ask: None,
462                last_trade_price: None,
463                spread: None,
464                order_min_size: None,
465                order_price_min_tick_size: None,
466                seconds_delay: None,
467                fees_enabled: None,
468                question_id: None,
469                uma_resolution_status: None,
470                resolution_source: None,
471                resolved_by: None,
472                clob_token_ids: vec![],
473                events: vec![],
474                tags: vec![],
475            }],
476            next_cursor: None,
477        };
478
479        let response = ListMarketsKeysetResponse::from_raw(raw);
480        assert!(response.items.is_empty());
481    }
482}