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 position from the Polymarket Data API `GET /positions` endpoint.
356#[derive(Clone, Debug, Deserialize)]
357pub struct DataApiPosition {
358    pub asset: String,
359    #[serde(alias = "conditionId", alias = "condition_id")]
360    pub condition_id: String,
361    pub size: f64,
362    #[serde(alias = "avgPrice", alias = "avg_price")]
363    pub avg_price: Option<f64>,
364}
365
366/// A trade from the Polymarket Data API `GET /trades` endpoint.
367#[derive(Clone, Debug, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct DataApiTrade {
370    pub asset: String,
371    pub condition_id: String,
372    pub side: PolymarketOrderSide,
373    pub price: f64,
374    pub size: f64,
375    pub timestamp: i64,
376    pub transaction_hash: String,
377}
378
379#[cfg(test)]
380mod tests {
381    use rstest::rstest;
382    use rust_decimal_macros::dec;
383
384    use super::*;
385    use crate::common::enums::{PolymarketOrderStatus, PolymarketTradeStatus, SignatureType};
386
387    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
388        let path = format!("test_data/{filename}");
389        let content = std::fs::read_to_string(path).expect("Failed to read test data");
390        serde_json::from_str(&content).expect("Failed to parse test data")
391    }
392
393    #[rstest]
394    fn test_open_order_live_buy_gtc() {
395        let order: PolymarketOpenOrder = load("http_open_order.json");
396
397        assert_eq!(
398            order.id,
399            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
400        );
401        assert_eq!(order.status, PolymarketOrderStatus::Live);
402        assert_eq!(order.side, PolymarketOrderSide::Buy);
403        assert_eq!(order.order_type, PolymarketOrderType::GTC);
404        assert_eq!(order.outcome, PolymarketOutcome::yes());
405        assert_eq!(order.original_size, dec!(100.0000));
406        assert_eq!(order.price, dec!(0.5000));
407        assert_eq!(order.size_matched, dec!(25.0000));
408        assert_eq!(order.created_at, 1703875200);
409        assert!(order.expiration.is_none());
410        assert_eq!(order.associate_trades, Some(vec!["0xabc001".to_string()]));
411    }
412
413    #[rstest]
414    fn test_open_order_matched_sell_fok() {
415        let order: PolymarketOpenOrder = load("http_open_order_sell_fok.json");
416
417        assert_eq!(order.status, PolymarketOrderStatus::Matched);
418        assert_eq!(order.side, PolymarketOrderSide::Sell);
419        assert_eq!(order.order_type, PolymarketOrderType::FOK);
420        assert_eq!(order.outcome, PolymarketOutcome::no());
421        assert_eq!(order.size_matched, dec!(50.0000));
422        assert_eq!(order.expiration, Some("1735689600".to_string()));
423        assert!(order.associate_trades.is_none());
424    }
425
426    #[rstest]
427    fn test_open_order_roundtrip() {
428        let order: PolymarketOpenOrder = load("http_open_order.json");
429        let json = serde_json::to_string(&order).unwrap();
430        let order2: PolymarketOpenOrder = serde_json::from_str(&json).unwrap();
431        assert_eq!(order, order2);
432    }
433
434    #[rstest]
435    fn test_trade_report_fields() {
436        let trade: PolymarketTradeReport = load("http_trade_report.json");
437
438        assert_eq!(trade.id, "trade-0xabcdef1234");
439        assert_eq!(
440            trade.taker_order_id,
441            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
442        );
443        assert_eq!(trade.side, PolymarketOrderSide::Buy);
444        assert_eq!(trade.size, dec!(25.0000));
445        assert_eq!(trade.fee_rate_bps, dec!(0));
446        assert_eq!(trade.price, dec!(0.5000));
447        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
448        assert_eq!(trade.outcome, PolymarketOutcome::yes());
449        assert_eq!(trade.bucket_index, 0);
450        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
451        assert_eq!(trade.maker_orders.len(), 2);
452    }
453
454    #[rstest]
455    fn test_trade_report_maker_orders() {
456        let trade: PolymarketTradeReport = load("http_trade_report.json");
457
458        let first = &trade.maker_orders[0];
459        assert_eq!(first.matched_amount, dec!(25.0000));
460        assert_eq!(first.price, dec!(0.5000));
461        assert_eq!(first.outcome, PolymarketOutcome::yes());
462
463        let second = &trade.maker_orders[1];
464        assert_eq!(second.matched_amount, dec!(5.0000));
465    }
466
467    #[rstest]
468    fn test_trade_report_roundtrip() {
469        let trade: PolymarketTradeReport = load("http_trade_report.json");
470        let json = serde_json::to_string(&trade).unwrap();
471        let trade2: PolymarketTradeReport = serde_json::from_str(&json).unwrap();
472        assert_eq!(trade, trade2);
473    }
474
475    #[rstest]
476    fn test_signed_order_camel_case_fields() {
477        let order: PolymarketOrder = load("http_signed_order.json");
478
479        assert_eq!(order.salt, 123456789);
480        assert_eq!(order.maker, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
481        assert_eq!(order.maker_amount, dec!(100000000));
482        assert_eq!(order.taker_amount, dec!(50000000));
483        assert_eq!(order.expiration, "0");
484        assert_eq!(order.timestamp, "1713398400000");
485        assert_eq!(
486            order.metadata,
487            "0x0000000000000000000000000000000000000000000000000000000000000000"
488        );
489        assert_eq!(
490            order.builder,
491            "0x0000000000000000000000000000000000000000000000000000000000000000"
492        );
493        assert_eq!(order.side, PolymarketOrderSide::Buy);
494        assert_eq!(order.signature_type, SignatureType::Eoa);
495    }
496
497    #[rstest]
498    fn test_signed_order_roundtrip() {
499        let order: PolymarketOrder = load("http_signed_order.json");
500        let json = serde_json::to_string(&order).unwrap();
501        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
502        assert_eq!(order, order2);
503    }
504
505    #[rstest]
506    fn test_signed_order_serializes_camel_case() {
507        let order: PolymarketOrder = load("http_signed_order.json");
508        let json = serde_json::to_string(&order).unwrap();
509
510        // Verify camelCase field names are present in serialized output
511        assert!(json.contains("\"tokenId\""));
512        assert!(json.contains("\"makerAmount\""));
513        assert!(json.contains("\"takerAmount\""));
514        assert!(json.contains("\"signatureType\""));
515        assert!(json.contains("\"expiration\""));
516        assert!(json.contains("\"timestamp\""));
517        assert!(json.contains("\"metadata\""));
518        assert!(json.contains("\"builder\""));
519    }
520
521    #[rstest]
522    fn test_signed_order_omits_v1_fields() {
523        // V2 dropped `taker`, `nonce`, and `feeRateBps` from the order body.
524        // A regression that re-introduces any of them would silently land V1
525        // shape on a V2 endpoint, so we explicitly assert their absence.
526        let order: PolymarketOrder = load("http_signed_order.json");
527        let json = serde_json::to_string(&order).unwrap();
528
529        assert!(
530            !json.contains("\"taker\""),
531            "wire body must not include `taker`: {json}"
532        );
533        assert!(
534            !json.contains("\"nonce\""),
535            "wire body must not include `nonce`: {json}"
536        );
537        assert!(
538            !json.contains("\"feeRateBps\""),
539            "wire body must not include `feeRateBps`: {json}"
540        );
541    }
542
543    #[rstest]
544    fn test_signed_order_v2_docs_example_roundtrips() {
545        // POST /order body shape from <https://docs.polymarket.com/v2-migration>.
546        // Round-tripping it ensures we accept the exact shape the docs publish.
547        let docs_example = r#"{
548            "salt": 12345,
549            "maker": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
550            "signer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
551            "tokenId": "102936",
552            "makerAmount": "1000000",
553            "takerAmount": "2000000",
554            "side": "BUY",
555            "signatureType": 1,
556            "expiration": "0",
557            "timestamp": "1713398400000",
558            "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
559            "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
560            "signature": "0xdeadbeef"
561        }"#;
562
563        let order: PolymarketOrder = serde_json::from_str(docs_example).unwrap();
564        assert_eq!(order.salt, 12345);
565        assert_eq!(order.token_id.as_str(), "102936");
566        assert_eq!(order.maker_amount, dec!(1000000));
567        assert_eq!(order.taker_amount, dec!(2000000));
568        assert_eq!(order.side, PolymarketOrderSide::Buy);
569        assert_eq!(order.signature_type, SignatureType::PolyProxy);
570        assert_eq!(order.expiration, "0");
571        assert_eq!(order.timestamp, "1713398400000");
572
573        // Round-trip preserves field semantics.
574        let json = serde_json::to_string(&order).unwrap();
575        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
576        assert_eq!(order, order2);
577    }
578
579    #[rstest]
580    fn test_gamma_event_deserialization() {
581        let events: Vec<GammaEvent> = load("gamma_event.json");
582
583        assert_eq!(events.len(), 1);
584        let event = &events[0];
585        assert_eq!(event.id, "30829");
586        assert_eq!(
587            event.slug.as_deref(),
588            Some("democratic-presidential-nominee-2028")
589        );
590        assert_eq!(
591            event.title.as_deref(),
592            Some("Democratic Presidential Nominee 2028")
593        );
594        assert_eq!(event.active, Some(true));
595        assert_eq!(event.closed, Some(false));
596        assert_eq!(event.archived, Some(false));
597        assert_eq!(event.markets.len(), 2);
598        assert_eq!(
599            event.markets[0].condition_id,
600            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
601        );
602        assert_eq!(
603            event.markets[1].condition_id,
604            "0xe39adea057926dc197fe30a441f57a340b2a232d5a687010f78bba9b6e02620f"
605        );
606    }
607
608    #[rstest]
609    fn test_gamma_event_empty_markets() {
610        let json = r#"[{"id": "evt-002"}]"#;
611        let events: Vec<GammaEvent> = serde_json::from_str(json).unwrap();
612
613        assert_eq!(events.len(), 1);
614        assert_eq!(events[0].id, "evt-002");
615        assert!(events[0].markets.is_empty());
616        assert!(events[0].slug.is_none());
617    }
618
619    #[rstest]
620    fn test_sports_market_are_weird() {
621        let money_line: GammaMarket = load("gamma_market_sports_market_money_line.json");
622        let map_handicap: GammaMarket = load("gamma_market_sports_market_map_handicap.json");
623
624        // same event, same slug
625        assert_eq!(
626            money_line.events.as_ref().unwrap()[0].game_id,
627            map_handicap.events.as_ref().unwrap()[0].game_id
628        );
629
630        // one market has no game_id
631        assert!(map_handicap.game_id.is_none());
632        assert_eq!(money_line.game_id, Some(1_427_074));
633    }
634
635    #[rstest]
636    fn test_gamma_market_enriched_fields() {
637        let market: GammaMarket = load("gamma_market.json");
638
639        assert_eq!(market.best_bid, Some(0.5));
640        assert_eq!(market.best_ask, Some(0.51));
641        assert_eq!(market.spread, Some(0.009));
642        assert_eq!(market.last_trade_price, Some(0.51));
643        assert!(market.one_day_price_change.is_none());
644        assert!(market.one_week_price_change.is_none());
645        assert_eq!(market.volume_1wk, Some(9.999997));
646        assert_eq!(market.volume_1mo, Some(9.999997));
647        assert_eq!(market.volume_1yr, Some(9.999997));
648        assert_eq!(market.rewards_min_size, Some(50.0));
649        assert_eq!(market.rewards_max_spread, Some(4.5));
650        assert_eq!(market.competitive, Some(0.9999750006249843));
651        assert!(market.category.is_none());
652        assert!(market.neg_risk_market_id.is_none());
653        assert_eq!(
654            market.outcome_prices.as_deref(),
655            Some("[\"0.505\", \"0.495\"]")
656        );
657    }
658
659    #[rstest]
660    fn test_gamma_market_enriched_fields_default_to_none() {
661        // Minimal market JSON: only required fields
662        let json = r#"{"id": "m1", "conditionId": "0xcond", "clobTokenIds": "[]", "outcomes": "[]", "question": "Q?"}"#;
663        let market: GammaMarket = serde_json::from_str(json).unwrap();
664
665        assert!(market.best_bid.is_none());
666        assert!(market.spread.is_none());
667        assert!(market.volume_1wk.is_none());
668        assert!(market.rewards_min_size.is_none());
669        assert!(market.competitive.is_none());
670        assert!(market.category.is_none());
671        assert!(market.neg_risk_market_id.is_none());
672    }
673
674    #[rstest]
675    fn test_gamma_event_enriched_fields() {
676        let events: Vec<GammaEvent> = load("gamma_event.json");
677        let event = &events[0];
678
679        assert_eq!(event.liquidity, Some(43042905.16152));
680        assert_eq!(event.volume, Some(799823812.487094));
681        assert_eq!(event.open_interest, Some(0.0));
682        assert_eq!(event.volume_24hr, Some(5669354.219446001));
683        assert!(event.category.is_none());
684        assert_eq!(event.neg_risk, Some(true));
685        assert_eq!(
686            event.neg_risk_market_id.as_deref(),
687            Some("0x2c3d7e0eee6f058be3006baabf0d54a07da254ba47fe6e3e095e7990c7814700")
688        );
689        assert_eq!(event.featured, Some(false));
690    }
691
692    #[rstest]
693    fn test_gamma_tag_deserialization() {
694        let tags: Vec<GammaTag> = load("gamma_tags.json");
695
696        assert_eq!(tags.len(), 5);
697        assert_eq!(tags[0].id, "101259");
698        assert_eq!(tags[0].label.as_deref(), Some("Health and Human Services"));
699        assert_eq!(tags[0].slug.as_deref(), Some("health-and-human-services"));
700        assert_eq!(tags[2].slug.as_deref(), Some("attorney-general"));
701    }
702
703    #[rstest]
704    fn test_search_response_deserialization() {
705        let response: SearchResponse = load("search_response.json");
706
707        // Real API returns no top-level "markets" key
708        assert!(response.markets.is_none());
709
710        let events = response.events.as_ref().unwrap();
711        assert_eq!(events.len(), 1);
712        assert_eq!(events[0].slug.as_deref(), Some("bitcoin-above-on-march-11"));
713        assert_eq!(events[0].markets.len(), 1);
714    }
715
716    #[rstest]
717    fn test_search_response_empty_fields() {
718        let json = "{}";
719        let response: SearchResponse = serde_json::from_str(json).unwrap();
720        assert!(response.markets.is_none());
721        assert!(response.events.is_none());
722    }
723
724    #[rstest]
725    fn test_clob_book_response_deserialization() {
726        let response: ClobBookResponse = load("clob_book_response.json");
727
728        assert_eq!(response.bids.len(), 3);
729        assert_eq!(response.asks.len(), 3);
730
731        assert_eq!(response.bids[0].price, "0.48");
732        assert_eq!(response.bids[0].size, "100.00");
733        assert_eq!(response.bids[2].price, "0.50");
734        assert_eq!(response.bids[2].size, "150.00");
735
736        assert_eq!(response.asks[0].price, "0.51");
737        assert_eq!(response.asks[0].size, "120.00");
738        assert_eq!(response.asks[2].price, "0.53");
739        assert_eq!(response.asks[2].size, "90.00");
740    }
741
742    #[rstest]
743    fn test_clob_book_response_ignores_extra_fields() {
744        // Verify serde silently ignores fields from both V1 and V2 `/book`
745        // responses. The live V2 endpoint adds `tick_size`, `min_order_size`,
746        // `neg_risk`, and `last_trade_price` on top of the V1 fields; pinning
747        // them here catches a future `#[serde(deny_unknown_fields)]` regression
748        // before it breaks production parsing.
749        let json = r#"{
750            "market": "0xabc",
751            "asset_id": "123",
752            "hash": "0x1",
753            "timestamp": "123",
754            "bids": [],
755            "asks": [],
756            "tick_size": "0.01",
757            "min_order_size": "5",
758            "neg_risk": false,
759            "last_trade_price": "0.55"
760        }"#;
761        let response: ClobBookResponse = serde_json::from_str(json).unwrap();
762        assert!(response.bids.is_empty());
763        assert!(response.asks.is_empty());
764    }
765
766    #[rstest]
767    fn test_fee_rate_response_zero() {
768        let response: FeeRateResponse = load("clob_fee_rate_response_zero.json");
769        assert_eq!(response.base_fee, dec!(0));
770    }
771
772    #[rstest]
773    fn test_fee_rate_response_nonzero() {
774        let response: FeeRateResponse = load("clob_fee_rate_response_nonzero.json");
775        assert_eq!(response.base_fee, dec!(150));
776    }
777
778    #[rstest]
779    fn test_data_api_position_deserialization() {
780        let positions: Vec<DataApiPosition> = load("data_api_positions_response.json");
781
782        assert_eq!(positions.len(), 4);
783        assert_eq!(
784            positions[0].asset,
785            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
786        );
787        assert_eq!(
788            positions[0].condition_id,
789            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
790        );
791        assert_eq!(positions[0].size, 150.5);
792        assert_eq!(positions[0].avg_price, Some(0.55));
793
794        // Zero-size position
795        assert_eq!(positions[1].size, 0.0);
796        assert_eq!(positions[1].avg_price, Some(0.45));
797
798        // Third position
799        assert_eq!(
800            positions[2].condition_id,
801            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
802        );
803        assert_eq!(positions[2].size, 42.0);
804        assert_eq!(positions[2].avg_price, Some(0.3));
805
806        // Dust position (below DUST_POSITION_THRESHOLD)
807        assert_eq!(positions[3].size, 0.005);
808        assert_eq!(positions[3].avg_price, Some(0.7));
809    }
810
811    #[rstest]
812    fn test_data_api_trade_deserialization() {
813        let trades: Vec<DataApiTrade> = load("data_api_trades_response.json");
814
815        assert_eq!(trades.len(), 3);
816        assert_eq!(
817            trades[0].asset,
818            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
819        );
820        assert_eq!(
821            trades[0].condition_id,
822            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
823        );
824        assert_eq!(trades[0].side, PolymarketOrderSide::Buy);
825        assert_eq!(trades[0].price, 0.55);
826        assert_eq!(trades[0].size, 100.0);
827        assert_eq!(trades[0].timestamp, 1710000000);
828        assert_eq!(
829            trades[0].transaction_hash,
830            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
831        );
832
833        assert_eq!(trades[1].side, PolymarketOrderSide::Sell);
834        assert_eq!(trades[1].price, 0.53);
835
836        // Third trade has different asset (other outcome token)
837        assert_eq!(
838            trades[2].asset,
839            "99999999999999999999999999999999999999999999999999999999999999999999999999999"
840        );
841    }
842}