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_json_number, deserialize_decimal_from_str,
30        deserialize_optional_decimal_from_json_number, deserialize_optional_polymarket_game_id,
31        serialize_decimal_as_json_number, serialize_decimal_as_str,
32        serialize_optional_decimal_as_json_number,
33    },
34};
35
36/// A signed limit order for submission to the CLOB V2 exchange.
37///
38/// References: <https://docs.polymarket.com/v2-migration>,
39/// <https://docs.polymarket.com/api-reference/trade/post-a-new-order>
40///
41/// `expiration` is part of the wire body but NOT part of the EIP-712 signed
42/// struct in V2 (the protocol enforces it server-side). `"0"` means no
43/// expiration. All other fields appear inside the signed struct.
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct PolymarketOrder {
47    pub salt: u64,
48    pub maker: String,
49    pub signer: String,
50    pub token_id: Ustr,
51    #[serde(
52        serialize_with = "serialize_decimal_as_str",
53        deserialize_with = "deserialize_decimal_from_str"
54    )]
55    pub maker_amount: Decimal,
56    #[serde(
57        serialize_with = "serialize_decimal_as_str",
58        deserialize_with = "deserialize_decimal_from_str"
59    )]
60    pub taker_amount: Decimal,
61    pub side: PolymarketOrderSide,
62    pub signature_type: SignatureType,
63    /// Unix seconds timestamp when a GTD order auto-expires. `"0"` for non-GTD.
64    /// Not included in the EIP-712 signed hash; protocol enforces this value.
65    pub expiration: String,
66    /// Order creation time in milliseconds. Replaces `nonce` from V1 for
67    /// per-address uniqueness (not an expiration).
68    pub timestamp: String,
69    /// Generic bytes32 metadata field. Zero bytes when unused.
70    pub metadata: String,
71    /// Builder code (`bytes32`). Zero bytes when unset.
72    pub builder: String,
73    pub signature: String,
74}
75
76/// An active order returned by REST GET /orders.
77///
78/// References: <https://docs.polymarket.com/#get-orders>
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct PolymarketOpenOrder {
81    pub associate_trades: Option<Vec<String>>,
82    pub id: String,
83    pub status: PolymarketOrderStatus,
84    pub market: Ustr,
85    #[serde(
86        serialize_with = "serialize_decimal_as_str",
87        deserialize_with = "deserialize_decimal_from_str"
88    )]
89    pub original_size: Decimal,
90    pub outcome: PolymarketOutcome,
91    pub maker_address: String,
92    pub owner: String,
93    #[serde(
94        serialize_with = "serialize_decimal_as_str",
95        deserialize_with = "deserialize_decimal_from_str"
96    )]
97    pub price: Decimal,
98    pub side: PolymarketOrderSide,
99    #[serde(
100        serialize_with = "serialize_decimal_as_str",
101        deserialize_with = "deserialize_decimal_from_str"
102    )]
103    pub size_matched: Decimal,
104    pub asset_id: Ustr,
105    pub expiration: Option<String>,
106    pub order_type: PolymarketOrderType,
107    pub created_at: u64,
108}
109
110/// A trade report returned by REST GET /trades.
111///
112/// References: <https://docs.polymarket.com/#get-trades>
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub struct PolymarketTradeReport {
115    pub id: String,
116    pub taker_order_id: String,
117    pub market: Ustr,
118    pub asset_id: Ustr,
119    pub side: PolymarketOrderSide,
120    #[serde(
121        serialize_with = "serialize_decimal_as_str",
122        deserialize_with = "deserialize_decimal_from_str"
123    )]
124    pub size: Decimal,
125    #[serde(
126        serialize_with = "serialize_decimal_as_str",
127        deserialize_with = "deserialize_decimal_from_str"
128    )]
129    pub fee_rate_bps: Decimal,
130    #[serde(
131        serialize_with = "serialize_decimal_as_str",
132        deserialize_with = "deserialize_decimal_from_str"
133    )]
134    pub price: Decimal,
135    pub status: PolymarketTradeStatus,
136    pub match_time: String,
137    pub last_update: String,
138    pub outcome: PolymarketOutcome,
139    pub bucket_index: u64,
140    pub owner: String,
141    pub maker_address: String,
142    pub transaction_hash: String,
143    pub maker_orders: Vec<PolymarketMakerOrder>,
144    pub trader_side: PolymarketLiquiditySide,
145}
146
147/// A market response from the Gamma API `GET /markets`.
148///
149/// References: <https://docs.polymarket.com/developers/gamma-markets-api/get-markets>
150#[derive(Clone, Debug, Deserialize, Serialize)]
151#[serde(rename_all = "camelCase")]
152pub struct GammaMarket {
153    /// Internal Gamma market ID.
154    pub id: String,
155    /// On-chain condition ID for the CTF contracts.
156    pub condition_id: String,
157    /// Hash used for resolution.
158    #[serde(rename = "questionID")]
159    pub question_id: Option<String>,
160    /// JSON-encoded array of two CLOB token IDs (Yes, No).
161    #[serde(default)]
162    pub clob_token_ids: String,
163    /// JSON-encoded outcome labels (e.g. `["Yes", "No"]`).
164    #[serde(default)]
165    pub outcomes: String,
166    /// Market question/title.
167    pub question: String,
168    /// Detailed description.
169    pub description: Option<String>,
170    /// Market start date (ISO 8601).
171    pub start_date: Option<String>,
172    /// Event window start time (ISO 8601).
173    pub event_start_time: Option<String>,
174    /// Market end date (ISO 8601).
175    pub end_date: Option<String>,
176    /// Whether market is active.
177    pub active: Option<bool>,
178    /// Whether market is closed.
179    pub closed: Option<bool>,
180    /// Time when the market closed.
181    pub closed_time: Option<String>,
182    /// UMA resolution state reported by Gamma.
183    pub uma_resolution_status: Option<String>,
184    /// JSON-encoded UMA resolution states reported by Gamma.
185    pub uma_resolution_statuses: Option<String>,
186    /// Source used to resolve the market.
187    pub resolution_source: Option<String>,
188    /// Crypto market resolution configuration.
189    pub crypto_market_config: Option<CryptoMarketConfig>,
190    /// Whether CLOB is accepting orders.
191    pub accepting_orders: Option<bool>,
192    /// Whether order book trading is enabled.
193    pub enable_order_book: Option<bool>,
194    /// Minimum price increment.
195    #[serde(
196        default,
197        deserialize_with = "deserialize_optional_decimal_from_json_number",
198        serialize_with = "serialize_optional_decimal_as_json_number"
199    )]
200    pub order_price_min_tick_size: Option<Decimal>,
201    /// Minimum order size.
202    #[serde(
203        default,
204        deserialize_with = "deserialize_optional_decimal_from_json_number",
205        serialize_with = "serialize_optional_decimal_as_json_number"
206    )]
207    pub order_min_size: Option<Decimal>,
208    /// Maker fee in basis points.
209    pub maker_base_fee: Option<i64>,
210    /// Taker fee in basis points.
211    pub taker_base_fee: Option<i64>,
212    /// URL slug.
213    #[serde(rename = "slug")]
214    pub market_slug: Option<String>,
215    /// Whether the market uses neg-risk CTF exchange.
216    #[serde(rename = "negRisk")]
217    pub neg_risk: Option<bool>,
218    /// Numeric liquidity value for sorting.
219    pub liquidity_num: Option<f64>,
220    /// Numeric volume value for sorting.
221    pub volume_num: Option<f64>,
222    /// 24-hour trading volume.
223    #[serde(rename = "volume24hr")]
224    pub volume_24hr: Option<f64>,
225    /// JSON-encoded outcome prices (e.g. `["0.60", "0.40"]`).
226    pub outcome_prices: Option<String>,
227    /// Best bid price.
228    pub best_bid: Option<f64>,
229    /// Best ask price.
230    pub best_ask: Option<f64>,
231    /// Bid-ask spread.
232    pub spread: Option<f64>,
233    /// Last trade price.
234    pub last_trade_price: Option<f64>,
235    /// 1-day price change.
236    pub one_day_price_change: Option<f64>,
237    /// 1-week price change.
238    pub one_week_price_change: Option<f64>,
239    /// 1-week volume.
240    #[serde(rename = "volume1wk")]
241    pub volume_1wk: Option<f64>,
242    /// 1-month volume.
243    #[serde(rename = "volume1mo")]
244    pub volume_1mo: Option<f64>,
245    /// 1-year volume.
246    #[serde(rename = "volume1yr")]
247    pub volume_1yr: Option<f64>,
248    /// Minimum size for rewards eligibility.
249    pub rewards_min_size: Option<f64>,
250    /// Maximum spread for rewards eligibility.
251    pub rewards_max_spread: Option<f64>,
252    /// Competitiveness score.
253    pub competitive: Option<f64>,
254    /// Market category.
255    pub category: Option<String>,
256    /// Neg-risk market ID for CTF exchange interaction.
257    #[serde(rename = "negRiskMarketID")]
258    pub neg_risk_market_id: Option<String>,
259    /// Fee schedule for this market.
260    pub fee_schedule: Option<FeeSchedule>,
261    /// Game ID for sport markets, kept verbatim because Gamma emits both
262    /// numeric and composite `<uuid>:<away>:<home>` forms. `null` and `-1`
263    /// both mean "no game" and surface as `None`. Reference shape:
264    /// <https://github.com/Polymarket/rs-clob-client/blob/main/src/gamma/types/response.rs>.
265    #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
266    pub game_id: Option<String>,
267    /// Events linked to this gamma market.
268    pub events: Option<Vec<GammaEvent>>,
269}
270
271#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
272#[serde(rename_all = "camelCase")]
273pub struct FeeSchedule {
274    #[serde(
275        serialize_with = "serialize_decimal_as_json_number",
276        deserialize_with = "deserialize_decimal_from_json_number"
277    )]
278    pub exponent: Decimal,
279    #[serde(
280        serialize_with = "serialize_decimal_as_json_number",
281        deserialize_with = "deserialize_decimal_from_json_number"
282    )]
283    pub rate: Decimal,
284    pub taker_only: bool,
285    #[serde(
286        serialize_with = "serialize_decimal_as_json_number",
287        deserialize_with = "deserialize_decimal_from_json_number"
288    )]
289    pub rebate_rate: Decimal,
290}
291
292/// Crypto market resolution configuration returned by Gamma.
293#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
294#[serde(rename_all = "camelCase")]
295pub struct CryptoMarketConfig {
296    pub id: String,
297    pub asset: String,
298    pub duration: String,
299    pub twap_enabled: bool,
300    #[serde(
301        default,
302        skip_serializing_if = "Option::is_none",
303        deserialize_with = "deserialize_optional_non_null_i64"
304    )]
305    pub twap_lookback_seconds: Option<i64>,
306}
307
308fn deserialize_optional_non_null_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
309where
310    D: serde::Deserializer<'de>,
311{
312    i64::deserialize(deserializer).map(Some)
313}
314
315/// An event response from the Gamma API `GET /events`.
316///
317/// Events are parent containers grouping related markets (e.g., an election
318/// event contains multiple outcome markets). Each event's `markets` array
319/// contains full [`GammaMarket`] objects.
320#[derive(Clone, Debug, Deserialize, Serialize)]
321#[serde(rename_all = "camelCase")]
322pub struct GammaEvent {
323    pub id: String,
324    pub slug: Option<String>,
325    pub title: Option<String>,
326    pub description: Option<String>,
327    pub start_date: Option<String>,
328    pub end_date: Option<String>,
329    pub active: Option<bool>,
330    pub closed: Option<bool>,
331    pub archived: Option<bool>,
332    #[serde(default)]
333    pub markets: Vec<GammaMarket>,
334    /// Event-level liquidity.
335    pub liquidity: Option<f64>,
336    /// Event-level volume.
337    pub volume: Option<f64>,
338    /// Event-level open interest.
339    pub open_interest: Option<f64>,
340    /// 24-hour event volume.
341    #[serde(rename = "volume24hr")]
342    pub volume_24hr: Option<f64>,
343    /// Event category.
344    pub category: Option<String>,
345    /// Whether event uses neg-risk.
346    pub neg_risk: Option<bool>,
347    /// Neg-risk market ID.
348    #[serde(rename = "negRiskMarketID")]
349    pub neg_risk_market_id: Option<String>,
350    /// Whether event is featured.
351    pub featured: Option<bool>,
352    /// Game ID for sport markets, kept verbatim because Gamma emits both
353    /// numeric and composite `<uuid>:<away>:<home>` forms. `null` and `-1`
354    /// both mean "no game" and surface as `None`. Reference shape:
355    /// <https://github.com/Polymarket/rs-clob-client/blob/main/src/gamma/types/response.rs>.
356    #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
357    pub game_id: Option<String>,
358}
359
360/// A tag from the Gamma API `GET /tags`.
361#[derive(Clone, Debug, Deserialize, Serialize)]
362pub struct GammaTag {
363    /// Tag identifier.
364    pub id: String,
365    /// Human-readable label.
366    pub label: Option<String>,
367    /// URL slug.
368    pub slug: Option<String>,
369}
370
371/// Response from the Gamma API `GET /public-search`.
372#[derive(Clone, Debug, Deserialize, Serialize)]
373pub struct SearchResponse {
374    /// Matching markets.
375    #[serde(default)]
376    pub markets: Option<Vec<GammaMarket>>,
377    /// Matching events.
378    #[serde(default)]
379    pub events: Option<Vec<GammaEvent>>,
380}
381
382/// Tick size response from CLOB `GET /tick-size`.
383///
384/// References: <https://docs.polymarket.com/api-reference/market-data/get-tick-size>
385#[derive(Clone, Debug, Deserialize)]
386pub struct TickSizeResponse {
387    /// Minimum tick size (price increment) for a token.
388    #[serde(deserialize_with = "deserialize_decimal_from_json_number")]
389    pub minimum_tick_size: Decimal,
390}
391
392/// Fee rate response from CLOB `GET /fee-rate`.
393///
394/// Returns the taker fee rate in basis points for a given token.
395#[derive(Clone, Debug, Deserialize)]
396pub struct FeeRateResponse {
397    /// Fee rate in basis points.
398    pub base_fee: Decimal,
399}
400
401/// A single price level from the CLOB order book.
402#[derive(Clone, Debug, Deserialize)]
403pub struct ClobBookLevel {
404    pub price: String,
405    pub size: String,
406}
407
408/// Response from the CLOB `GET /book` endpoint.
409///
410/// Extra fields (`market`, `asset_id`, `hash`, `timestamp`) are silently ignored.
411#[derive(Clone, Debug, Deserialize)]
412pub struct ClobBookResponse {
413    pub bids: Vec<ClobBookLevel>,
414    pub asks: Vec<ClobBookLevel>,
415}
416
417/// A single outcome token in a CLOB market response.
418#[derive(Clone, Debug, Deserialize, Serialize)]
419pub struct ClobMarketToken {
420    pub token_id: String,
421    pub outcome: String,
422    #[serde(
423        default,
424        deserialize_with = "deserialize_optional_decimal_from_json_number",
425        serialize_with = "serialize_optional_decimal_as_json_number"
426    )]
427    pub price: Option<Decimal>,
428    pub winner: bool,
429}
430
431/// A daily reward rate in a CLOB market response.
432#[derive(Clone, Debug, Deserialize, Serialize)]
433pub struct ClobMarketRewardRate {
434    pub asset_address: String,
435    #[serde(
436        deserialize_with = "deserialize_decimal_from_json_number",
437        serialize_with = "serialize_decimal_as_json_number"
438    )]
439    pub rewards_daily_rate: Decimal,
440}
441
442/// Reward configuration in a CLOB market response.
443#[derive(Clone, Debug, Deserialize, Serialize)]
444pub struct ClobMarketRewards {
445    pub rates: Option<Vec<ClobMarketRewardRate>>,
446    #[serde(
447        default,
448        deserialize_with = "deserialize_optional_decimal_from_json_number",
449        serialize_with = "serialize_optional_decimal_as_json_number"
450    )]
451    pub min_size: Option<Decimal>,
452    #[serde(
453        default,
454        deserialize_with = "deserialize_optional_decimal_from_json_number",
455        serialize_with = "serialize_optional_decimal_as_json_number"
456    )]
457    pub max_spread: Option<Decimal>,
458}
459
460/// Response from CLOB `GET /markets/{condition_id}`.
461#[derive(Clone, Debug, Deserialize, Serialize)]
462pub struct ClobMarketResponse {
463    pub enable_order_book: Option<bool>,
464    pub active: Option<bool>,
465    pub condition_id: String,
466    pub closed: bool,
467    pub archived: Option<bool>,
468    pub accepting_orders: Option<bool>,
469    pub accepting_order_timestamp: Option<String>,
470    #[serde(
471        default,
472        deserialize_with = "deserialize_optional_decimal_from_json_number",
473        serialize_with = "serialize_optional_decimal_as_json_number"
474    )]
475    pub minimum_order_size: Option<Decimal>,
476    #[serde(
477        default,
478        deserialize_with = "deserialize_optional_decimal_from_json_number",
479        serialize_with = "serialize_optional_decimal_as_json_number"
480    )]
481    pub minimum_tick_size: Option<Decimal>,
482    pub question_id: Option<String>,
483    pub question: Option<String>,
484    pub description: Option<String>,
485    pub market_slug: Option<String>,
486    pub end_date_iso: Option<String>,
487    pub game_start_time: Option<String>,
488    pub seconds_delay: Option<i64>,
489    pub fpmm: Option<String>,
490    pub maker_base_fee: Option<i64>,
491    pub taker_base_fee: Option<i64>,
492    pub notifications_enabled: Option<bool>,
493    pub neg_risk: Option<bool>,
494    pub neg_risk_market_id: Option<String>,
495    pub neg_risk_request_id: Option<String>,
496    pub icon: Option<String>,
497    pub image: Option<String>,
498    pub rewards: Option<ClobMarketRewards>,
499    pub is_50_50_outcome: Option<bool>,
500    pub tokens: Vec<ClobMarketToken>,
501    pub tags: Option<Vec<String>>,
502}
503
504/// A position from the Polymarket Data API `GET /positions` endpoint.
505#[derive(Clone, Debug, Deserialize)]
506pub struct DataApiPosition {
507    pub asset: String,
508    #[serde(alias = "conditionId", alias = "condition_id")]
509    pub condition_id: String,
510    pub size: Decimal,
511    #[serde(alias = "avgPrice", alias = "avg_price")]
512    pub avg_price: Option<Decimal>,
513}
514
515/// A trade from the Polymarket Data API `GET /trades` endpoint.
516#[derive(Clone, Debug, Deserialize)]
517#[serde(rename_all = "camelCase")]
518pub struct DataApiTrade {
519    pub proxy_wallet: Option<String>,
520    pub asset: String,
521    pub condition_id: String,
522    pub side: PolymarketOrderSide,
523    #[serde(deserialize_with = "deserialize_decimal_from_json_number")]
524    pub price: Decimal,
525    #[serde(deserialize_with = "deserialize_decimal_from_json_number")]
526    pub size: Decimal,
527    pub timestamp: i64,
528    pub title: Option<String>,
529    pub slug: Option<String>,
530    pub icon: Option<String>,
531    pub event_slug: Option<String>,
532    pub outcome: Option<String>,
533    pub outcome_index: Option<i64>,
534    pub name: Option<String>,
535    pub pseudonym: Option<String>,
536    pub bio: Option<String>,
537    pub profile_image: Option<String>,
538    pub profile_image_optimized: Option<String>,
539    pub transaction_hash: String,
540}
541
542#[cfg(test)]
543mod tests {
544    use rstest::rstest;
545    use rust_decimal_macros::dec;
546
547    use super::*;
548    use crate::common::enums::{PolymarketOrderStatus, PolymarketTradeStatus, SignatureType};
549
550    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
551        let path = format!("test_data/{filename}");
552        let content = std::fs::read_to_string(path).expect("Failed to read test data");
553        serde_json::from_str(&content).expect("Failed to parse test data")
554    }
555
556    #[rstest]
557    fn test_open_order_live_buy_gtc() {
558        let order: PolymarketOpenOrder = load("http_open_order.json");
559
560        assert_eq!(
561            order.id,
562            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
563        );
564        assert_eq!(order.status, PolymarketOrderStatus::Live);
565        assert_eq!(order.side, PolymarketOrderSide::Buy);
566        assert_eq!(order.order_type, PolymarketOrderType::GTC);
567        assert_eq!(order.outcome, PolymarketOutcome::yes());
568        assert_eq!(order.original_size, dec!(100.0000));
569        assert_eq!(order.price, dec!(0.5000));
570        assert_eq!(order.size_matched, dec!(25.0000));
571        assert_eq!(order.created_at, 1703875200);
572        assert!(order.expiration.is_none());
573        assert_eq!(order.associate_trades, Some(vec!["0xabc001".to_string()]));
574    }
575
576    #[rstest]
577    fn test_open_order_matched_sell_fok() {
578        let order: PolymarketOpenOrder = load("http_open_order_sell_fok.json");
579
580        assert_eq!(order.status, PolymarketOrderStatus::Matched);
581        assert_eq!(order.side, PolymarketOrderSide::Sell);
582        assert_eq!(order.order_type, PolymarketOrderType::FOK);
583        assert_eq!(order.outcome, PolymarketOutcome::no());
584        assert_eq!(order.size_matched, dec!(50.0000));
585        assert_eq!(order.expiration, Some("1735689600".to_string()));
586        assert!(order.associate_trades.is_none());
587    }
588
589    #[rstest]
590    fn test_open_order_roundtrip() {
591        let order: PolymarketOpenOrder = load("http_open_order.json");
592        let json = serde_json::to_string(&order).unwrap();
593        let order2: PolymarketOpenOrder = serde_json::from_str(&json).unwrap();
594        assert_eq!(order, order2);
595    }
596
597    #[rstest]
598    fn test_trade_report_fields() {
599        let trade: PolymarketTradeReport = load("http_trade_report.json");
600
601        assert_eq!(trade.id, "trade-0xabcdef1234");
602        assert_eq!(
603            trade.taker_order_id,
604            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
605        );
606        assert_eq!(trade.side, PolymarketOrderSide::Buy);
607        assert_eq!(trade.size, dec!(25.0000));
608        assert_eq!(trade.fee_rate_bps, dec!(0));
609        assert_eq!(trade.price, dec!(0.5000));
610        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
611        assert_eq!(trade.outcome, PolymarketOutcome::yes());
612        assert_eq!(trade.bucket_index, 0);
613        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
614        assert_eq!(trade.maker_orders.len(), 2);
615    }
616
617    #[rstest]
618    fn test_trade_report_maker_orders() {
619        let trade: PolymarketTradeReport = load("http_trade_report.json");
620
621        let first = &trade.maker_orders[0];
622        assert_eq!(first.matched_amount, dec!(25.0000));
623        assert_eq!(first.price, dec!(0.5000));
624        assert_eq!(first.outcome, PolymarketOutcome::yes());
625
626        let second = &trade.maker_orders[1];
627        assert_eq!(second.matched_amount, dec!(5.0000));
628    }
629
630    #[rstest]
631    fn test_trade_report_roundtrip() {
632        let trade: PolymarketTradeReport = load("http_trade_report.json");
633        let json = serde_json::to_string(&trade).unwrap();
634        let trade2: PolymarketTradeReport = serde_json::from_str(&json).unwrap();
635        assert_eq!(trade, trade2);
636    }
637
638    #[rstest]
639    fn test_signed_order_camel_case_fields() {
640        let order: PolymarketOrder = load("http_signed_order.json");
641
642        assert_eq!(order.salt, 123456789);
643        assert_eq!(order.maker, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
644        assert_eq!(order.maker_amount, dec!(100000000));
645        assert_eq!(order.taker_amount, dec!(50000000));
646        assert_eq!(order.expiration, "0");
647        assert_eq!(order.timestamp, "1713398400000");
648        assert_eq!(
649            order.metadata,
650            "0x0000000000000000000000000000000000000000000000000000000000000000"
651        );
652        assert_eq!(
653            order.builder,
654            "0x0000000000000000000000000000000000000000000000000000000000000000"
655        );
656        assert_eq!(order.side, PolymarketOrderSide::Buy);
657        assert_eq!(order.signature_type, SignatureType::Eoa);
658    }
659
660    #[rstest]
661    fn test_signed_order_roundtrip() {
662        let order: PolymarketOrder = load("http_signed_order.json");
663        let json = serde_json::to_string(&order).unwrap();
664        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
665        assert_eq!(order, order2);
666    }
667
668    #[rstest]
669    fn test_signed_order_serializes_camel_case() {
670        let order: PolymarketOrder = load("http_signed_order.json");
671        let json = serde_json::to_string(&order).unwrap();
672
673        // Verify camelCase field names are present in serialized output
674        assert!(json.contains("\"tokenId\""));
675        assert!(json.contains("\"makerAmount\""));
676        assert!(json.contains("\"takerAmount\""));
677        assert!(json.contains("\"signatureType\""));
678        assert!(json.contains("\"expiration\""));
679        assert!(json.contains("\"timestamp\""));
680        assert!(json.contains("\"metadata\""));
681        assert!(json.contains("\"builder\""));
682    }
683
684    #[rstest]
685    fn test_signed_order_omits_v1_fields() {
686        // V2 dropped `taker`, `nonce`, and `feeRateBps` from the order body.
687        // A regression that re-introduces any of them would silently land V1
688        // shape on a V2 endpoint, so we explicitly assert their absence.
689        let order: PolymarketOrder = load("http_signed_order.json");
690        let json = serde_json::to_string(&order).unwrap();
691
692        assert!(
693            !json.contains("\"taker\""),
694            "wire body must not include `taker`: {json}"
695        );
696        assert!(
697            !json.contains("\"nonce\""),
698            "wire body must not include `nonce`: {json}"
699        );
700        assert!(
701            !json.contains("\"feeRateBps\""),
702            "wire body must not include `feeRateBps`: {json}"
703        );
704    }
705
706    #[rstest]
707    fn test_signed_order_v2_docs_example_roundtrips() {
708        // POST /order body shape from <https://docs.polymarket.com/v2-migration>.
709        // Round-tripping it ensures we accept the exact shape the docs publish.
710        let docs_example = r#"{
711            "salt": 12345,
712            "maker": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
713            "signer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
714            "tokenId": "102936",
715            "makerAmount": "1000000",
716            "takerAmount": "2000000",
717            "side": "BUY",
718            "signatureType": 1,
719            "expiration": "0",
720            "timestamp": "1713398400000",
721            "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
722            "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
723            "signature": "0xdeadbeef"
724        }"#;
725
726        let order: PolymarketOrder = serde_json::from_str(docs_example).unwrap();
727        assert_eq!(order.salt, 12345);
728        assert_eq!(order.token_id.as_str(), "102936");
729        assert_eq!(order.maker_amount, dec!(1000000));
730        assert_eq!(order.taker_amount, dec!(2000000));
731        assert_eq!(order.side, PolymarketOrderSide::Buy);
732        assert_eq!(order.signature_type, SignatureType::PolyProxy);
733        assert_eq!(order.expiration, "0");
734        assert_eq!(order.timestamp, "1713398400000");
735
736        // Round-trip preserves field semantics.
737        let json = serde_json::to_string(&order).unwrap();
738        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
739        assert_eq!(order, order2);
740    }
741
742    #[rstest]
743    fn test_gamma_event_deserialization() {
744        let events: Vec<GammaEvent> = load("gamma_event.json");
745
746        assert_eq!(events.len(), 1);
747        let event = &events[0];
748        assert_eq!(event.id, "30829");
749        assert_eq!(
750            event.slug.as_deref(),
751            Some("democratic-presidential-nominee-2028")
752        );
753        assert_eq!(
754            event.title.as_deref(),
755            Some("Democratic Presidential Nominee 2028")
756        );
757        assert_eq!(event.active, Some(true));
758        assert_eq!(event.closed, Some(false));
759        assert_eq!(event.archived, Some(false));
760        assert_eq!(event.markets.len(), 2);
761        assert_eq!(
762            event.markets[0].condition_id,
763            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
764        );
765        assert_eq!(
766            event.markets[1].condition_id,
767            "0xe39adea057926dc197fe30a441f57a340b2a232d5a687010f78bba9b6e02620f"
768        );
769    }
770
771    #[rstest]
772    fn test_gamma_event_empty_markets() {
773        let json = r#"[{"id": "evt-002"}]"#;
774        let events: Vec<GammaEvent> = serde_json::from_str(json).unwrap();
775
776        assert_eq!(events.len(), 1);
777        assert_eq!(events[0].id, "evt-002");
778        assert!(events[0].markets.is_empty());
779        assert!(events[0].slug.is_none());
780    }
781
782    #[rstest]
783    fn test_sports_market_are_weird() {
784        let money_line: GammaMarket = load("gamma_market_sports_market_money_line.json");
785        let map_handicap: GammaMarket = load("gamma_market_sports_market_map_handicap.json");
786
787        // same event, same slug
788        assert_eq!(
789            money_line.events.as_ref().unwrap()[0].game_id,
790            map_handicap.events.as_ref().unwrap()[0].game_id
791        );
792
793        // one market has no game_id
794        assert!(map_handicap.game_id.is_none());
795        assert_eq!(money_line.game_id.as_deref(), Some("1427074"));
796    }
797
798    #[rstest]
799    fn test_gamma_event_composite_sports_game_id() {
800        // Live Gamma record from issue #4771: the event carries a numeric
801        // `gameId` while its first market carries a composite one.
802        let events: Vec<GammaEvent> = load("gamma_event_sports_composite_game_id.json");
803
804        assert_eq!(events.len(), 1);
805
806        let event = &events[0];
807
808        assert_eq!(event.id, "835109");
809        assert_eq!(event.game_id.as_deref(), Some("287011684"));
810        assert_eq!(event.markets.len(), 2);
811        assert_eq!(event.markets[0].id, "3524358");
812        assert_eq!(
813            event.markets[0].game_id.as_deref(),
814            Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
815        );
816        assert_eq!(event.markets[1].id, "3554041");
817        assert_eq!(event.markets[1].game_id, None);
818
819        // Re-serialization feeds the Python loader, so the key stays a string
820        // even where Gamma sent a number.
821        let encoded = serde_json::to_value(event).unwrap();
822
823        assert_eq!(encoded["gameId"], serde_json::json!("287011684"));
824        assert_eq!(
825            encoded["markets"][0]["gameId"],
826            serde_json::json!("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
827        );
828    }
829
830    #[rstest]
831    fn test_fee_schedule_decimal_fields() {
832        let market: GammaMarket = load("gamma_market_sports_market_money_line.json");
833        let schedule = market.fee_schedule.unwrap();
834
835        assert_eq!(schedule.exponent, Decimal::ONE);
836        assert_eq!(schedule.rate, dec!(0.03));
837        assert!(schedule.taker_only);
838        assert_eq!(schedule.rebate_rate, dec!(0.25));
839    }
840
841    #[rstest]
842    fn test_gamma_market_crypto_market_config_fields() {
843        let market: GammaMarket = load("gamma_market_crypto_twap.json");
844        let config = market.crypto_market_config.as_ref().unwrap();
845
846        assert_eq!(config.id, "btc-5m-twap-60");
847        assert_eq!(config.asset, "btc");
848        assert_eq!(config.duration, "5m");
849        assert!(config.twap_enabled);
850        assert_eq!(config.twap_lookback_seconds, Some(60));
851        assert_eq!(
852            market.resolution_source.as_deref(),
853            Some("https://data.chain.link/streams/btc-usd-twap-60s-streams")
854        );
855        assert_eq!(
856            market.event_start_time.as_deref(),
857            Some("2026-08-22T16:00:00Z")
858        );
859    }
860
861    #[rstest]
862    fn test_crypto_market_config_absent_twap_lookback_serializes_omitted() {
863        let crypto_market_config = serde_json::json!({
864            "id": "btc-5m",
865            "asset": "btc",
866            "duration": "5m",
867            "twapEnabled": false,
868        });
869
870        let config: CryptoMarketConfig = serde_json::from_value(crypto_market_config).unwrap();
871        let encoded = serde_json::to_value(config).unwrap();
872
873        assert!(encoded.get("twapLookbackSeconds").is_none());
874    }
875
876    #[rstest]
877    fn test_crypto_market_config_rejects_null_twap_lookback() {
878        let crypto_market_config = serde_json::json!({
879            "id": "btc-5m",
880            "asset": "btc",
881            "duration": "5m",
882            "twapEnabled": false,
883            "twapLookbackSeconds": null,
884        });
885
886        let result = serde_json::from_value::<CryptoMarketConfig>(crypto_market_config);
887
888        assert!(result.is_err());
889    }
890
891    #[rstest]
892    #[case(serde_json::json!(-37))]
893    #[case(serde_json::json!(i64::MIN))]
894    #[case(serde_json::json!(i64::MAX))]
895    fn test_crypto_market_config_signed_twap_lookback_roundtrip(
896        #[case] twap_lookback_seconds: serde_json::Value,
897    ) {
898        let crypto_market_config = serde_json::json!({
899            "id": "eth-15m",
900            "asset": "eth",
901            "duration": "15m",
902            "twapEnabled": true,
903            "twapLookbackSeconds": twap_lookback_seconds,
904        });
905
906        let config: CryptoMarketConfig = serde_json::from_value(crypto_market_config).unwrap();
907        let encoded = serde_json::to_value(config).unwrap();
908
909        assert_eq!(encoded["twapLookbackSeconds"], twap_lookback_seconds);
910    }
911
912    #[rstest]
913    fn test_crypto_market_config_rejects_twap_lookback_above_i64_max() {
914        let crypto_market_config = serde_json::json!({
915            "id": "eth-15m",
916            "asset": "eth",
917            "duration": "15m",
918            "twapEnabled": true,
919            "twapLookbackSeconds": 9_223_372_036_854_775_808u64,
920        });
921
922        let result = serde_json::from_value::<CryptoMarketConfig>(crypto_market_config);
923
924        assert!(result.is_err());
925    }
926
927    #[rstest]
928    fn test_gamma_market_enriched_fields() {
929        let market: GammaMarket = load("gamma_market.json");
930
931        assert_eq!(
932            market.event_start_time.as_deref(),
933            Some("2026-03-12T09:20:00Z")
934        );
935        assert_eq!(market.best_bid, Some(0.5));
936        assert_eq!(market.best_ask, Some(0.51));
937        assert_eq!(market.spread, Some(0.009));
938        assert_eq!(market.last_trade_price, Some(0.51));
939        assert!(market.one_day_price_change.is_none());
940        assert!(market.one_week_price_change.is_none());
941        assert_eq!(market.volume_1wk, Some(9.999997));
942        assert_eq!(market.volume_1mo, Some(9.999997));
943        assert_eq!(market.volume_1yr, Some(9.999997));
944        assert_eq!(market.rewards_min_size, Some(50.0));
945        assert_eq!(market.rewards_max_spread, Some(4.5));
946        assert_eq!(market.competitive, Some(0.9999750006249843));
947        assert!(market.category.is_none());
948        assert!(market.neg_risk_market_id.is_none());
949        assert!(market.uma_resolution_status.is_none());
950        assert_eq!(market.uma_resolution_statuses.as_deref(), Some("[]"));
951        assert_eq!(
952            market.outcome_prices.as_deref(),
953            Some("[\"0.505\", \"0.495\"]")
954        );
955    }
956
957    #[rstest]
958    fn test_gamma_market_uma_resolution_statuses() {
959        let market: GammaMarket = load("gamma_market.json");
960
961        assert!(market.uma_resolution_status.is_none());
962        assert_eq!(market.uma_resolution_statuses.as_deref(), Some("[]"));
963    }
964
965    #[rstest]
966    fn test_gamma_market_enriched_fields_default_to_none() {
967        // Minimal market JSON: only required fields
968        let json = r#"{"id": "m1", "conditionId": "0xcond", "clobTokenIds": "[]", "outcomes": "[]", "question": "Q?"}"#;
969        let market: GammaMarket = serde_json::from_str(json).unwrap();
970
971        assert!(market.best_bid.is_none());
972        assert!(market.spread.is_none());
973        assert!(market.volume_1wk.is_none());
974        assert!(market.rewards_min_size.is_none());
975        assert!(market.competitive.is_none());
976        assert!(market.category.is_none());
977        assert!(market.neg_risk_market_id.is_none());
978        assert!(market.crypto_market_config.is_none());
979        assert!(market.event_start_time.is_none());
980    }
981
982    #[rstest]
983    fn test_gamma_event_enriched_fields() {
984        let events: Vec<GammaEvent> = load("gamma_event.json");
985        let event = &events[0];
986
987        assert_eq!(event.liquidity, Some(43042905.16152));
988        assert_eq!(event.volume, Some(799823812.487094));
989        assert_eq!(event.open_interest, Some(0.0));
990        assert_eq!(event.volume_24hr, Some(5669354.219446001));
991        assert!(event.category.is_none());
992        assert_eq!(event.neg_risk, Some(true));
993        assert_eq!(
994            event.neg_risk_market_id.as_deref(),
995            Some("0x2c3d7e0eee6f058be3006baabf0d54a07da254ba47fe6e3e095e7990c7814700")
996        );
997        assert_eq!(event.featured, Some(false));
998    }
999
1000    #[rstest]
1001    fn test_gamma_tag_deserialization() {
1002        let tags: Vec<GammaTag> = load("gamma_tags.json");
1003
1004        assert_eq!(tags.len(), 5);
1005        assert_eq!(tags[0].id, "101259");
1006        assert_eq!(tags[0].label.as_deref(), Some("Health and Human Services"));
1007        assert_eq!(tags[0].slug.as_deref(), Some("health-and-human-services"));
1008        assert_eq!(tags[2].slug.as_deref(), Some("attorney-general"));
1009    }
1010
1011    #[rstest]
1012    fn test_search_response_deserialization() {
1013        let response: SearchResponse = load("search_response.json");
1014
1015        // Real API returns no top-level "markets" key
1016        assert!(response.markets.is_none());
1017
1018        let events = response.events.as_ref().unwrap();
1019        assert_eq!(events.len(), 1);
1020        assert_eq!(events[0].slug.as_deref(), Some("bitcoin-above-on-march-11"));
1021        assert_eq!(events[0].markets.len(), 1);
1022    }
1023
1024    #[rstest]
1025    fn test_search_response_empty_fields() {
1026        let json = "{}";
1027        let response: SearchResponse = serde_json::from_str(json).unwrap();
1028        assert!(response.markets.is_none());
1029        assert!(response.events.is_none());
1030    }
1031
1032    #[rstest]
1033    fn test_clob_book_response_deserialization() {
1034        let response: ClobBookResponse = load("clob_book_response.json");
1035
1036        assert_eq!(response.bids.len(), 3);
1037        assert_eq!(response.asks.len(), 3);
1038
1039        assert_eq!(response.bids[0].price, "0.48");
1040        assert_eq!(response.bids[0].size, "100.00");
1041        assert_eq!(response.bids[2].price, "0.50");
1042        assert_eq!(response.bids[2].size, "150.00");
1043
1044        assert_eq!(response.asks[0].price, "0.51");
1045        assert_eq!(response.asks[0].size, "120.00");
1046        assert_eq!(response.asks[2].price, "0.53");
1047        assert_eq!(response.asks[2].size, "90.00");
1048    }
1049
1050    #[rstest]
1051    fn test_clob_book_response_ignores_extra_fields() {
1052        // Verify serde silently ignores fields from both V1 and V2 `/book`
1053        // responses. The live V2 endpoint adds `tick_size`, `min_order_size`,
1054        // `neg_risk`, and `last_trade_price` on top of the V1 fields; pinning
1055        // them here catches a future `#[serde(deny_unknown_fields)]` regression
1056        // before it breaks production parsing.
1057        let json = r#"{
1058            "market": "0xabc",
1059            "asset_id": "123",
1060            "hash": "0x1",
1061            "timestamp": "123",
1062            "bids": [],
1063            "asks": [],
1064            "tick_size": "0.01",
1065            "min_order_size": "5",
1066            "neg_risk": false,
1067            "last_trade_price": "0.55"
1068        }"#;
1069        let response: ClobBookResponse = serde_json::from_str(json).unwrap();
1070        assert!(response.bids.is_empty());
1071        assert!(response.asks.is_empty());
1072    }
1073
1074    #[rstest]
1075    fn test_clob_market_response_captured_fields() {
1076        let response: ClobMarketResponse = load("clob_market_response.json");
1077        let raw: serde_json::Value = load("clob_market_response.json");
1078
1079        assert_eq!(response.enable_order_book, Some(true));
1080        assert_eq!(response.active, Some(true));
1081        assert!(!response.closed);
1082        assert_eq!(response.archived, Some(false));
1083        assert_eq!(response.accepting_orders, Some(true));
1084        assert_eq!(
1085            response.accepting_order_timestamp.as_deref(),
1086            Some("2026-08-01T22:56:49Z")
1087        );
1088        assert_eq!(response.minimum_order_size, Some(dec!(5)));
1089        assert_eq!(response.minimum_tick_size, Some(dec!(0.01)));
1090        assert_eq!(
1091            response.condition_id,
1092            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1093        );
1094        assert_eq!(
1095            response.question_id.as_deref(),
1096            Some("0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd")
1097        );
1098        assert_eq!(
1099            response.question.as_deref(),
1100            Some("LoL: T1 vs Hanwha Life Esports (BO3) - LCK Round 3-4 Legend Group")
1101        );
1102        assert_eq!(response.description.as_deref(), raw["description"].as_str());
1103        assert_eq!(
1104            response.market_slug.as_deref(),
1105            Some("sanitized-clob-market")
1106        );
1107        assert_eq!(
1108            response.end_date_iso.as_deref(),
1109            Some("2026-08-08T00:00:00Z")
1110        );
1111        assert_eq!(
1112            response.game_start_time.as_deref(),
1113            Some("2026-08-08T08:00:00Z")
1114        );
1115        assert_eq!(response.seconds_delay, Some(1));
1116        assert_eq!(response.fpmm.as_deref(), Some(""));
1117        assert_eq!(response.maker_base_fee, Some(1000));
1118        assert_eq!(response.taker_base_fee, Some(1000));
1119        assert_eq!(response.notifications_enabled, Some(true));
1120        assert_eq!(response.neg_risk, Some(false));
1121        assert_eq!(response.neg_risk_market_id.as_deref(), Some(""));
1122        assert_eq!(response.neg_risk_request_id.as_deref(), Some(""));
1123        assert_eq!(
1124            response.icon.as_deref(),
1125            Some("https://example.com/sanitized-market.png")
1126        );
1127        assert_eq!(
1128            response.image.as_deref(),
1129            Some("https://example.com/sanitized-market.png")
1130        );
1131        let rewards = response.rewards.as_ref().expect("captured rewards");
1132        assert!(rewards.rates.is_none());
1133        assert_eq!(rewards.min_size, Some(dec!(50)));
1134        assert_eq!(rewards.max_spread, Some(dec!(4.5)));
1135        assert_eq!(response.is_50_50_outcome, Some(false));
1136        assert_eq!(response.tokens.len(), 2);
1137        assert_eq!(
1138            response.tokens[0].token_id,
1139            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1140        );
1141        assert_eq!(response.tokens[0].outcome, "T1");
1142        assert_eq!(response.tokens[0].price, Some(dec!(0.715)));
1143        assert!(!response.tokens[0].winner);
1144        assert_eq!(
1145            response.tokens[1].token_id,
1146            "10000000000000000000000000000000000000000000000000000000000000000000000000002"
1147        );
1148        assert_eq!(response.tokens[1].outcome, "Hanwha Life Esports");
1149        assert_eq!(response.tokens[1].price, Some(dec!(0.285)));
1150        assert!(!response.tokens[1].winner);
1151        assert_eq!(
1152            response.tags.as_deref(),
1153            Some(
1154                &[
1155                    "Sports".to_string(),
1156                    "Esports".to_string(),
1157                    "league of legends".to_string(),
1158                    "Games".to_string(),
1159                ][..]
1160            )
1161        );
1162    }
1163
1164    #[rstest]
1165    fn test_clob_market_rewards_documented_rate_fields() {
1166        // Constructed from the documented Rewards schema because the capture has `rates: null`
1167        let json = r#"{
1168            "rates":[{"asset_address":"0x1111111111111111111111111111111111111111","rewards_daily_rate":12.5}],
1169            "min_size":25,
1170            "max_spread":3.5
1171        }"#;
1172        let rewards: ClobMarketRewards = serde_json::from_str(json).unwrap();
1173
1174        let rates = rewards.rates.as_deref().expect("documented reward rate");
1175        assert_eq!(rates.len(), 1);
1176        assert_eq!(
1177            rates[0].asset_address,
1178            "0x1111111111111111111111111111111111111111"
1179        );
1180        assert_eq!(rates[0].rewards_daily_rate, dec!(12.5));
1181        assert_eq!(rewards.min_size, Some(dec!(25)));
1182        assert_eq!(rewards.max_spread, Some(dec!(3.5)));
1183    }
1184
1185    #[rstest]
1186    fn test_clob_market_decimal_fields_preserve_precision() {
1187        let json = r#"{
1188            "condition_id":"0xcondition",
1189            "closed":false,
1190            "minimum_order_size":123456789.1234567890123456789,
1191            "minimum_tick_size":0.1234567890123456789012345678,
1192            "rewards":{
1193                "rates":[{
1194                    "asset_address":"0x1111111111111111111111111111111111111111",
1195                    "rewards_daily_rate":0.1234567890123456789012345678
1196                }],
1197                "min_size":123456789.1234567890123456789,
1198                "max_spread":0.1234567890123456789012345678
1199            },
1200            "tokens":[{
1201                "token_id":"token-1",
1202                "outcome":"Yes",
1203                "price":0.1234567890123456789012345678,
1204                "winner":false
1205            }]
1206        }"#;
1207        let market: ClobMarketResponse = serde_json::from_str(json).unwrap();
1208        let precise = Decimal::from_str_exact("0.1234567890123456789012345678").unwrap();
1209        let large = Decimal::from_str_exact("123456789.1234567890123456789").unwrap();
1210
1211        assert_eq!(market.minimum_order_size, Some(large));
1212        assert_eq!(market.minimum_tick_size, Some(precise));
1213        let rewards = market.rewards.as_ref().unwrap();
1214        assert_eq!(
1215            rewards.rates.as_ref().unwrap()[0].rewards_daily_rate,
1216            precise
1217        );
1218        assert_eq!(rewards.min_size, Some(large));
1219        assert_eq!(rewards.max_spread, Some(precise));
1220        assert_eq!(market.tokens[0].price, Some(precise));
1221        let serialized = serde_json::to_string(&market).unwrap();
1222        assert!(serialized.contains("\"minimum_order_size\":123456789.1234567890123456789"));
1223        assert!(serialized.contains("\"minimum_tick_size\":0.1234567890123456789012345678"));
1224        assert!(serialized.contains("\"rewards_daily_rate\":0.1234567890123456789012345678"));
1225        assert!(serialized.contains("\"min_size\":123456789.1234567890123456789"));
1226        assert!(serialized.contains("\"max_spread\":0.1234567890123456789012345678"));
1227        assert!(serialized.contains("\"price\":0.1234567890123456789012345678"));
1228    }
1229
1230    #[rstest]
1231    fn test_clob_market_response_deserialization_accepting_false() {
1232        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_false.json");
1233        assert_eq!(
1234            response.condition_id,
1235            "0x8ccc3f4951ff02c1d34b87988752b4444ad17228732780a6cf22afefe8478bb6"
1236        );
1237        assert!(response.closed);
1238        assert_eq!(response.tokens.len(), 2);
1239        assert_eq!(response.tokens[0].outcome, "Yes");
1240        assert!(!response.tokens[0].winner);
1241        assert_eq!(response.tokens[1].outcome, "No");
1242        assert!(response.tokens[1].winner);
1243    }
1244
1245    #[rstest]
1246    fn test_clob_market_response_deserialization_accepting_true() {
1247        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_true.json");
1248        assert_eq!(
1249            response.condition_id,
1250            "0xd57eed0d44f5b8ca54925d8d6ff440b146b3e6e071da18136ee3ee572d34479e"
1251        );
1252        assert!(response.closed);
1253        assert_eq!(response.tokens.len(), 2);
1254        assert_eq!(response.tokens[0].outcome, "Yes");
1255        assert!(response.tokens[0].winner);
1256        assert_eq!(response.tokens[1].outcome, "No");
1257        assert!(!response.tokens[1].winner);
1258    }
1259
1260    #[rstest]
1261    fn test_tick_size_response_preserves_json_number() {
1262        let response: TickSizeResponse =
1263            serde_json::from_str(r#"{"minimum_tick_size":0.1234567890123456789012345678}"#)
1264                .unwrap();
1265        let precise =
1266            rust_decimal::Decimal::from_str_exact("0.1234567890123456789012345678").unwrap();
1267
1268        assert_eq!(response.minimum_tick_size, precise);
1269    }
1270
1271    #[rstest]
1272    fn test_fee_rate_response_zero() {
1273        let response: FeeRateResponse = load("clob_fee_rate_response_zero.json");
1274        assert_eq!(response.base_fee, dec!(0));
1275    }
1276
1277    #[rstest]
1278    fn test_fee_rate_response_nonzero() {
1279        let response: FeeRateResponse = load("clob_fee_rate_response_nonzero.json");
1280        assert_eq!(response.base_fee, dec!(150));
1281    }
1282
1283    #[rstest]
1284    fn test_data_api_position_deserialization() {
1285        let positions: Vec<DataApiPosition> = load("data_api_positions_response.json");
1286
1287        assert_eq!(positions.len(), 4);
1288        assert_eq!(
1289            positions[0].asset,
1290            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
1291        );
1292        assert_eq!(
1293            positions[0].condition_id,
1294            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
1295        );
1296        assert_eq!(positions[0].size, dec!(150.5));
1297        assert_eq!(positions[0].avg_price, Some(dec!(0.55)));
1298
1299        // Zero-size position
1300        assert_eq!(positions[1].size, dec!(0));
1301        assert_eq!(positions[1].avg_price, Some(dec!(0.45)));
1302
1303        // Third position
1304        assert_eq!(
1305            positions[2].condition_id,
1306            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
1307        );
1308        assert_eq!(positions[2].size, dec!(42));
1309        assert_eq!(positions[2].avg_price, Some(dec!(0.3)));
1310
1311        // Dust position (below DUST_POSITION_THRESHOLD)
1312        assert_eq!(positions[3].size, dec!(0.005));
1313        assert_eq!(positions[3].avg_price, Some(dec!(0.7)));
1314    }
1315
1316    #[rstest]
1317    fn test_data_api_trade_deserialization() {
1318        let trades: Vec<DataApiTrade> = load("data_api_trades_captured_response.json");
1319
1320        assert_eq!(trades.len(), 3);
1321        assert_eq!(
1322            trades[0].asset,
1323            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1324        );
1325        assert_eq!(
1326            trades[0].condition_id,
1327            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1328        );
1329        assert_eq!(trades[0].side, PolymarketOrderSide::Sell);
1330        assert_eq!(trades[0].price, dec!(0.7));
1331        assert_eq!(trades[0].size, dec!(92.59));
1332        assert_eq!(trades[0].timestamp, 1786179735);
1333        assert_eq!(
1334            trades[0].transaction_hash,
1335            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1336        );
1337
1338        assert_eq!(trades[1].asset, trades[0].asset);
1339        assert_eq!(trades[1].condition_id, trades[0].condition_id);
1340        assert_eq!(trades[1].side, PolymarketOrderSide::Buy);
1341        assert_eq!(trades[1].price, dec!(0.709999959));
1342        assert_eq!(trades[1].size, dec!(1.464786));
1343        assert_eq!(trades[1].timestamp, 1786179730);
1344        assert_eq!(
1345            trades[1].transaction_hash,
1346            "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1347        );
1348        assert_eq!(
1349            trades[2].asset,
1350            "10000000000000000000000000000000000000000000000000000000000000000000000000002"
1351        );
1352        assert_eq!(trades[2].condition_id, trades[0].condition_id);
1353        assert_eq!(trades[2].side, PolymarketOrderSide::Buy);
1354        assert_eq!(trades[2].price, dec!(0.2972581967));
1355        assert_eq!(trades[2].size, dec!(244));
1356        assert_eq!(trades[2].timestamp, 1786179726);
1357        assert_eq!(
1358            trades[2].transaction_hash,
1359            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1360        );
1361
1362        for (trade, outcome, outcome_index) in [
1363            (&trades[0], "T1", 0),
1364            (&trades[1], "T1", 0),
1365            (&trades[2], "Hanwha Life Esports", 1),
1366        ] {
1367            assert_eq!(
1368                trade.proxy_wallet.as_deref(),
1369                Some("0x1111111111111111111111111111111111111111")
1370            );
1371            assert_eq!(
1372                trade.title.as_deref(),
1373                Some("LoL: T1 vs Hanwha Life Esports (BO3) - LCK Round 3-4 Legend Group")
1374            );
1375            assert_eq!(trade.slug.as_deref(), Some("sanitized-market"));
1376            assert_eq!(
1377                trade.icon.as_deref(),
1378                Some("https://example.com/sanitized-market.png")
1379            );
1380            assert_eq!(trade.event_slug.as_deref(), Some("sanitized-event"));
1381            assert_eq!(trade.outcome.as_deref(), Some(outcome));
1382            assert_eq!(trade.outcome_index, Some(outcome_index));
1383            assert_eq!(trade.name.as_deref(), Some("Sanitized trader"));
1384            assert_eq!(trade.pseudonym.as_deref(), Some("sanitized-trader"));
1385            assert_eq!(trade.bio.as_deref(), Some("Sanitized profile"));
1386            assert_eq!(
1387                trade.profile_image.as_deref(),
1388                Some("https://example.com/sanitized-profile.png")
1389            );
1390            assert_eq!(
1391                trade.profile_image_optimized.as_deref(),
1392                Some("https://example.com/sanitized-profile-optimized.png")
1393            );
1394        }
1395    }
1396
1397    #[rstest]
1398    fn test_data_api_trade_decimal_fields_preserve_precision() {
1399        let json = r#"{
1400            "asset":"token-1",
1401            "conditionId":"0xcondition",
1402            "side":"BUY",
1403            "price":0.1234567890123456789012345678,
1404            "size":123456789.1234567890123456789,
1405            "timestamp":1786179735,
1406            "transactionHash":"0xtransaction"
1407        }"#;
1408        let trade: DataApiTrade = serde_json::from_str(json).unwrap();
1409
1410        assert_eq!(
1411            trade.price,
1412            Decimal::from_str_exact("0.1234567890123456789012345678").unwrap()
1413        );
1414        assert_eq!(
1415            trade.size,
1416            Decimal::from_str_exact("123456789.1234567890123456789").unwrap()
1417        );
1418    }
1419}