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