Skip to main content

digdigdig3/l3/open/prediction/polymarket/
parser.rs

1//! Polymarket response parser
2//!
3//! Contains all Polymarket-specific domain types and conversion functions
4//! to V5 core types.
5//!
6//! ## Type hierarchy
7//!
8//! Polymarket types (parse raw JSON) → V5 core types (used by chart/UI)
9//! - `PolyMarket` → `SymbolInfo`, `Ticker`
10//! - `PriceHistoryPoint` → `Kline`
11//! - `PolyOrderBook` → `OrderBook`
12
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value;
15
16use crate::core::types::{
17    AccountType, ExchangeError, ExchangeResult, Kline, OrderBook, OrderBookLevel, SymbolInfo, Ticker,
18};
19
20// ═══════════════════════════════════════════════════════════════════════════
21// CUSTOM DESERIALIZERS
22// ═══════════════════════════════════════════════════════════════════════════
23
24/// Deserialize arrays that may be native JSON arrays or stringified JSON.
25///
26/// The Gamma API sometimes returns arrays as JSON strings (e.g., `"[\"Yes\", \"No\"]"`).
27fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
28where
29    D: Deserializer<'de>,
30{
31    let value: Option<Value> = Option::deserialize(deserializer)?;
32    match value {
33        None | Some(Value::Null) => Ok(None),
34        Some(Value::Array(arr)) => {
35            let vec = arr
36                .iter()
37                .filter_map(|v| v.as_str().map(String::from))
38                .collect();
39            Ok(Some(vec))
40        }
41        Some(Value::String(s)) => match serde_json::from_str(&s) {
42            Ok(parsed) => Ok(Some(parsed)),
43            Err(_) => Ok(Some(vec![s])),
44        },
45        _ => Ok(None),
46    }
47}
48
49/// Deserialize a string field to f64 (handles both string and number)
50fn deserialize_string_to_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
51where
52    D: Deserializer<'de>,
53{
54    use serde::de::Error;
55    let v: Value = Value::deserialize(deserializer)?;
56    match v {
57        Value::Number(n) => n
58            .as_f64()
59            .ok_or_else(|| Error::custom("number out of range")),
60        Value::String(s) => {
61            // Normalize leading dot: ".48" -> "0.48"
62            let s = if s.starts_with('.') {
63                format!("0{}", s)
64            } else {
65                s
66            };
67            s.parse::<f64>()
68                .map_err(|_| Error::custom(format!("invalid float: {}", s)))
69        }
70        _ => Err(Error::custom("expected string or number")),
71    }
72}
73
74/// Deserialize optional string to optional f64
75fn _deserialize_opt_string_to_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
76where
77    D: Deserializer<'de>,
78{
79    use serde::de::Error;
80    let v: Option<Value> = Option::deserialize(deserializer)?;
81    match v {
82        None | Some(Value::Null) => Ok(None),
83        Some(Value::Number(n)) => Ok(n.as_f64()),
84        Some(Value::String(s)) => {
85            let s = if s.starts_with('.') {
86                format!("0{}", s)
87            } else {
88                s
89            };
90            if s.is_empty() {
91                Ok(None)
92            } else {
93                s.parse::<f64>()
94                    .map(Some)
95                    .map_err(|_| Error::custom(format!("invalid float: {}", s)))
96            }
97        }
98        _ => Ok(None),
99    }
100}
101
102/// Deserialize a field that may be a JSON string, number, or null into an optional String.
103///
104/// The Polymarket CLOB API returns `minimum_order_size` and `minimum_tick_size` as numbers
105/// in some responses (e.g. `15` or `0.01`) even though the documented type is string.
106fn deserialize_number_or_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
107where
108    D: Deserializer<'de>,
109{
110    let v: Option<Value> = Option::deserialize(deserializer)?;
111    match v {
112        None | Some(Value::Null) => Ok(None),
113        Some(Value::String(s)) => Ok(Some(s)),
114        Some(Value::Number(n)) => Ok(Some(n.to_string())),
115        Some(other) => Ok(Some(other.to_string())),
116    }
117}
118
119// ═══════════════════════════════════════════════════════════════════════════
120// GAMMA API TYPES
121// ═══════════════════════════════════════════════════════════════════════════
122
123/// A prediction market from the Gamma API.
124///
125/// Represents a single binary YES/NO prediction market.
126/// Source: GET `https://gamma-api.polymarket.com/markets`
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct PolyMarket {
130    // Core identification
131    /// Unique numeric market identifier
132    pub id: String,
133    /// Blockchain condition ID (0x + 64 hex chars) — primary CLOB identifier
134    #[serde(default)]
135    pub condition_id: Option<String>,
136    /// Alternative identifier
137    #[serde(default)]
138    pub question_id: Option<String>,
139    /// URL-friendly identifier
140    #[serde(default)]
141    pub slug: Option<String>,
142    /// The prediction question text
143    #[serde(default)]
144    pub question: Option<String>,
145
146    // Outcomes and tokens
147    /// Outcome labels, e.g. `["Yes", "No"]`
148    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
149    pub outcomes: Option<Vec<String>>,
150    /// Current prices (0.0-1.0) as strings, matches outcomes order
151    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
152    pub outcome_prices: Option<Vec<String>>,
153    /// CLOB token IDs for trading, matches outcomes order
154    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
155    pub clob_token_ids: Option<Vec<String>>,
156
157    // Pricing
158    #[serde(default)]
159    pub last_trade_price: Option<f64>,
160    #[serde(default)]
161    pub best_bid: Option<f64>,
162    #[serde(default)]
163    pub best_ask: Option<f64>,
164    #[serde(default)]
165    pub spread: Option<f64>,
166    #[serde(default)]
167    pub one_day_price_change: Option<f64>,
168    #[serde(default)]
169    pub one_hour_price_change: Option<f64>,
170    #[serde(default)]
171    pub one_week_price_change: Option<f64>,
172
173    // Volume
174    #[serde(default)]
175    pub volume: Option<String>,
176    #[serde(default)]
177    pub volume_num: Option<f64>,
178    #[serde(default, rename = "volume24hr")]
179    pub volume_24hr: Option<f64>,
180    #[serde(default, rename = "volume1wk")]
181    pub volume_1wk: Option<f64>,
182    #[serde(default, rename = "volume1mo")]
183    pub volume_1mo: Option<f64>,
184
185    // Liquidity
186    #[serde(default)]
187    pub liquidity: Option<String>,
188    #[serde(default)]
189    pub liquidity_num: Option<f64>,
190
191    // Status flags
192    #[serde(default)]
193    pub active: Option<bool>,
194    #[serde(default)]
195    pub closed: Option<bool>,
196    #[serde(default)]
197    pub archived: Option<bool>,
198    #[serde(default)]
199    pub accepting_orders: Option<bool>,
200    #[serde(default)]
201    pub enable_order_book: Option<bool>,
202    #[serde(default)]
203    pub restricted: Option<bool>,
204
205    // Timestamps
206    #[serde(default)]
207    pub start_date: Option<String>,
208    #[serde(default)]
209    pub end_date: Option<String>,
210    #[serde(default)]
211    pub created_at: Option<String>,
212    #[serde(default)]
213    pub updated_at: Option<String>,
214
215    // Metadata
216    #[serde(default)]
217    pub category: Option<String>,
218    #[serde(default)]
219    pub description: Option<String>,
220    #[serde(default)]
221    pub resolution_source: Option<String>,
222    #[serde(default)]
223    pub image: Option<String>,
224    #[serde(default)]
225    pub icon: Option<String>,
226    #[serde(default)]
227    pub market_type: Option<String>,
228
229    // Trading configuration
230    #[serde(default)]
231    pub order_price_min_tick_size: Option<f64>,
232    #[serde(default)]
233    pub order_min_size: Option<f64>,
234    #[serde(default)]
235    pub maker_base_fee: Option<i32>,
236    #[serde(default)]
237    pub taker_base_fee: Option<i32>,
238
239    // Tags
240    #[serde(default)]
241    pub tags: Option<Vec<PolyTag>>,
242}
243
244impl PolyMarket {
245    /// Get YES price as f64 (first outcome price)
246    pub fn yes_price(&self) -> Option<f64> {
247        self.outcome_prices
248            .as_ref()
249            .and_then(|p| p.first())
250            .and_then(|s| s.parse::<f64>().ok())
251    }
252
253    /// Get NO price as f64 (second outcome price)
254    pub fn no_price(&self) -> Option<f64> {
255        self.outcome_prices
256            .as_ref()
257            .and_then(|p| p.get(1))
258            .and_then(|s| s.parse::<f64>().ok())
259    }
260
261    /// Get YES token ID (first CLOB token)
262    pub fn yes_token_id(&self) -> Option<&str> {
263        self.clob_token_ids
264            .as_ref()
265            .and_then(|ids| ids.first())
266            .map(|s| s.as_str())
267    }
268
269    /// Get NO token ID (second CLOB token)
270    pub fn no_token_id(&self) -> Option<&str> {
271        self.clob_token_ids
272            .as_ref()
273            .and_then(|ids| ids.get(1))
274            .map(|s| s.as_str())
275    }
276
277    /// Check if market is tradeable (active, not closed, order book enabled)
278    pub fn is_tradeable(&self) -> bool {
279        self.active.unwrap_or(false)
280            && !self.closed.unwrap_or(true)
281            && self.enable_order_book.unwrap_or(false)
282    }
283}
284
285/// Tag for market categorization
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct PolyTag {
288    pub id: Option<String>,
289    pub label: Option<String>,
290    pub slug: Option<String>,
291}
292
293/// A prediction event container from the Gamma API.
294///
295/// Events group related markets. Source: GET `https://gamma-api.polymarket.com/events`
296#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct PolyEvent {
299    pub id: String,
300    #[serde(default)]
301    pub ticker: Option<String>,
302    #[serde(default)]
303    pub slug: Option<String>,
304    #[serde(default)]
305    pub title: Option<String>,
306    #[serde(default)]
307    pub subtitle: Option<String>,
308    #[serde(default)]
309    pub description: Option<String>,
310    #[serde(default)]
311    pub active: Option<bool>,
312    #[serde(default)]
313    pub closed: Option<bool>,
314    #[serde(default)]
315    pub archived: Option<bool>,
316    #[serde(default)]
317    pub start_date: Option<String>,
318    #[serde(default)]
319    pub end_date: Option<String>,
320    #[serde(default)]
321    pub liquidity: Option<f64>,
322    #[serde(default)]
323    pub volume: Option<f64>,
324    #[serde(default, rename = "volume24hr")]
325    pub volume_24hr: Option<f64>,
326    #[serde(default)]
327    pub category: Option<String>,
328    #[serde(default)]
329    pub image: Option<String>,
330    #[serde(default)]
331    pub icon: Option<String>,
332    #[serde(default)]
333    pub markets: Option<Vec<PolyMarket>>,
334}
335
336// ═══════════════════════════════════════════════════════════════════════════
337// CLOB API TYPES
338// ═══════════════════════════════════════════════════════════════════════════
339
340/// CLOB market from the paginated /markets endpoint
341///
342/// Source: GET `https://clob.polymarket.com/markets`
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ClobMarket {
345    /// Blockchain condition ID — primary market identifier
346    pub condition_id: String,
347    /// Human-readable question
348    #[serde(default)]
349    pub question: Option<String>,
350    /// URL slug
351    #[serde(default)]
352    pub market_slug: Option<String>,
353    /// Active for trading
354    #[serde(default)]
355    pub active: Option<bool>,
356    /// Market closed
357    #[serde(default)]
358    pub closed: Option<bool>,
359    /// ISO 8601 end date
360    #[serde(rename = "end_date_iso", default)]
361    pub end_date: Option<String>,
362    /// Tokens (outcomes)
363    #[serde(default)]
364    pub tokens: Vec<PolyToken>,
365    /// Minimum order size (API returns number or string)
366    #[serde(default, deserialize_with = "deserialize_number_or_string")]
367    pub minimum_order_size: Option<String>,
368    /// Minimum tick size (API returns number or string)
369    #[serde(default, deserialize_with = "deserialize_number_or_string")]
370    pub minimum_tick_size: Option<String>,
371    /// Description
372    #[serde(default)]
373    pub description: Option<String>,
374    /// Maker fee in bps
375    #[serde(default)]
376    pub maker_base_fee: Option<i32>,
377    /// Taker fee in bps
378    #[serde(default)]
379    pub taker_base_fee: Option<i32>,
380    /// Negative risk market
381    #[serde(default)]
382    pub neg_risk: Option<bool>,
383}
384
385/// A single outcome token within a CLOB market
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct PolyToken {
388    /// Token ID used for CLOB price/book/history API calls
389    pub token_id: String,
390    /// Outcome label ("Yes" or "No")
391    pub outcome: String,
392    /// Current price (0.0 - 1.0)
393    #[serde(default)]
394    pub price: Option<f64>,
395    /// Whether this outcome won
396    #[serde(default)]
397    pub winner: Option<bool>,
398}
399
400/// Order book from CLOB API.
401///
402/// Source: GET `https://clob.polymarket.com/book?token_id=...`
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct PolyOrderBook {
405    /// Market condition ID
406    pub market: String,
407    /// Token ID (YES or NO)
408    pub asset_id: String,
409    /// ISO 8601 timestamp
410    #[serde(default)]
411    pub timestamp: Option<String>,
412    /// Bid levels (price desc)
413    #[serde(default)]
414    pub bids: Vec<PolyPriceLevel>,
415    /// Ask levels (price asc)
416    #[serde(default)]
417    pub asks: Vec<PolyPriceLevel>,
418    /// Min order size
419    #[serde(default)]
420    pub min_order_size: Option<String>,
421    /// Tick size
422    #[serde(default)]
423    pub tick_size: Option<String>,
424}
425
426/// Single price level in order book
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct PolyPriceLevel {
429    /// Price (0.0 - 1.0) as string
430    pub price: String,
431    /// Total size at this level
432    pub size: String,
433}
434
435impl PolyPriceLevel {
436    /// Parse price as f64 (normalizes ".48" to "0.48")
437    pub fn price_f64(&self) -> Option<f64> {
438        let s = if self.price.starts_with('.') {
439            format!("0{}", self.price)
440        } else {
441            self.price.clone()
442        };
443        s.parse::<f64>().ok()
444    }
445
446    /// Parse size as f64
447    pub fn size_f64(&self) -> Option<f64> {
448        self.size.parse::<f64>().ok()
449    }
450}
451
452/// Price history data point from CLOB API.
453///
454/// Source: GET `https://clob.polymarket.com/prices-history?market=...`
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct PriceHistoryPoint {
457    /// Unix timestamp in seconds
458    #[serde(rename = "t")]
459    pub timestamp: i64,
460    /// Price at this point (0.0 - 1.0)
461    #[serde(rename = "p")]
462    pub price: f64,
463}
464
465/// Midpoint price response
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct PolyMidpoint {
468    #[serde(deserialize_with = "deserialize_string_to_f64")]
469    pub mid: f64,
470}
471
472/// Order from authenticated CLOB API
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct PolyOrder {
475    pub id: String,
476    pub status: String,
477    pub market: String,
478    pub asset_id: String,
479    pub side: String,
480    pub original_size: String,
481    pub size_matched: String,
482    pub price: String,
483    pub outcome: String,
484    pub owner: String,
485    #[serde(default)]
486    pub maker_address: Option<String>,
487    #[serde(default)]
488    pub created_at: Option<String>,
489    #[serde(default)]
490    pub expiration: Option<String>,
491    #[serde(default)]
492    pub order_type: Option<String>,
493}
494
495/// Trade from CLOB API
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct PolyTrade {
498    pub id: String,
499    pub market: String,
500    pub asset_id: String,
501    pub side: String,
502    pub size: String,
503    pub price: String,
504    #[serde(default)]
505    pub status: Option<String>,
506    #[serde(default)]
507    pub outcome: Option<String>,
508    #[serde(default)]
509    pub match_time: Option<String>,
510    #[serde(default)]
511    pub transaction_hash: Option<String>,
512}
513
514// ═══════════════════════════════════════════════════════════════════════════
515// WEBSOCKET TYPES
516// ═══════════════════════════════════════════════════════════════════════════
517
518/// WebSocket subscription message
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct WsSubscription {
521    #[serde(rename = "type")]
522    pub msg_type: String,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub assets_ids: Option<Vec<String>>,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub operation: Option<String>,
527}
528
529/// Full order book snapshot from WebSocket
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct WsBookSnapshot {
532    pub event_type: String,
533    #[serde(default)]
534    pub asset_id: Option<String>,
535    pub market: String,
536    pub bids: Vec<PolyPriceLevel>,
537    pub asks: Vec<PolyPriceLevel>,
538    #[serde(default)]
539    pub timestamp: Option<String>,
540    #[serde(default)]
541    pub hash: Option<String>,
542}
543
544/// Incremental price update from WebSocket
545///
546/// Polymarket sends price_change as a single level update with `price`/`size`
547/// fields at the top level, not in a `changes` array.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct WsPriceChange {
550    pub event_type: String,
551    #[serde(default)]
552    pub asset_id: Option<String>,
553    #[serde(default)]
554    pub market: Option<String>,
555    /// Batch of changes (may be absent — single-level updates use price/size fields)
556    #[serde(default)]
557    pub changes: Vec<PolyPriceLevel>,
558    /// Price of the changed level (single-level format)
559    #[serde(default)]
560    pub price: Option<String>,
561    /// Size at this price (single-level format)
562    #[serde(default)]
563    pub size: Option<String>,
564    #[serde(default)]
565    pub side: Option<String>,
566    #[serde(default)]
567    pub timestamp: Option<String>,
568}
569
570/// Last trade price event from WebSocket
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct WsLastTradePrice {
573    pub event_type: String,
574    #[serde(default)]
575    pub asset_id: Option<String>,
576    pub market: String,
577    pub price: String,
578    #[serde(default)]
579    pub size: Option<String>,
580    #[serde(default)]
581    pub side: Option<String>,
582    #[serde(default)]
583    pub timestamp: Option<String>,
584}
585
586/// Tick size change event from WebSocket
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct WsTickSizeChange {
589    pub event_type: String,
590    #[serde(default)]
591    pub asset_id: Option<String>,
592    pub market: String,
593    pub old_tick_size: String,
594    pub new_tick_size: String,
595    #[serde(default)]
596    pub side: Option<String>,
597    #[serde(default)]
598    pub timestamp: Option<String>,
599}
600
601/// Best bid/ask event from WebSocket
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct WsBestBidAsk {
604    pub event_type: String,
605    #[serde(default)]
606    pub asset_id: Option<String>,
607    pub market: String,
608    pub best_bid: String,
609    pub best_ask: String,
610    #[serde(default)]
611    pub spread: Option<String>,
612    #[serde(default)]
613    pub timestamp: Option<String>,
614}
615
616// ═══════════════════════════════════════════════════════════════════════════
617// PARSER
618// ═══════════════════════════════════════════════════════════════════════════
619
620/// Response parser for Polymarket API responses
621pub struct PolymarketParser;
622
623impl PolymarketParser {
624    // -----------------------------------------------------------------------
625    // Market parsing
626    // -----------------------------------------------------------------------
627
628    /// Parse CLOB markets list from paginated response
629    ///
630    /// Handles both `{"data": [...]}` and bare `[...]` formats.
631    pub fn parse_clob_markets(response: &Value) -> ExchangeResult<Vec<ClobMarket>> {
632        let arr = response
633            .get("data")
634            .and_then(|v| v.as_array())
635            .or_else(|| response.as_array())
636            .ok_or_else(|| ExchangeError::Parse("Expected array of markets".to_string()))?;
637
638        arr.iter()
639            .map(|v| {
640                serde_json::from_value(v.clone())
641                    .map_err(|e| ExchangeError::Parse(format!("Failed to parse ClobMarket: {}", e)))
642            })
643            .collect()
644    }
645
646    /// Parse single CLOB market
647    pub fn parse_clob_market(response: &Value) -> ExchangeResult<ClobMarket> {
648        serde_json::from_value(response.clone())
649            .map_err(|e| ExchangeError::Parse(format!("Failed to parse ClobMarket: {}", e)))
650    }
651
652    /// Parse Gamma markets list
653    pub fn parse_gamma_markets(response: &Value) -> ExchangeResult<Vec<PolyMarket>> {
654        let arr = response
655            .as_array()
656            .or_else(|| response.get("data").and_then(|v| v.as_array()))
657            .ok_or_else(|| ExchangeError::Parse("Expected array of markets".to_string()))?;
658
659        arr.iter()
660            .map(|v| {
661                serde_json::from_value(v.clone())
662                    .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyMarket: {}", e)))
663            })
664            .collect()
665    }
666
667    /// Parse single Gamma market
668    pub fn parse_gamma_market(response: &Value) -> ExchangeResult<PolyMarket> {
669        serde_json::from_value(response.clone())
670            .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyMarket: {}", e)))
671    }
672
673    /// Parse events list from Gamma API
674    pub fn parse_events(response: &Value) -> ExchangeResult<Vec<PolyEvent>> {
675        let arr = response
676            .as_array()
677            .or_else(|| response.get("data").and_then(|v| v.as_array()))
678            .ok_or_else(|| ExchangeError::Parse("Expected array of events".to_string()))?;
679
680        arr.iter()
681            .map(|v| {
682                serde_json::from_value(v.clone())
683                    .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyEvent: {}", e)))
684            })
685            .collect()
686    }
687
688    /// Parse single event from Gamma API
689    pub fn parse_event(response: &Value) -> ExchangeResult<PolyEvent> {
690        serde_json::from_value(response.clone())
691            .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyEvent: {}", e)))
692    }
693
694    // -----------------------------------------------------------------------
695    // Price / book parsing
696    // -----------------------------------------------------------------------
697
698    /// Parse order book response
699    pub fn parse_order_book(response: &Value) -> ExchangeResult<PolyOrderBook> {
700        serde_json::from_value(response.clone())
701            .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyOrderBook: {}", e)))
702    }
703
704    /// Parse midpoint price response
705    pub fn parse_midpoint(response: &Value) -> ExchangeResult<PolyMidpoint> {
706        serde_json::from_value(response.clone())
707            .map_err(|e| ExchangeError::Parse(format!("Failed to parse PolyMidpoint: {}", e)))
708    }
709
710    /// Parse last trade price from `{"price": "0.52"}` response
711    pub fn parse_price(response: &Value) -> ExchangeResult<f64> {
712        let price_str = response
713            .get("price")
714            .and_then(|v| v.as_str())
715            .ok_or_else(|| ExchangeError::Parse("Missing 'price' field".to_string()))?;
716
717        let normalized = if price_str.starts_with('.') {
718            format!("0{}", price_str)
719        } else {
720            price_str.to_string()
721        };
722
723        normalized
724            .parse::<f64>()
725            .map_err(|e| ExchangeError::Parse(format!("Invalid price '{}': {}", price_str, e)))
726    }
727
728    /// Parse price history response to raw points
729    ///
730    /// Response format: `{"history": [{"t": 1234567890, "p": 0.65}, ...]}`
731    /// or bare array: `[{"t": ..., "p": ...}, ...]`
732    pub fn parse_price_history(response: &Value) -> ExchangeResult<Vec<PriceHistoryPoint>> {
733        let arr = response
734            .get("history")
735            .and_then(|v| v.as_array())
736            .or_else(|| response.as_array())
737            .ok_or_else(|| ExchangeError::Parse("Expected price history array".to_string()))?;
738
739        arr.iter()
740            .map(|v| {
741                serde_json::from_value(v.clone()).map_err(|e| {
742                    ExchangeError::Parse(format!("Failed to parse PriceHistoryPoint: {}", e))
743                })
744            })
745            .collect()
746    }
747
748    /// Get pagination cursor from response
749    pub fn get_next_cursor(response: &Value) -> Option<String> {
750        response
751            .get("next_cursor")
752            .and_then(|v| v.as_str())
753            .filter(|s| !s.is_empty() && *s != "LTE=")
754            .map(String::from)
755    }
756
757    /// Check response for API errors
758    pub fn check_error(response: &Value) -> ExchangeResult<()> {
759        if let Some(error) = response.get("error") {
760            let msg = error
761                .as_str()
762                .unwrap_or("Unknown API error")
763                .to_string();
764            return Err(ExchangeError::Api { code: 0, message: msg });
765        }
766        Ok(())
767    }
768}
769
770// ═══════════════════════════════════════════════════════════════════════════
771// CONVERSIONS TO V5 CORE TYPES
772// ═══════════════════════════════════════════════════════════════════════════
773
774/// Convert a ClobMarket to V5 SymbolInfo
775///
776/// Uses `condition_id` as the symbol identifier.
777/// The market question becomes the base_asset for display purposes.
778pub fn clob_market_to_symbol_info(market: &ClobMarket, account_type: AccountType) -> SymbolInfo {
779    let question_short = market
780        .question
781        .as_deref()
782        .unwrap_or("Unknown")
783        .chars()
784        .take(50)
785        .collect::<String>();
786
787    SymbolInfo {
788        symbol: market.condition_id.clone(),
789        base_asset: question_short,
790        quote_asset: "USDC".to_string(),
791        status: if market.active.unwrap_or(false) && !market.closed.unwrap_or(true) {
792            "TRADING"
793        } else {
794            "BREAK"
795        }
796        .to_string(),
797        price_precision: 4,
798        quantity_precision: 2,
799        min_quantity: market
800            .minimum_order_size
801            .as_ref()
802            .and_then(|s| s.parse::<f64>().ok()),
803        max_quantity: None,
804        // CLOB markets provide minimum_tick_size — use it for both tick_size and step_size
805        tick_size: market
806            .minimum_tick_size
807            .as_ref()
808            .and_then(|s| s.parse::<f64>().ok()),
809        step_size: market
810            .minimum_tick_size
811            .as_ref()
812            .and_then(|s| s.parse::<f64>().ok()),
813        min_notional: None,
814        account_type,
815    }
816}
817
818/// Convert a PolyMarket (Gamma) to V5 SymbolInfo
819pub fn poly_market_to_symbol_info(market: &PolyMarket, account_type: AccountType) -> SymbolInfo {
820    let condition_id = market
821        .condition_id
822        .as_deref()
823        .unwrap_or(&market.id)
824        .to_string();
825
826    let question = market
827        .question
828        .as_deref()
829        .unwrap_or("Unknown")
830        .chars()
831        .take(50)
832        .collect::<String>();
833
834    SymbolInfo {
835        symbol: condition_id,
836        base_asset: question,
837        quote_asset: "USDC".to_string(),
838        status: if market.active.unwrap_or(false) && !market.closed.unwrap_or(true) {
839            "TRADING"
840        } else {
841            "BREAK"
842        }
843        .to_string(),
844        price_precision: 4,
845        quantity_precision: 2,
846        min_quantity: market.order_min_size,
847        max_quantity: None,
848        // Gamma markets provide order_price_min_tick_size — use it for both tick_size and step_size
849        tick_size: market.order_price_min_tick_size,
850        step_size: market.order_price_min_tick_size,
851        min_notional: None,
852        account_type,
853    }
854}
855
856/// Convert price history points to V5 Klines
857///
858/// Prediction probability (0.0-1.0) IS the price.
859/// Each PriceHistoryPoint becomes a flat kline: open=high=low=close=price, volume=0.
860///
861/// `interval_ms` — duration of each interval in milliseconds (for close_time calculation)
862pub fn price_history_to_klines(
863    history: Vec<PriceHistoryPoint>,
864    interval_ms: u64,
865) -> Vec<Kline> {
866    history
867        .into_iter()
868        .map(|point| {
869            let open_time = point.timestamp * 1000; // seconds → milliseconds
870            let price = point.price;
871
872            Kline {
873                open_time,
874                open: price,
875                high: price,
876                low: price,
877                close: price,
878                volume: 0.0,
879                quote_volume: None,
880                close_time: Some(open_time + interval_ms as i64 - 1),
881                trades: None,
882            }
883        })
884        .collect()
885}
886
887/// Convert PolyOrderBook to V5 OrderBook
888///
889/// Bids are sorted descending (highest price first).
890/// Asks are sorted ascending (lowest price first).
891/// The CLOB API does not guarantee order, so we sort explicitly.
892pub fn poly_orderbook_to_v5(book: &PolyOrderBook) -> OrderBook {
893    let mut bids: Vec<OrderBookLevel> = book
894        .bids
895        .iter()
896        .filter_map(|level| {
897            let p = level.price_f64()?;
898            let s = level.size_f64()?;
899            Some(OrderBookLevel::new(p, s))
900        })
901        .collect();
902    // Sort bids descending by price (best bid first)
903    bids.sort_by(|a, b| b.price.partial_cmp(&a.price).unwrap_or(std::cmp::Ordering::Equal));
904
905    let mut asks: Vec<OrderBookLevel> = book
906        .asks
907        .iter()
908        .filter_map(|level| {
909            let p = level.price_f64()?;
910            let s = level.size_f64()?;
911            Some(OrderBookLevel::new(p, s))
912        })
913        .collect();
914    // Sort asks ascending by price (best ask first)
915    asks.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal));
916
917    OrderBook {
918        bids,
919        asks,
920        timestamp: chrono::Utc::now().timestamp_millis(),
921        sequence: book.timestamp.clone(),
922        last_update_id: None,
923        first_update_id: None,
924        prev_update_id: None,
925        event_time: None,
926        transaction_time: None,
927        checksum: None,
928    }
929}
930
931/// Convert a ClobMarket to V5 Ticker using the primary token price.
932///
933/// Prefers the "Yes" outcome token; falls back to the first token for non-binary markets.
934pub fn clob_market_to_ticker(market: &ClobMarket) -> Option<Ticker> {
935    let yes_token = market
936        .tokens
937        .iter()
938        .find(|t| t.outcome == "Yes")
939        .or_else(|| market.tokens.first())?;
940    let price = yes_token.price?;
941
942    Some(Ticker {
943        last_price: price,
944        bid_price: None,
945        ask_price: None,
946        high_24h: None,
947        low_24h: None,
948        volume_24h: None,
949        quote_volume_24h: None,
950        price_change_24h: None,
951        price_change_percent_24h: None,
952        timestamp: chrono::Utc::now().timestamp_millis(),
953    })
954}
955
956/// Convert a PolyMarket (Gamma) to V5 Ticker
957pub fn poly_market_to_ticker(market: &PolyMarket) -> Ticker {
958    let last_price = market
959        .last_trade_price
960        .or_else(|| market.yes_price())
961        .unwrap_or(0.0);
962
963    Ticker {
964        last_price,
965        bid_price: market.best_bid,
966        ask_price: market.best_ask,
967        high_24h: None,
968        low_24h: None,
969        volume_24h: market.volume_24hr,
970        quote_volume_24h: market.volume_24hr,
971        price_change_24h: market.one_day_price_change,
972        price_change_percent_24h: market
973            .one_day_price_change
974            .zip(Some(last_price))
975            .map(|(change, _)| change * 100.0),
976        timestamp: chrono::Utc::now().timestamp_millis(),
977    }
978}
979
980/// Get interval duration in milliseconds for a Polymarket interval string
981pub fn interval_to_ms(interval: &str) -> u64 {
982    match interval {
983        "1m" => 60_000,
984        "1h" => 3_600_000,
985        "6h" => 21_600_000,
986        "1d" => 86_400_000,
987        "1w" => 604_800_000,
988        _ => 86_400_000,
989    }
990}