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#[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#[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#[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}