Skip to main content

nautilus_polymarket/http/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP REST model types for the Polymarket CLOB API.
17
18use rust_decimal::Decimal;
19use serde::{Deserialize, Serialize};
20use ustr::Ustr;
21
22use crate::common::{
23    enums::{
24        PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType,
25        PolymarketOutcome, PolymarketTradeStatus, SignatureType,
26    },
27    models::PolymarketMakerOrder,
28    parse::{
29        deserialize_decimal_from_str, deserialize_optional_polymarket_game_id,
30        serialize_decimal_as_str,
31    },
32};
33
34/// A signed limit order for submission to the CLOB V2 exchange.
35///
36/// References: <https://docs.polymarket.com/v2-migration>,
37/// <https://docs.polymarket.com/api-reference/trade/post-a-new-order>
38///
39/// `expiration` is part of the wire body but NOT part of the EIP-712 signed
40/// struct in V2 (the protocol enforces it server-side). `"0"` means no
41/// expiration. All other fields appear inside the signed struct.
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct PolymarketOrder {
45    pub salt: u64,
46    pub maker: String,
47    pub signer: String,
48    pub token_id: Ustr,
49    #[serde(
50        serialize_with = "serialize_decimal_as_str",
51        deserialize_with = "deserialize_decimal_from_str"
52    )]
53    pub maker_amount: Decimal,
54    #[serde(
55        serialize_with = "serialize_decimal_as_str",
56        deserialize_with = "deserialize_decimal_from_str"
57    )]
58    pub taker_amount: Decimal,
59    pub side: PolymarketOrderSide,
60    pub signature_type: SignatureType,
61    /// Unix seconds timestamp when a GTD order auto-expires. `"0"` for non-GTD.
62    /// Not included in the EIP-712 signed hash; protocol enforces this value.
63    pub expiration: String,
64    /// Order creation time in milliseconds. Replaces `nonce` from V1 for
65    /// per-address uniqueness (not an expiration).
66    pub timestamp: String,
67    /// Generic bytes32 metadata field. Zero bytes when unused.
68    pub metadata: String,
69    /// Builder code (`bytes32`). Zero bytes when unset.
70    pub builder: String,
71    pub signature: String,
72}
73
74/// An active order returned by REST GET /orders.
75///
76/// References: <https://docs.polymarket.com/#get-orders>
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct PolymarketOpenOrder {
79    pub associate_trades: Option<Vec<String>>,
80    pub id: String,
81    pub status: PolymarketOrderStatus,
82    pub market: Ustr,
83    #[serde(
84        serialize_with = "serialize_decimal_as_str",
85        deserialize_with = "deserialize_decimal_from_str"
86    )]
87    pub original_size: Decimal,
88    pub outcome: PolymarketOutcome,
89    pub maker_address: String,
90    pub owner: String,
91    #[serde(
92        serialize_with = "serialize_decimal_as_str",
93        deserialize_with = "deserialize_decimal_from_str"
94    )]
95    pub price: Decimal,
96    pub side: PolymarketOrderSide,
97    #[serde(
98        serialize_with = "serialize_decimal_as_str",
99        deserialize_with = "deserialize_decimal_from_str"
100    )]
101    pub size_matched: Decimal,
102    pub asset_id: Ustr,
103    pub expiration: Option<String>,
104    pub order_type: PolymarketOrderType,
105    pub created_at: u64,
106}
107
108/// A trade report returned by REST GET /trades.
109///
110/// References: <https://docs.polymarket.com/#get-trades>
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct PolymarketTradeReport {
113    pub id: String,
114    pub taker_order_id: String,
115    pub market: Ustr,
116    pub asset_id: Ustr,
117    pub side: PolymarketOrderSide,
118    #[serde(
119        serialize_with = "serialize_decimal_as_str",
120        deserialize_with = "deserialize_decimal_from_str"
121    )]
122    pub size: Decimal,
123    #[serde(
124        serialize_with = "serialize_decimal_as_str",
125        deserialize_with = "deserialize_decimal_from_str"
126    )]
127    pub fee_rate_bps: Decimal,
128    #[serde(
129        serialize_with = "serialize_decimal_as_str",
130        deserialize_with = "deserialize_decimal_from_str"
131    )]
132    pub price: Decimal,
133    pub status: PolymarketTradeStatus,
134    pub match_time: String,
135    pub last_update: String,
136    pub outcome: PolymarketOutcome,
137    pub bucket_index: u64,
138    pub owner: String,
139    pub maker_address: String,
140    pub transaction_hash: String,
141    pub maker_orders: Vec<PolymarketMakerOrder>,
142    pub trader_side: PolymarketLiquiditySide,
143}
144
145/// A market response from the Gamma API `GET /markets`.
146///
147/// References: <https://docs.polymarket.com/developers/gamma-markets-api/get-markets>
148#[derive(Clone, Debug, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct GammaMarket {
151    /// Internal Gamma market ID.
152    pub id: String,
153    /// On-chain condition ID for the CTF contracts.
154    pub condition_id: String,
155    /// Hash used for resolution.
156    #[serde(rename = "questionID")]
157    pub question_id: Option<String>,
158    /// JSON-encoded array of two CLOB token IDs (Yes, No).
159    #[serde(default)]
160    pub clob_token_ids: String,
161    /// JSON-encoded outcome labels (e.g. `["Yes", "No"]`).
162    #[serde(default)]
163    pub outcomes: String,
164    /// Market question/title.
165    pub question: String,
166    /// Detailed description.
167    pub description: Option<String>,
168    /// Market start date (ISO 8601).
169    pub start_date: Option<String>,
170    /// Market end date (ISO 8601).
171    pub end_date: Option<String>,
172    /// Whether market is active.
173    pub active: Option<bool>,
174    /// Whether market is closed.
175    pub closed: Option<bool>,
176    /// Whether CLOB is accepting orders.
177    pub accepting_orders: Option<bool>,
178    /// Whether order book trading is enabled.
179    pub enable_order_book: Option<bool>,
180    /// Minimum price increment.
181    pub order_price_min_tick_size: Option<f64>,
182    /// Minimum order size.
183    pub order_min_size: Option<f64>,
184    /// Maker fee in basis points.
185    pub maker_base_fee: Option<i64>,
186    /// Taker fee in basis points.
187    pub taker_base_fee: Option<i64>,
188    /// URL slug.
189    #[serde(rename = "slug")]
190    pub market_slug: Option<String>,
191    /// Whether the market uses neg-risk CTF exchange.
192    #[serde(rename = "negRisk")]
193    pub neg_risk: Option<bool>,
194    /// Numeric liquidity value for sorting.
195    pub liquidity_num: Option<f64>,
196    /// Numeric volume value for sorting.
197    pub volume_num: Option<f64>,
198    /// 24-hour trading volume.
199    #[serde(rename = "volume24hr")]
200    pub volume_24hr: Option<f64>,
201    /// JSON-encoded outcome prices (e.g. `["0.60", "0.40"]`).
202    pub outcome_prices: Option<String>,
203    /// Best bid price.
204    pub best_bid: Option<f64>,
205    /// Best ask price.
206    pub best_ask: Option<f64>,
207    /// Bid-ask spread.
208    pub spread: Option<f64>,
209    /// Last trade price.
210    pub last_trade_price: Option<f64>,
211    /// 1-day price change.
212    pub one_day_price_change: Option<f64>,
213    /// 1-week price change.
214    pub one_week_price_change: Option<f64>,
215    /// 1-week volume.
216    #[serde(rename = "volume1wk")]
217    pub volume_1wk: Option<f64>,
218    /// 1-month volume.
219    #[serde(rename = "volume1mo")]
220    pub volume_1mo: Option<f64>,
221    /// 1-year volume.
222    #[serde(rename = "volume1yr")]
223    pub volume_1yr: Option<f64>,
224    /// Minimum size for rewards eligibility.
225    pub rewards_min_size: Option<f64>,
226    /// Maximum spread for rewards eligibility.
227    pub rewards_max_spread: Option<f64>,
228    /// Competitiveness score.
229    pub competitive: Option<f64>,
230    /// Market category.
231    pub category: Option<String>,
232    /// Neg-risk market ID for CTF exchange interaction.
233    #[serde(rename = "negRiskMarketID")]
234    pub neg_risk_market_id: Option<String>,
235    /// Fee schedule for this market.
236    pub fee_schedule: Option<FeeSchedule>,
237    /// Game ID for sport markets. `null` and `-1` both mean "no game" and
238    /// surface as `None`. Reference shape:
239    /// <https://github.com/Polymarket/rs-clob-client/blob/main/src/gamma/types/response.rs>.
240    #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
241    pub game_id: Option<u64>,
242    /// Events linked to this gamma market.
243    pub events: Option<Vec<GammaEvent>>,
244}
245
246#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
247#[serde(rename_all = "camelCase")]
248pub struct FeeSchedule {
249    pub exponent: f64,
250    pub rate: f64,
251    pub taker_only: bool,
252    pub rebate_rate: f64,
253}
254
255/// An event response from the Gamma API `GET /events`.
256///
257/// Events are parent containers grouping related markets (e.g., an election
258/// event contains multiple outcome markets). Each event's `markets` array
259/// contains full [`GammaMarket`] objects.
260#[derive(Clone, Debug, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct GammaEvent {
263    pub id: String,
264    pub slug: Option<String>,
265    pub title: Option<String>,
266    pub description: Option<String>,
267    pub start_date: Option<String>,
268    pub end_date: Option<String>,
269    pub active: Option<bool>,
270    pub closed: Option<bool>,
271    pub archived: Option<bool>,
272    #[serde(default)]
273    pub markets: Vec<GammaMarket>,
274    /// Event-level liquidity.
275    pub liquidity: Option<f64>,
276    /// Event-level volume.
277    pub volume: Option<f64>,
278    /// Event-level open interest.
279    pub open_interest: Option<f64>,
280    /// 24-hour event volume.
281    #[serde(rename = "volume24hr")]
282    pub volume_24hr: Option<f64>,
283    /// Event category.
284    pub category: Option<String>,
285    /// Whether event uses neg-risk.
286    pub neg_risk: Option<bool>,
287    /// Neg-risk market ID.
288    #[serde(rename = "negRiskMarketID")]
289    pub neg_risk_market_id: Option<String>,
290    /// Whether event is featured.
291    pub featured: Option<bool>,
292    /// Game ID for sport markets. `null` and `-1` both mean "no game" and
293    /// surface as `None`. Reference shape:
294    /// <https://github.com/Polymarket/rs-clob-client/blob/main/src/gamma/types/response.rs>.
295    #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
296    pub game_id: Option<u64>,
297}
298
299/// A tag from the Gamma API `GET /tags`.
300#[derive(Clone, Debug, Deserialize)]
301pub struct GammaTag {
302    /// Tag identifier.
303    pub id: String,
304    /// Human-readable label.
305    pub label: Option<String>,
306    /// URL slug.
307    pub slug: Option<String>,
308}
309
310/// Response from the Gamma API `GET /public-search`.
311#[derive(Clone, Debug, Deserialize)]
312pub struct SearchResponse {
313    /// Matching markets.
314    #[serde(default)]
315    pub markets: Option<Vec<GammaMarket>>,
316    /// Matching events.
317    #[serde(default)]
318    pub events: Option<Vec<GammaEvent>>,
319}
320
321/// Tick size response from CLOB `GET /tick-size`.
322///
323/// References: <https://docs.polymarket.com/api-reference/market-data/get-tick-size>
324#[derive(Clone, Debug, Deserialize)]
325pub struct TickSizeResponse {
326    /// Minimum tick size (price increment) for a token.
327    pub minimum_tick_size: f64,
328}
329
330/// Fee rate response from CLOB `GET /fee-rate`.
331///
332/// Returns the taker fee rate in basis points for a given token.
333#[derive(Clone, Debug, Deserialize)]
334pub struct FeeRateResponse {
335    /// Fee rate in basis points.
336    pub base_fee: Decimal,
337}
338
339/// A single price level from the CLOB order book.
340#[derive(Clone, Debug, Deserialize)]
341pub struct ClobBookLevel {
342    pub price: String,
343    pub size: String,
344}
345
346/// Response from the CLOB `GET /book` endpoint.
347///
348/// Extra fields (`market`, `asset_id`, `hash`, `timestamp`) are silently ignored.
349#[derive(Clone, Debug, Deserialize)]
350pub struct ClobBookResponse {
351    pub bids: Vec<ClobBookLevel>,
352    pub asks: Vec<ClobBookLevel>,
353}
354
355/// A single outcome token in a CLOB market response.
356#[derive(Clone, Debug, Deserialize)]
357pub struct ClobMarketToken {
358    pub token_id: String,
359    pub outcome: String,
360    pub winner: bool,
361}
362
363/// Response from CLOB `GET /markets/{condition_id}`.
364#[derive(Clone, Debug, Deserialize)]
365pub struct ClobMarketResponse {
366    pub condition_id: String,
367    pub closed: bool,
368    pub tokens: Vec<ClobMarketToken>,
369}
370
371/// A position from the Polymarket Data API `GET /positions` endpoint.
372#[derive(Clone, Debug, Deserialize)]
373pub struct DataApiPosition {
374    pub asset: String,
375    #[serde(alias = "conditionId", alias = "condition_id")]
376    pub condition_id: String,
377    pub size: f64,
378    #[serde(alias = "avgPrice", alias = "avg_price")]
379    pub avg_price: Option<f64>,
380}
381
382/// A trade from the Polymarket Data API `GET /trades` endpoint.
383#[derive(Clone, Debug, Deserialize)]
384#[serde(rename_all = "camelCase")]
385pub struct DataApiTrade {
386    pub asset: String,
387    pub condition_id: String,
388    pub side: PolymarketOrderSide,
389    pub price: f64,
390    pub size: f64,
391    pub timestamp: i64,
392    pub transaction_hash: String,
393}
394
395#[cfg(test)]
396mod tests {
397    use rstest::rstest;
398    use rust_decimal_macros::dec;
399
400    use super::*;
401    use crate::common::enums::{PolymarketOrderStatus, PolymarketTradeStatus, SignatureType};
402
403    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
404        let path = format!("test_data/{filename}");
405        let content = std::fs::read_to_string(path).expect("Failed to read test data");
406        serde_json::from_str(&content).expect("Failed to parse test data")
407    }
408
409    #[rstest]
410    fn test_open_order_live_buy_gtc() {
411        let order: PolymarketOpenOrder = load("http_open_order.json");
412
413        assert_eq!(
414            order.id,
415            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
416        );
417        assert_eq!(order.status, PolymarketOrderStatus::Live);
418        assert_eq!(order.side, PolymarketOrderSide::Buy);
419        assert_eq!(order.order_type, PolymarketOrderType::GTC);
420        assert_eq!(order.outcome, PolymarketOutcome::yes());
421        assert_eq!(order.original_size, dec!(100.0000));
422        assert_eq!(order.price, dec!(0.5000));
423        assert_eq!(order.size_matched, dec!(25.0000));
424        assert_eq!(order.created_at, 1703875200);
425        assert!(order.expiration.is_none());
426        assert_eq!(order.associate_trades, Some(vec!["0xabc001".to_string()]));
427    }
428
429    #[rstest]
430    fn test_open_order_matched_sell_fok() {
431        let order: PolymarketOpenOrder = load("http_open_order_sell_fok.json");
432
433        assert_eq!(order.status, PolymarketOrderStatus::Matched);
434        assert_eq!(order.side, PolymarketOrderSide::Sell);
435        assert_eq!(order.order_type, PolymarketOrderType::FOK);
436        assert_eq!(order.outcome, PolymarketOutcome::no());
437        assert_eq!(order.size_matched, dec!(50.0000));
438        assert_eq!(order.expiration, Some("1735689600".to_string()));
439        assert!(order.associate_trades.is_none());
440    }
441
442    #[rstest]
443    fn test_open_order_roundtrip() {
444        let order: PolymarketOpenOrder = load("http_open_order.json");
445        let json = serde_json::to_string(&order).unwrap();
446        let order2: PolymarketOpenOrder = serde_json::from_str(&json).unwrap();
447        assert_eq!(order, order2);
448    }
449
450    #[rstest]
451    fn test_trade_report_fields() {
452        let trade: PolymarketTradeReport = load("http_trade_report.json");
453
454        assert_eq!(trade.id, "trade-0xabcdef1234");
455        assert_eq!(
456            trade.taker_order_id,
457            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
458        );
459        assert_eq!(trade.side, PolymarketOrderSide::Buy);
460        assert_eq!(trade.size, dec!(25.0000));
461        assert_eq!(trade.fee_rate_bps, dec!(0));
462        assert_eq!(trade.price, dec!(0.5000));
463        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
464        assert_eq!(trade.outcome, PolymarketOutcome::yes());
465        assert_eq!(trade.bucket_index, 0);
466        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
467        assert_eq!(trade.maker_orders.len(), 2);
468    }
469
470    #[rstest]
471    fn test_trade_report_maker_orders() {
472        let trade: PolymarketTradeReport = load("http_trade_report.json");
473
474        let first = &trade.maker_orders[0];
475        assert_eq!(first.matched_amount, dec!(25.0000));
476        assert_eq!(first.price, dec!(0.5000));
477        assert_eq!(first.outcome, PolymarketOutcome::yes());
478
479        let second = &trade.maker_orders[1];
480        assert_eq!(second.matched_amount, dec!(5.0000));
481    }
482
483    #[rstest]
484    fn test_trade_report_roundtrip() {
485        let trade: PolymarketTradeReport = load("http_trade_report.json");
486        let json = serde_json::to_string(&trade).unwrap();
487        let trade2: PolymarketTradeReport = serde_json::from_str(&json).unwrap();
488        assert_eq!(trade, trade2);
489    }
490
491    #[rstest]
492    fn test_signed_order_camel_case_fields() {
493        let order: PolymarketOrder = load("http_signed_order.json");
494
495        assert_eq!(order.salt, 123456789);
496        assert_eq!(order.maker, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
497        assert_eq!(order.maker_amount, dec!(100000000));
498        assert_eq!(order.taker_amount, dec!(50000000));
499        assert_eq!(order.expiration, "0");
500        assert_eq!(order.timestamp, "1713398400000");
501        assert_eq!(
502            order.metadata,
503            "0x0000000000000000000000000000000000000000000000000000000000000000"
504        );
505        assert_eq!(
506            order.builder,
507            "0x0000000000000000000000000000000000000000000000000000000000000000"
508        );
509        assert_eq!(order.side, PolymarketOrderSide::Buy);
510        assert_eq!(order.signature_type, SignatureType::Eoa);
511    }
512
513    #[rstest]
514    fn test_signed_order_roundtrip() {
515        let order: PolymarketOrder = load("http_signed_order.json");
516        let json = serde_json::to_string(&order).unwrap();
517        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
518        assert_eq!(order, order2);
519    }
520
521    #[rstest]
522    fn test_signed_order_serializes_camel_case() {
523        let order: PolymarketOrder = load("http_signed_order.json");
524        let json = serde_json::to_string(&order).unwrap();
525
526        // Verify camelCase field names are present in serialized output
527        assert!(json.contains("\"tokenId\""));
528        assert!(json.contains("\"makerAmount\""));
529        assert!(json.contains("\"takerAmount\""));
530        assert!(json.contains("\"signatureType\""));
531        assert!(json.contains("\"expiration\""));
532        assert!(json.contains("\"timestamp\""));
533        assert!(json.contains("\"metadata\""));
534        assert!(json.contains("\"builder\""));
535    }
536
537    #[rstest]
538    fn test_signed_order_omits_v1_fields() {
539        // V2 dropped `taker`, `nonce`, and `feeRateBps` from the order body.
540        // A regression that re-introduces any of them would silently land V1
541        // shape on a V2 endpoint, so we explicitly assert their absence.
542        let order: PolymarketOrder = load("http_signed_order.json");
543        let json = serde_json::to_string(&order).unwrap();
544
545        assert!(
546            !json.contains("\"taker\""),
547            "wire body must not include `taker`: {json}"
548        );
549        assert!(
550            !json.contains("\"nonce\""),
551            "wire body must not include `nonce`: {json}"
552        );
553        assert!(
554            !json.contains("\"feeRateBps\""),
555            "wire body must not include `feeRateBps`: {json}"
556        );
557    }
558
559    #[rstest]
560    fn test_signed_order_v2_docs_example_roundtrips() {
561        // POST /order body shape from <https://docs.polymarket.com/v2-migration>.
562        // Round-tripping it ensures we accept the exact shape the docs publish.
563        let docs_example = r#"{
564            "salt": 12345,
565            "maker": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
566            "signer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
567            "tokenId": "102936",
568            "makerAmount": "1000000",
569            "takerAmount": "2000000",
570            "side": "BUY",
571            "signatureType": 1,
572            "expiration": "0",
573            "timestamp": "1713398400000",
574            "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
575            "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
576            "signature": "0xdeadbeef"
577        }"#;
578
579        let order: PolymarketOrder = serde_json::from_str(docs_example).unwrap();
580        assert_eq!(order.salt, 12345);
581        assert_eq!(order.token_id.as_str(), "102936");
582        assert_eq!(order.maker_amount, dec!(1000000));
583        assert_eq!(order.taker_amount, dec!(2000000));
584        assert_eq!(order.side, PolymarketOrderSide::Buy);
585        assert_eq!(order.signature_type, SignatureType::PolyProxy);
586        assert_eq!(order.expiration, "0");
587        assert_eq!(order.timestamp, "1713398400000");
588
589        // Round-trip preserves field semantics.
590        let json = serde_json::to_string(&order).unwrap();
591        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
592        assert_eq!(order, order2);
593    }
594
595    #[rstest]
596    fn test_gamma_event_deserialization() {
597        let events: Vec<GammaEvent> = load("gamma_event.json");
598
599        assert_eq!(events.len(), 1);
600        let event = &events[0];
601        assert_eq!(event.id, "30829");
602        assert_eq!(
603            event.slug.as_deref(),
604            Some("democratic-presidential-nominee-2028")
605        );
606        assert_eq!(
607            event.title.as_deref(),
608            Some("Democratic Presidential Nominee 2028")
609        );
610        assert_eq!(event.active, Some(true));
611        assert_eq!(event.closed, Some(false));
612        assert_eq!(event.archived, Some(false));
613        assert_eq!(event.markets.len(), 2);
614        assert_eq!(
615            event.markets[0].condition_id,
616            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
617        );
618        assert_eq!(
619            event.markets[1].condition_id,
620            "0xe39adea057926dc197fe30a441f57a340b2a232d5a687010f78bba9b6e02620f"
621        );
622    }
623
624    #[rstest]
625    fn test_gamma_event_empty_markets() {
626        let json = r#"[{"id": "evt-002"}]"#;
627        let events: Vec<GammaEvent> = serde_json::from_str(json).unwrap();
628
629        assert_eq!(events.len(), 1);
630        assert_eq!(events[0].id, "evt-002");
631        assert!(events[0].markets.is_empty());
632        assert!(events[0].slug.is_none());
633    }
634
635    #[rstest]
636    fn test_sports_market_are_weird() {
637        let money_line: GammaMarket = load("gamma_market_sports_market_money_line.json");
638        let map_handicap: GammaMarket = load("gamma_market_sports_market_map_handicap.json");
639
640        // same event, same slug
641        assert_eq!(
642            money_line.events.as_ref().unwrap()[0].game_id,
643            map_handicap.events.as_ref().unwrap()[0].game_id
644        );
645
646        // one market has no game_id
647        assert!(map_handicap.game_id.is_none());
648        assert_eq!(money_line.game_id, Some(1_427_074));
649    }
650
651    #[rstest]
652    fn test_gamma_market_enriched_fields() {
653        let market: GammaMarket = load("gamma_market.json");
654
655        assert_eq!(market.best_bid, Some(0.5));
656        assert_eq!(market.best_ask, Some(0.51));
657        assert_eq!(market.spread, Some(0.009));
658        assert_eq!(market.last_trade_price, Some(0.51));
659        assert!(market.one_day_price_change.is_none());
660        assert!(market.one_week_price_change.is_none());
661        assert_eq!(market.volume_1wk, Some(9.999997));
662        assert_eq!(market.volume_1mo, Some(9.999997));
663        assert_eq!(market.volume_1yr, Some(9.999997));
664        assert_eq!(market.rewards_min_size, Some(50.0));
665        assert_eq!(market.rewards_max_spread, Some(4.5));
666        assert_eq!(market.competitive, Some(0.9999750006249843));
667        assert!(market.category.is_none());
668        assert!(market.neg_risk_market_id.is_none());
669        assert_eq!(
670            market.outcome_prices.as_deref(),
671            Some("[\"0.505\", \"0.495\"]")
672        );
673    }
674
675    #[rstest]
676    fn test_gamma_market_enriched_fields_default_to_none() {
677        // Minimal market JSON: only required fields
678        let json = r#"{"id": "m1", "conditionId": "0xcond", "clobTokenIds": "[]", "outcomes": "[]", "question": "Q?"}"#;
679        let market: GammaMarket = serde_json::from_str(json).unwrap();
680
681        assert!(market.best_bid.is_none());
682        assert!(market.spread.is_none());
683        assert!(market.volume_1wk.is_none());
684        assert!(market.rewards_min_size.is_none());
685        assert!(market.competitive.is_none());
686        assert!(market.category.is_none());
687        assert!(market.neg_risk_market_id.is_none());
688    }
689
690    #[rstest]
691    fn test_gamma_event_enriched_fields() {
692        let events: Vec<GammaEvent> = load("gamma_event.json");
693        let event = &events[0];
694
695        assert_eq!(event.liquidity, Some(43042905.16152));
696        assert_eq!(event.volume, Some(799823812.487094));
697        assert_eq!(event.open_interest, Some(0.0));
698        assert_eq!(event.volume_24hr, Some(5669354.219446001));
699        assert!(event.category.is_none());
700        assert_eq!(event.neg_risk, Some(true));
701        assert_eq!(
702            event.neg_risk_market_id.as_deref(),
703            Some("0x2c3d7e0eee6f058be3006baabf0d54a07da254ba47fe6e3e095e7990c7814700")
704        );
705        assert_eq!(event.featured, Some(false));
706    }
707
708    #[rstest]
709    fn test_gamma_tag_deserialization() {
710        let tags: Vec<GammaTag> = load("gamma_tags.json");
711
712        assert_eq!(tags.len(), 5);
713        assert_eq!(tags[0].id, "101259");
714        assert_eq!(tags[0].label.as_deref(), Some("Health and Human Services"));
715        assert_eq!(tags[0].slug.as_deref(), Some("health-and-human-services"));
716        assert_eq!(tags[2].slug.as_deref(), Some("attorney-general"));
717    }
718
719    #[rstest]
720    fn test_search_response_deserialization() {
721        let response: SearchResponse = load("search_response.json");
722
723        // Real API returns no top-level "markets" key
724        assert!(response.markets.is_none());
725
726        let events = response.events.as_ref().unwrap();
727        assert_eq!(events.len(), 1);
728        assert_eq!(events[0].slug.as_deref(), Some("bitcoin-above-on-march-11"));
729        assert_eq!(events[0].markets.len(), 1);
730    }
731
732    #[rstest]
733    fn test_search_response_empty_fields() {
734        let json = "{}";
735        let response: SearchResponse = serde_json::from_str(json).unwrap();
736        assert!(response.markets.is_none());
737        assert!(response.events.is_none());
738    }
739
740    #[rstest]
741    fn test_clob_book_response_deserialization() {
742        let response: ClobBookResponse = load("clob_book_response.json");
743
744        assert_eq!(response.bids.len(), 3);
745        assert_eq!(response.asks.len(), 3);
746
747        assert_eq!(response.bids[0].price, "0.48");
748        assert_eq!(response.bids[0].size, "100.00");
749        assert_eq!(response.bids[2].price, "0.50");
750        assert_eq!(response.bids[2].size, "150.00");
751
752        assert_eq!(response.asks[0].price, "0.51");
753        assert_eq!(response.asks[0].size, "120.00");
754        assert_eq!(response.asks[2].price, "0.53");
755        assert_eq!(response.asks[2].size, "90.00");
756    }
757
758    #[rstest]
759    fn test_clob_book_response_ignores_extra_fields() {
760        // Verify serde silently ignores fields from both V1 and V2 `/book`
761        // responses. The live V2 endpoint adds `tick_size`, `min_order_size`,
762        // `neg_risk`, and `last_trade_price` on top of the V1 fields; pinning
763        // them here catches a future `#[serde(deny_unknown_fields)]` regression
764        // before it breaks production parsing.
765        let json = r#"{
766            "market": "0xabc",
767            "asset_id": "123",
768            "hash": "0x1",
769            "timestamp": "123",
770            "bids": [],
771            "asks": [],
772            "tick_size": "0.01",
773            "min_order_size": "5",
774            "neg_risk": false,
775            "last_trade_price": "0.55"
776        }"#;
777        let response: ClobBookResponse = serde_json::from_str(json).unwrap();
778        assert!(response.bids.is_empty());
779        assert!(response.asks.is_empty());
780    }
781
782    #[rstest]
783    fn test_clob_market_response_deserialization_accepting_false() {
784        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_false.json");
785        assert_eq!(
786            response.condition_id,
787            "0x8ccc3f4951ff02c1d34b87988752b4444ad17228732780a6cf22afefe8478bb6"
788        );
789        assert!(response.closed);
790        assert_eq!(response.tokens.len(), 2);
791        assert_eq!(response.tokens[0].outcome, "Yes");
792        assert!(!response.tokens[0].winner);
793        assert_eq!(response.tokens[1].outcome, "No");
794        assert!(response.tokens[1].winner);
795    }
796
797    #[rstest]
798    fn test_clob_market_response_deserialization_accepting_true() {
799        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_true.json");
800        assert_eq!(
801            response.condition_id,
802            "0xd57eed0d44f5b8ca54925d8d6ff440b146b3e6e071da18136ee3ee572d34479e"
803        );
804        assert!(response.closed);
805        assert_eq!(response.tokens.len(), 2);
806        assert_eq!(response.tokens[0].outcome, "Yes");
807        assert!(response.tokens[0].winner);
808        assert_eq!(response.tokens[1].outcome, "No");
809        assert!(!response.tokens[1].winner);
810    }
811
812    #[rstest]
813    fn test_fee_rate_response_zero() {
814        let response: FeeRateResponse = load("clob_fee_rate_response_zero.json");
815        assert_eq!(response.base_fee, dec!(0));
816    }
817
818    #[rstest]
819    fn test_fee_rate_response_nonzero() {
820        let response: FeeRateResponse = load("clob_fee_rate_response_nonzero.json");
821        assert_eq!(response.base_fee, dec!(150));
822    }
823
824    #[rstest]
825    fn test_data_api_position_deserialization() {
826        let positions: Vec<DataApiPosition> = load("data_api_positions_response.json");
827
828        assert_eq!(positions.len(), 4);
829        assert_eq!(
830            positions[0].asset,
831            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
832        );
833        assert_eq!(
834            positions[0].condition_id,
835            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
836        );
837        assert_eq!(positions[0].size, 150.5);
838        assert_eq!(positions[0].avg_price, Some(0.55));
839
840        // Zero-size position
841        assert_eq!(positions[1].size, 0.0);
842        assert_eq!(positions[1].avg_price, Some(0.45));
843
844        // Third position
845        assert_eq!(
846            positions[2].condition_id,
847            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
848        );
849        assert_eq!(positions[2].size, 42.0);
850        assert_eq!(positions[2].avg_price, Some(0.3));
851
852        // Dust position (below DUST_POSITION_THRESHOLD)
853        assert_eq!(positions[3].size, 0.005);
854        assert_eq!(positions[3].avg_price, Some(0.7));
855    }
856
857    #[rstest]
858    fn test_data_api_trade_deserialization() {
859        let trades: Vec<DataApiTrade> = load("data_api_trades_response.json");
860
861        assert_eq!(trades.len(), 3);
862        assert_eq!(
863            trades[0].asset,
864            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
865        );
866        assert_eq!(
867            trades[0].condition_id,
868            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
869        );
870        assert_eq!(trades[0].side, PolymarketOrderSide::Buy);
871        assert_eq!(trades[0].price, 0.55);
872        assert_eq!(trades[0].size, 100.0);
873        assert_eq!(trades[0].timestamp, 1710000000);
874        assert_eq!(
875            trades[0].transaction_hash,
876            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
877        );
878
879        assert_eq!(trades[1].side, PolymarketOrderSide::Sell);
880        assert_eq!(trades[1].price, 0.53);
881
882        // Third trade has different asset (other outcome token)
883        assert_eq!(
884            trades[2].asset,
885            "99999999999999999999999999999999999999999999999999999999999999999999999999999"
886        );
887    }
888}