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}
689
690/// A trade row from the Polymarket Data API v2 `GET /v2/trades` endpoint.
691///
692/// References: <https://docs.polymarket.com/api-reference/data-api/migrating-from-v1>
693#[derive(Clone, Debug, Deserialize)]
694pub struct DataApiTrade {
695    pub proxy_wallet: Option<String>,
696    #[serde(rename = "token_id")]
697    pub asset: String,
698    pub condition_id: String,
699    pub side: PolymarketOrderSide,
700    #[serde(deserialize_with = "deserialize_decimal_from_json_number")]
701    pub price: Decimal,
702    #[serde(deserialize_with = "deserialize_decimal_from_json_number")]
703    pub size: Decimal,
704    pub timestamp: i64,
705    pub title: Option<String>,
706    pub slug: Option<String>,
707    pub icon: Option<String>,
708    pub event_slug: Option<String>,
709    pub outcome: Option<String>,
710    pub outcome_index: Option<i64>,
711    pub name: Option<String>,
712    pub pseudonym: Option<String>,
713    pub bio: Option<String>,
714    pub profile_image: Option<String>,
715    pub profile_image_optimized: Option<String>,
716    pub transaction_hash: String,
717}
718
719/// The `pagination` object shared by every paginated Data API v2 response.
720#[derive(Clone, Debug, Deserialize)]
721pub struct DataApiPagination {
722    /// Exact: `true` iff another page exists, never inferred from page fullness.
723    pub has_more: bool,
724    /// Opaque cursor for the next page; `null` on the last page.
725    pub next_cursor: Option<String>,
726}
727
728/// The `{ data, pagination }` envelope shared by every Data API v2 response.
729///
730/// A documented miss is `data: null` or an empty list, never an error.
731#[derive(Clone, Debug, Deserialize)]
732#[serde(bound = "T: Deserialize<'de>")]
733pub struct DataApiPage<T> {
734    #[serde(
735        default = "Vec::new",
736        deserialize_with = "deserialize_nullable_vec_as_empty"
737    )]
738    pub data: Vec<T>,
739    pub pagination: DataApiPagination,
740}
741
742fn deserialize_nullable_vec_as_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
743where
744    D: serde::Deserializer<'de>,
745    T: Deserialize<'de>,
746{
747    Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
748}
749
750#[cfg(test)]
751mod tests {
752    use rstest::rstest;
753    use rust_decimal_macros::dec;
754
755    use super::*;
756    use crate::common::enums::{
757        PolymarketOrderStatus, PolymarketSignatureType, PolymarketTradeStatus,
758    };
759
760    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
761        let path = format!("test_data/{filename}");
762        let content = std::fs::read_to_string(path).expect("Failed to read test data");
763        serde_json::from_str(&content).expect("Failed to parse test data")
764    }
765
766    #[rstest]
767    #[case::market(include_str!("../../test_data/decimal_precision_market.json"), false)]
768    #[case::event(include_str!("../../test_data/decimal_precision_event.json"), true)]
769    fn test_gamma_financial_fields_round_trip_exactly(#[case] raw: &str, #[case] event: bool) {
770        let encoded = if event {
771            serde_json::to_string(&serde_json::from_str::<GammaEvent>(raw).unwrap()).unwrap()
772        } else {
773            serde_json::to_string(&serde_json::from_str::<GammaMarket>(raw).unwrap()).unwrap()
774        };
775        let expected: std::collections::BTreeMap<String, Box<serde_json::value::RawValue>> =
776            serde_json::from_str(raw).unwrap();
777        let actual: std::collections::BTreeMap<String, Box<serde_json::value::RawValue>> =
778            serde_json::from_str(&encoded).unwrap();
779
780        for (field, value) in expected {
781            if value.get().starts_with(|c: char| c.is_ascii_digit()) {
782                assert_eq!(actual[&field].get(), value.get(), "{field}");
783            }
784        }
785    }
786
787    #[rstest]
788    fn test_data_api_position_preserves_decimal_precision() {
789        let raw = include_str!("../../test_data/decimal_precision_position.json");
790        let position: DataApiPosition = serde_json::from_str(raw).unwrap();
791        assert_eq!(position.asset, "precision-asset");
792        assert_eq!(position.condition_id, "0xprecision");
793        assert_eq!(position.size, dec!(12345678901.123456));
794        assert_eq!(
795            position.avg_price,
796            Some(dec!(0.1234567890123456789012345678))
797        );
798        let strings = raw
799            .replace("12345678901.123456", "\"12345678901.123456\"")
800            .replace(
801                "0.1234567890123456789012345678",
802                "\"0.1234567890123456789012345678\"",
803            );
804        let string_position: DataApiPosition = serde_json::from_str(&strings).unwrap();
805        assert_eq!(string_position.size, position.size);
806        assert_eq!(string_position.avg_price, position.avg_price);
807    }
808
809    #[rstest]
810    fn test_gamma_financial_fields_and_fee_info_preserve_decimal_precision() {
811        let market: GammaMarket = serde_json::from_str(include_str!(
812            "../../test_data/decimal_precision_market.json"
813        ))
814        .unwrap();
815        assert_eq!(market.best_bid, Some(dec!(0.1234567890123456789012345678)));
816        assert_eq!(market.best_ask, Some(dec!(0.2345678901234567890123456789)));
817        assert_eq!(market.liquidity_num, Some(dec!(12345678901.123456)));
818        assert_eq!(market.volume_num, Some(dec!(12345678901.123457)));
819        let fee = market.fee_schedule.unwrap();
820        let info = fee.to_info();
821        assert_eq!(info["exponent"], "1.234567890123456789012345678");
822        assert_eq!(info["rate"], "0.1234567890123456789012345678");
823        assert_eq!(info["rebateRate"], "0.0234567890123456789012345678");
824        assert_eq!(info["takerOnly"], true);
825        let restored: FeeSchedule = serde_json::from_value(info).unwrap();
826        assert_eq!(restored.exponent, fee.exponent);
827        assert_eq!(restored.rate, fee.rate);
828        assert_eq!(restored.rebate_rate, fee.rebate_rate);
829        assert_eq!(restored.taker_only, fee.taker_only);
830    }
831
832    #[rstest]
833    fn test_open_order_live_buy_gtc() {
834        let order: PolymarketOpenOrder = load("http_open_order.json");
835
836        assert_eq!(
837            order.id,
838            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
839        );
840        assert_eq!(order.status, PolymarketOrderStatus::Live);
841        assert_eq!(order.side, PolymarketOrderSide::Buy);
842        assert_eq!(order.order_type, PolymarketOrderType::GTC);
843        assert_eq!(order.outcome, PolymarketOutcome::yes());
844        assert_eq!(order.original_size, dec!(100.0000));
845        assert_eq!(order.price, dec!(0.5000));
846        assert_eq!(order.size_matched, dec!(25.0000));
847        assert_eq!(order.created_at, 1703875200);
848        assert!(order.expiration.is_none());
849        assert_eq!(order.associate_trades, Some(vec!["0xabc001".to_string()]));
850    }
851
852    #[rstest]
853    fn test_open_order_matched_sell_fok() {
854        let order: PolymarketOpenOrder = load("http_open_order_sell_fok.json");
855
856        assert_eq!(order.status, PolymarketOrderStatus::Matched);
857        assert_eq!(order.side, PolymarketOrderSide::Sell);
858        assert_eq!(order.order_type, PolymarketOrderType::FOK);
859        assert_eq!(order.outcome, PolymarketOutcome::no());
860        assert_eq!(order.size_matched, dec!(50.0000));
861        assert_eq!(order.expiration, Some("1735689600".to_string()));
862        assert!(order.associate_trades.is_none());
863    }
864
865    #[rstest]
866    fn test_open_order_roundtrip() {
867        let order: PolymarketOpenOrder = load("http_open_order.json");
868        let json = serde_json::to_string(&order).unwrap();
869        let order2: PolymarketOpenOrder = serde_json::from_str(&json).unwrap();
870        assert_eq!(order, order2);
871    }
872
873    #[rstest]
874    fn test_trade_report_fields() {
875        let trade: PolymarketTradeReport = load("http_trade_report.json");
876
877        assert_eq!(trade.id, "trade-0xabcdef1234");
878        assert_eq!(
879            trade.taker_order_id,
880            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
881        );
882        assert_eq!(trade.side, PolymarketOrderSide::Buy);
883        assert_eq!(trade.size, dec!(25.0000));
884        assert_eq!(trade.fee_rate_bps, dec!(0));
885        assert_eq!(trade.price, dec!(0.5000));
886        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
887        assert_eq!(trade.outcome, PolymarketOutcome::yes());
888        assert_eq!(trade.bucket_index, 0);
889        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
890        assert_eq!(trade.maker_orders.len(), 2);
891    }
892
893    #[rstest]
894    fn test_trade_report_maker_orders() {
895        let trade: PolymarketTradeReport = load("http_trade_report.json");
896
897        let first = &trade.maker_orders[0];
898        assert_eq!(first.matched_amount, dec!(25.0000));
899        assert_eq!(first.price, dec!(0.5000));
900        assert_eq!(first.outcome, PolymarketOutcome::yes());
901
902        let second = &trade.maker_orders[1];
903        assert_eq!(second.matched_amount, dec!(5.0000));
904    }
905
906    #[rstest]
907    fn test_trade_report_roundtrip() {
908        let trade: PolymarketTradeReport = load("http_trade_report.json");
909        let json = serde_json::to_string(&trade).unwrap();
910        let trade2: PolymarketTradeReport = serde_json::from_str(&json).unwrap();
911        assert_eq!(trade, trade2);
912    }
913
914    #[rstest]
915    fn test_signed_order_camel_case_fields() {
916        let order: PolymarketOrder = load("http_signed_order.json");
917        let debug = format!("{order:?}");
918
919        assert_eq!(order.salt, 123456789);
920        assert_eq!(order.maker, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
921        assert_eq!(order.maker_amount, dec!(100000000));
922        assert_eq!(order.taker_amount, dec!(50000000));
923        assert_eq!(order.expiration, "0");
924        assert_eq!(order.timestamp, "1713398400000");
925        assert_eq!(
926            order.metadata,
927            "0x0000000000000000000000000000000000000000000000000000000000000000"
928        );
929        assert_eq!(
930            order.builder,
931            "0x0000000000000000000000000000000000000000000000000000000000000000"
932        );
933        assert_eq!(order.side, PolymarketOrderSide::Buy);
934        assert_eq!(order.signature_type, PolymarketSignatureType::Eoa);
935        assert!(debug.contains(REDACTED));
936        assert!(!debug.contains(order.signature.expose_secret()));
937    }
938
939    #[rstest]
940    fn test_signed_order_roundtrip() {
941        let order: PolymarketOrder = load("http_signed_order.json");
942        let json = serde_json::to_string(&order).unwrap();
943        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
944        assert_eq!(order, order2);
945    }
946
947    #[rstest]
948    fn test_signed_order_serializes_camel_case() {
949        let order: PolymarketOrder = load("http_signed_order.json");
950        let json = serde_json::to_string(&order).unwrap();
951
952        // Verify camelCase field names are present in serialized output
953        assert!(json.contains("\"tokenId\""));
954        assert!(json.contains("\"makerAmount\""));
955        assert!(json.contains("\"takerAmount\""));
956        assert!(json.contains("\"signatureType\""));
957        assert!(json.contains("\"expiration\""));
958        assert!(json.contains("\"timestamp\""));
959        assert!(json.contains("\"metadata\""));
960        assert!(json.contains("\"builder\""));
961    }
962
963    #[rstest]
964    fn test_signed_order_omits_v1_fields() {
965        // V2 dropped `taker`, `nonce`, and `feeRateBps` from the order body.
966        // A regression that re-introduces any of them would silently land V1
967        // shape on a V2 endpoint, so we explicitly assert their absence.
968        let order: PolymarketOrder = load("http_signed_order.json");
969        let json = serde_json::to_string(&order).unwrap();
970
971        assert!(
972            !json.contains("\"taker\""),
973            "wire body must not include `taker`: {json}"
974        );
975        assert!(
976            !json.contains("\"nonce\""),
977            "wire body must not include `nonce`: {json}"
978        );
979        assert!(
980            !json.contains("\"feeRateBps\""),
981            "wire body must not include `feeRateBps`: {json}"
982        );
983    }
984
985    #[rstest]
986    fn test_signed_order_v2_docs_example_roundtrips() {
987        // POST /order body shape from <https://docs.polymarket.com/v2-migration>.
988        // Round-tripping it ensures we accept the exact shape the docs publish.
989        let docs_example = r#"{
990            "salt": 12345,
991            "maker": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
992            "signer": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
993            "tokenId": "102936",
994            "makerAmount": "1000000",
995            "takerAmount": "2000000",
996            "side": "BUY",
997            "signatureType": 1,
998            "expiration": "0",
999            "timestamp": "1713398400000",
1000            "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
1001            "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
1002            "signature": "0xdeadbeef"
1003        }"#;
1004
1005        let order: PolymarketOrder = serde_json::from_str(docs_example).unwrap();
1006        assert_eq!(order.salt, 12345);
1007        assert_eq!(order.token_id.as_str(), "102936");
1008        assert_eq!(order.maker_amount, dec!(1000000));
1009        assert_eq!(order.taker_amount, dec!(2000000));
1010        assert_eq!(order.side, PolymarketOrderSide::Buy);
1011        assert_eq!(order.signature_type, PolymarketSignatureType::PolyProxy);
1012        assert_eq!(order.expiration, "0");
1013        assert_eq!(order.timestamp, "1713398400000");
1014
1015        // Round-trip preserves field semantics.
1016        let json = serde_json::to_string(&order).unwrap();
1017        let order2: PolymarketOrder = serde_json::from_str(&json).unwrap();
1018        assert_eq!(order, order2);
1019    }
1020
1021    #[rstest]
1022    fn test_gamma_event_deserialization() {
1023        let events: Vec<GammaEvent> = load("gamma_event.json");
1024
1025        assert_eq!(events.len(), 1);
1026        let event = &events[0];
1027        assert_eq!(event.id, "30829");
1028        assert_eq!(
1029            event.slug.as_deref(),
1030            Some("democratic-presidential-nominee-2028")
1031        );
1032        assert_eq!(
1033            event.title.as_deref(),
1034            Some("Democratic Presidential Nominee 2028")
1035        );
1036        assert_eq!(event.active, Some(true));
1037        assert_eq!(event.closed, Some(false));
1038        assert_eq!(event.archived, Some(false));
1039        assert_eq!(event.markets.len(), 2);
1040        assert_eq!(
1041            event.markets[0].condition_id,
1042            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
1043        );
1044        assert_eq!(
1045            event.markets[1].condition_id,
1046            "0xe39adea057926dc197fe30a441f57a340b2a232d5a687010f78bba9b6e02620f"
1047        );
1048    }
1049
1050    #[rstest]
1051    fn test_gamma_event_empty_markets() {
1052        let json = r#"[{"id": "evt-002"}]"#;
1053        let events: Vec<GammaEvent> = serde_json::from_str(json).unwrap();
1054
1055        assert_eq!(events.len(), 1);
1056        assert_eq!(events[0].id, "evt-002");
1057        assert!(events[0].markets.is_empty());
1058        assert!(events[0].slug.is_none());
1059    }
1060
1061    #[rstest]
1062    fn test_sports_market_are_weird() {
1063        let money_line: GammaMarket = load("gamma_market_sports_market_money_line.json");
1064        let map_handicap: GammaMarket = load("gamma_market_sports_market_map_handicap.json");
1065
1066        // same event, same slug
1067        assert_eq!(
1068            money_line.events.as_ref().unwrap()[0].game_id,
1069            map_handicap.events.as_ref().unwrap()[0].game_id
1070        );
1071
1072        // one market has no game_id
1073        assert!(map_handicap.game_id.is_none());
1074        assert_eq!(money_line.game_id.as_deref(), Some("1427074"));
1075    }
1076
1077    #[rstest]
1078    fn test_gamma_event_composite_sports_game_id() {
1079        // Live Gamma record from issue #4771: the event carries a numeric
1080        // `gameId` while its first market carries a composite one.
1081        let events: Vec<GammaEvent> = load("gamma_event_sports_composite_game_id.json");
1082
1083        assert_eq!(events.len(), 1);
1084
1085        let event = &events[0];
1086
1087        assert_eq!(event.id, "835109");
1088        assert_eq!(event.game_id.as_deref(), Some("287011684"));
1089        assert_eq!(event.markets.len(), 2);
1090        assert_eq!(event.markets[0].id, "3524358");
1091        assert_eq!(
1092            event.markets[0].game_id.as_deref(),
1093            Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
1094        );
1095        assert_eq!(event.markets[1].id, "3554041");
1096        assert_eq!(event.markets[1].game_id, None);
1097
1098        // Re-serialization feeds the Python loader, so the key stays a string
1099        // even where Gamma sent a number.
1100        let encoded = serde_json::to_value(event).unwrap();
1101
1102        assert_eq!(encoded["gameId"], serde_json::json!("287011684"));
1103        assert_eq!(
1104            encoded["markets"][0]["gameId"],
1105            serde_json::json!("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
1106        );
1107    }
1108
1109    #[rstest]
1110    fn test_fee_schedule_decimal_fields() {
1111        let market: GammaMarket = load("gamma_market_sports_market_money_line.json");
1112        let schedule = market.fee_schedule.unwrap();
1113
1114        assert_eq!(schedule.exponent, Decimal::ONE);
1115        assert_eq!(schedule.rate, dec!(0.03));
1116        assert!(schedule.taker_only);
1117        assert_eq!(schedule.rebate_rate, dec!(0.25));
1118    }
1119
1120    #[rstest]
1121    fn test_gamma_market_crypto_market_config_fields() {
1122        let market: GammaMarket = load("gamma_market_crypto_twap.json");
1123        let config = market.crypto_market_config.as_ref().unwrap();
1124
1125        assert_eq!(config.id, "btc-5m-twap-60");
1126        assert_eq!(config.asset, "btc");
1127        assert_eq!(config.duration, "5m");
1128        assert!(config.twap_enabled);
1129        assert_eq!(config.twap_lookback_seconds, Some(60));
1130        assert_eq!(
1131            market.resolution_source.as_deref(),
1132            Some("https://data.chain.link/streams/btc-usd-twap-60s-streams")
1133        );
1134        assert_eq!(
1135            market.event_start_time.as_deref(),
1136            Some("2026-08-22T16:00:00Z")
1137        );
1138    }
1139
1140    #[rstest]
1141    fn test_crypto_market_config_absent_twap_lookback_serializes_omitted() {
1142        let crypto_market_config = serde_json::json!({
1143            "id": "btc-5m",
1144            "asset": "btc",
1145            "duration": "5m",
1146            "twapEnabled": false,
1147        });
1148
1149        let config: CryptoMarketConfig = serde_json::from_value(crypto_market_config).unwrap();
1150        let encoded = serde_json::to_value(config).unwrap();
1151
1152        assert!(encoded.get("twapLookbackSeconds").is_none());
1153    }
1154
1155    #[rstest]
1156    fn test_crypto_market_config_rejects_null_twap_lookback() {
1157        let crypto_market_config = serde_json::json!({
1158            "id": "btc-5m",
1159            "asset": "btc",
1160            "duration": "5m",
1161            "twapEnabled": false,
1162            "twapLookbackSeconds": null,
1163        });
1164
1165        let result = serde_json::from_value::<CryptoMarketConfig>(crypto_market_config);
1166
1167        assert!(result.is_err());
1168    }
1169
1170    #[rstest]
1171    #[case(serde_json::json!(-37))]
1172    #[case(serde_json::json!(i64::MIN))]
1173    #[case(serde_json::json!(i64::MAX))]
1174    fn test_crypto_market_config_signed_twap_lookback_roundtrip(
1175        #[case] twap_lookback_seconds: serde_json::Value,
1176    ) {
1177        let crypto_market_config = serde_json::json!({
1178            "id": "eth-15m",
1179            "asset": "eth",
1180            "duration": "15m",
1181            "twapEnabled": true,
1182            "twapLookbackSeconds": twap_lookback_seconds,
1183        });
1184
1185        let config: CryptoMarketConfig = serde_json::from_value(crypto_market_config).unwrap();
1186        let encoded = serde_json::to_value(config).unwrap();
1187
1188        assert_eq!(encoded["twapLookbackSeconds"], twap_lookback_seconds);
1189    }
1190
1191    #[rstest]
1192    fn test_crypto_market_config_rejects_twap_lookback_above_i64_max() {
1193        let crypto_market_config = serde_json::json!({
1194            "id": "eth-15m",
1195            "asset": "eth",
1196            "duration": "15m",
1197            "twapEnabled": true,
1198            "twapLookbackSeconds": 9_223_372_036_854_775_808u64,
1199        });
1200
1201        let result = serde_json::from_value::<CryptoMarketConfig>(crypto_market_config);
1202
1203        assert!(result.is_err());
1204    }
1205
1206    #[rstest]
1207    fn test_gamma_market_enriched_fields() {
1208        let market: GammaMarket = load("gamma_market.json");
1209
1210        assert_eq!(
1211            market.event_start_time.as_deref(),
1212            Some("2026-03-12T09:20:00Z")
1213        );
1214        assert_eq!(market.best_bid, Some(dec!(0.5)));
1215        assert_eq!(market.best_ask, Some(dec!(0.51)));
1216        assert_eq!(market.spread, Some(dec!(0.009)));
1217        assert_eq!(market.last_trade_price, Some(dec!(0.51)));
1218        assert!(market.one_day_price_change.is_none());
1219        assert!(market.one_week_price_change.is_none());
1220        assert_eq!(market.volume_1wk, Some(dec!(9.999997)));
1221        assert_eq!(market.volume_1mo, Some(dec!(9.999997)));
1222        assert_eq!(market.volume_1yr, Some(dec!(9.999997)));
1223        assert_eq!(market.rewards_min_size, Some(dec!(50.0)));
1224        assert_eq!(market.rewards_max_spread, Some(dec!(4.5)));
1225        assert_eq!(market.competitive, Some(0.9999750006249843));
1226        assert!(market.category.is_none());
1227        assert!(market.neg_risk_market_id.is_none());
1228        assert!(market.uma_resolution_status.is_none());
1229        assert_eq!(market.uma_resolution_statuses.as_deref(), Some("[]"));
1230        assert_eq!(
1231            market.outcome_prices.as_deref(),
1232            Some("[\"0.505\", \"0.495\"]")
1233        );
1234    }
1235
1236    #[rstest]
1237    fn test_gamma_market_uma_resolution_statuses() {
1238        let market: GammaMarket = load("gamma_market.json");
1239
1240        assert!(market.uma_resolution_status.is_none());
1241        assert_eq!(market.uma_resolution_statuses.as_deref(), Some("[]"));
1242    }
1243
1244    #[rstest]
1245    fn test_gamma_market_enriched_fields_default_to_none() {
1246        // Minimal market JSON: only required fields
1247        let json = r#"{"id": "m1", "conditionId": "0xcond", "clobTokenIds": "[]", "outcomes": "[]", "question": "Q?"}"#;
1248        let market: GammaMarket = serde_json::from_str(json).unwrap();
1249
1250        assert!(market.best_bid.is_none());
1251        assert!(market.spread.is_none());
1252        assert!(market.volume_1wk.is_none());
1253        assert!(market.rewards_min_size.is_none());
1254        assert!(market.competitive.is_none());
1255        assert!(market.category.is_none());
1256        assert!(market.neg_risk_market_id.is_none());
1257        assert!(market.crypto_market_config.is_none());
1258        assert!(market.event_start_time.is_none());
1259    }
1260
1261    #[rstest]
1262    fn test_gamma_event_enriched_fields() {
1263        let events: Vec<GammaEvent> = load("gamma_event.json");
1264        let event = &events[0];
1265
1266        assert_eq!(event.liquidity, Some(dec!(43042905.16152)));
1267        assert_eq!(event.volume, Some(dec!(799823812.487094)));
1268        assert_eq!(event.open_interest, Some(dec!(0.0)));
1269        assert_eq!(event.volume_24hr, Some(dec!(5669354.219446001)));
1270        assert!(event.category.is_none());
1271        assert_eq!(event.neg_risk, Some(true));
1272        assert_eq!(
1273            event.neg_risk_market_id.as_deref(),
1274            Some("0x2c3d7e0eee6f058be3006baabf0d54a07da254ba47fe6e3e095e7990c7814700")
1275        );
1276        assert_eq!(event.featured, Some(false));
1277    }
1278
1279    #[rstest]
1280    fn test_gamma_tag_deserialization() {
1281        let tags: Vec<GammaTag> = load("gamma_tags.json");
1282
1283        assert_eq!(tags.len(), 5);
1284        assert_eq!(tags[0].id, "101259");
1285        assert_eq!(tags[0].label.as_deref(), Some("Health and Human Services"));
1286        assert_eq!(tags[0].slug.as_deref(), Some("health-and-human-services"));
1287        assert_eq!(tags[2].slug.as_deref(), Some("attorney-general"));
1288    }
1289
1290    #[rstest]
1291    fn test_search_response_deserialization() {
1292        let response: SearchResponse = load("search_response.json");
1293
1294        // Real API returns no top-level "markets" key
1295        assert!(response.markets.is_none());
1296
1297        let events = response.events.as_ref().unwrap();
1298        assert_eq!(events.len(), 1);
1299        assert_eq!(events[0].slug.as_deref(), Some("bitcoin-above-on-march-11"));
1300        assert_eq!(events[0].markets.len(), 1);
1301    }
1302
1303    #[rstest]
1304    fn test_search_response_empty_fields() {
1305        let json = "{}";
1306        let response: SearchResponse = serde_json::from_str(json).unwrap();
1307        assert!(response.markets.is_none());
1308        assert!(response.events.is_none());
1309    }
1310
1311    #[rstest]
1312    fn test_clob_book_response_deserialization() {
1313        let response: ClobBookResponse = load("clob_book_response.json");
1314
1315        assert_eq!(response.bids.len(), 3);
1316        assert_eq!(response.asks.len(), 3);
1317
1318        assert_eq!(response.bids[0].price, "0.48");
1319        assert_eq!(response.bids[0].size, "100.00");
1320        assert_eq!(response.bids[2].price, "0.50");
1321        assert_eq!(response.bids[2].size, "150.00");
1322
1323        assert_eq!(response.asks[0].price, "0.51");
1324        assert_eq!(response.asks[0].size, "120.00");
1325        assert_eq!(response.asks[2].price, "0.53");
1326        assert_eq!(response.asks[2].size, "90.00");
1327    }
1328
1329    #[rstest]
1330    fn test_clob_book_response_ignores_extra_fields() {
1331        // Verify serde silently ignores fields from both V1 and V2 `/book`
1332        // responses. The live V2 endpoint adds `tick_size`, `min_order_size`,
1333        // `neg_risk`, and `last_trade_price` on top of the V1 fields; pinning
1334        // them here catches a future `#[serde(deny_unknown_fields)]` regression
1335        // before it breaks production parsing.
1336        let json = r#"{
1337            "market": "0xabc",
1338            "asset_id": "123",
1339            "hash": "0x1",
1340            "timestamp": "123",
1341            "bids": [],
1342            "asks": [],
1343            "tick_size": "0.01",
1344            "min_order_size": "5",
1345            "neg_risk": false,
1346            "last_trade_price": "0.55"
1347        }"#;
1348        let response: ClobBookResponse = serde_json::from_str(json).unwrap();
1349        assert!(response.bids.is_empty());
1350        assert!(response.asks.is_empty());
1351    }
1352
1353    #[rstest]
1354    fn test_clob_market_response_captured_fields() {
1355        let response: ClobMarketResponse = load("clob_market_response.json");
1356        let raw: serde_json::Value = load("clob_market_response.json");
1357
1358        assert_eq!(response.enable_order_book, Some(true));
1359        assert_eq!(response.active, Some(true));
1360        assert!(!response.closed);
1361        assert_eq!(response.archived, Some(false));
1362        assert_eq!(response.accepting_orders, Some(true));
1363        assert_eq!(
1364            response.accepting_order_timestamp.as_deref(),
1365            Some("2026-08-01T22:56:49Z")
1366        );
1367        assert_eq!(response.minimum_order_size, Some(dec!(5)));
1368        assert_eq!(response.minimum_tick_size, Some(dec!(0.01)));
1369        assert_eq!(
1370            response.condition_id,
1371            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1372        );
1373        assert_eq!(
1374            response.question_id.as_deref(),
1375            Some("0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd")
1376        );
1377        assert_eq!(
1378            response.question.as_deref(),
1379            Some("LoL: T1 vs Hanwha Life Esports (BO3) - LCK Round 3-4 Legend Group")
1380        );
1381        assert_eq!(response.description.as_deref(), raw["description"].as_str());
1382        assert_eq!(
1383            response.market_slug.as_deref(),
1384            Some("sanitized-clob-market")
1385        );
1386        assert_eq!(
1387            response.end_date_iso.as_deref(),
1388            Some("2026-08-08T00:00:00Z")
1389        );
1390        assert_eq!(
1391            response.game_start_time.as_deref(),
1392            Some("2026-08-08T08:00:00Z")
1393        );
1394        assert_eq!(response.seconds_delay, Some(1));
1395        assert_eq!(response.fpmm.as_deref(), Some(""));
1396        assert_eq!(response.maker_base_fee, Some(1000));
1397        assert_eq!(response.taker_base_fee, Some(1000));
1398        assert_eq!(response.notifications_enabled, Some(true));
1399        assert_eq!(response.neg_risk, Some(false));
1400        assert_eq!(response.neg_risk_market_id.as_deref(), Some(""));
1401        assert_eq!(response.neg_risk_request_id.as_deref(), Some(""));
1402        assert_eq!(
1403            response.icon.as_deref(),
1404            Some("https://example.com/sanitized-market.png")
1405        );
1406        assert_eq!(
1407            response.image.as_deref(),
1408            Some("https://example.com/sanitized-market.png")
1409        );
1410        let rewards = response.rewards.as_ref().expect("captured rewards");
1411        assert!(rewards.rates.is_none());
1412        assert_eq!(rewards.min_size, Some(dec!(50)));
1413        assert_eq!(rewards.max_spread, Some(dec!(4.5)));
1414        assert_eq!(response.is_50_50_outcome, Some(false));
1415        assert_eq!(response.tokens.len(), 2);
1416        assert_eq!(
1417            response.tokens[0].token_id,
1418            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1419        );
1420        assert_eq!(response.tokens[0].outcome, "T1");
1421        assert_eq!(response.tokens[0].price, Some(dec!(0.715)));
1422        assert!(!response.tokens[0].winner);
1423        assert_eq!(
1424            response.tokens[1].token_id,
1425            "10000000000000000000000000000000000000000000000000000000000000000000000000002"
1426        );
1427        assert_eq!(response.tokens[1].outcome, "Hanwha Life Esports");
1428        assert_eq!(response.tokens[1].price, Some(dec!(0.285)));
1429        assert!(!response.tokens[1].winner);
1430        assert_eq!(
1431            response.tags.as_deref(),
1432            Some(
1433                &[
1434                    "Sports".to_string(),
1435                    "Esports".to_string(),
1436                    "league of legends".to_string(),
1437                    "Games".to_string(),
1438                ][..]
1439            )
1440        );
1441    }
1442
1443    #[rstest]
1444    fn test_clob_market_rewards_documented_rate_fields() {
1445        // Constructed from the documented Rewards schema because the capture has `rates: null`
1446        let json = r#"{
1447            "rates":[{"asset_address":"0x1111111111111111111111111111111111111111","rewards_daily_rate":12.5}],
1448            "min_size":25,
1449            "max_spread":3.5
1450        }"#;
1451        let rewards: ClobMarketRewards = serde_json::from_str(json).unwrap();
1452
1453        let rates = rewards.rates.as_deref().expect("documented reward rate");
1454        assert_eq!(rates.len(), 1);
1455        assert_eq!(
1456            rates[0].asset_address,
1457            "0x1111111111111111111111111111111111111111"
1458        );
1459        assert_eq!(rates[0].rewards_daily_rate, dec!(12.5));
1460        assert_eq!(rewards.min_size, Some(dec!(25)));
1461        assert_eq!(rewards.max_spread, Some(dec!(3.5)));
1462    }
1463
1464    #[rstest]
1465    fn test_clob_market_decimal_fields_preserve_precision() {
1466        let json = r#"{
1467            "condition_id":"0xcondition",
1468            "closed":false,
1469            "minimum_order_size":123456789.1234567890123456789,
1470            "minimum_tick_size":0.1234567890123456789012345678,
1471            "rewards":{
1472                "rates":[{
1473                    "asset_address":"0x1111111111111111111111111111111111111111",
1474                    "rewards_daily_rate":0.1234567890123456789012345678
1475                }],
1476                "min_size":123456789.1234567890123456789,
1477                "max_spread":0.1234567890123456789012345678
1478            },
1479            "tokens":[{
1480                "token_id":"token-1",
1481                "outcome":"Yes",
1482                "price":0.1234567890123456789012345678,
1483                "winner":false
1484            }]
1485        }"#;
1486        let market: ClobMarketResponse = serde_json::from_str(json).unwrap();
1487        let precise = Decimal::from_str_exact("0.1234567890123456789012345678").unwrap();
1488        let large = Decimal::from_str_exact("123456789.1234567890123456789").unwrap();
1489
1490        assert_eq!(market.minimum_order_size, Some(large));
1491        assert_eq!(market.minimum_tick_size, Some(precise));
1492        let rewards = market.rewards.as_ref().unwrap();
1493        assert_eq!(
1494            rewards.rates.as_ref().unwrap()[0].rewards_daily_rate,
1495            precise
1496        );
1497        assert_eq!(rewards.min_size, Some(large));
1498        assert_eq!(rewards.max_spread, Some(precise));
1499        assert_eq!(market.tokens[0].price, Some(precise));
1500        let serialized = serde_json::to_string(&market).unwrap();
1501        assert!(serialized.contains("\"minimum_order_size\":123456789.1234567890123456789"));
1502        assert!(serialized.contains("\"minimum_tick_size\":0.1234567890123456789012345678"));
1503        assert!(serialized.contains("\"rewards_daily_rate\":0.1234567890123456789012345678"));
1504        assert!(serialized.contains("\"min_size\":123456789.1234567890123456789"));
1505        assert!(serialized.contains("\"max_spread\":0.1234567890123456789012345678"));
1506        assert!(serialized.contains("\"price\":0.1234567890123456789012345678"));
1507    }
1508
1509    #[rstest]
1510    fn test_clob_market_response_deserialization_accepting_false() {
1511        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_false.json");
1512        assert_eq!(
1513            response.condition_id,
1514            "0x8ccc3f4951ff02c1d34b87988752b4444ad17228732780a6cf22afefe8478bb6"
1515        );
1516        assert!(response.closed);
1517        assert_eq!(response.tokens.len(), 2);
1518        assert_eq!(response.tokens[0].outcome, "Yes");
1519        assert!(!response.tokens[0].winner);
1520        assert_eq!(response.tokens[1].outcome, "No");
1521        assert!(response.tokens[1].winner);
1522    }
1523
1524    #[rstest]
1525    fn test_clob_market_response_deserialization_accepting_true() {
1526        let response: ClobMarketResponse = load("clob_market_closed_binary_accepting_true.json");
1527        assert_eq!(
1528            response.condition_id,
1529            "0xd57eed0d44f5b8ca54925d8d6ff440b146b3e6e071da18136ee3ee572d34479e"
1530        );
1531        assert!(response.closed);
1532        assert_eq!(response.tokens.len(), 2);
1533        assert_eq!(response.tokens[0].outcome, "Yes");
1534        assert!(response.tokens[0].winner);
1535        assert_eq!(response.tokens[1].outcome, "No");
1536        assert!(!response.tokens[1].winner);
1537    }
1538
1539    #[rstest]
1540    fn test_tick_size_response_preserves_json_number() {
1541        let response: TickSizeResponse =
1542            serde_json::from_str(r#"{"minimum_tick_size":0.1234567890123456789012345678}"#)
1543                .unwrap();
1544        let precise =
1545            rust_decimal::Decimal::from_str_exact("0.1234567890123456789012345678").unwrap();
1546
1547        assert_eq!(response.minimum_tick_size, precise);
1548    }
1549
1550    #[rstest]
1551    fn test_fee_rate_response_zero() {
1552        let response: FeeRateResponse = load("clob_fee_rate_response_zero.json");
1553        assert_eq!(response.base_fee, dec!(0));
1554    }
1555
1556    #[rstest]
1557    fn test_fee_rate_response_nonzero() {
1558        let response: FeeRateResponse = load("clob_fee_rate_response_nonzero.json");
1559        assert_eq!(response.base_fee, dec!(150));
1560    }
1561
1562    #[rstest]
1563    fn test_data_api_position_deserialization() {
1564        let positions: Vec<DataApiPosition> =
1565            load::<DataApiPage<DataApiPosition>>("data_api_positions_response.json").data;
1566
1567        assert_eq!(positions.len(), 4);
1568        assert_eq!(
1569            positions[0].asset,
1570            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
1571        );
1572        assert_eq!(
1573            positions[0].condition_id,
1574            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
1575        );
1576        assert_eq!(positions[0].size, dec!(150.5));
1577        assert_eq!(positions[0].avg_price, Some(dec!(0.55)));
1578
1579        // Zero-size position
1580        assert_eq!(positions[1].size, dec!(0));
1581        assert_eq!(positions[1].avg_price, Some(dec!(0.45)));
1582
1583        // Third position
1584        assert_eq!(
1585            positions[2].condition_id,
1586            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
1587        );
1588        assert_eq!(positions[2].size, dec!(42));
1589        assert_eq!(positions[2].avg_price, Some(dec!(0.3)));
1590
1591        // Dust position (below DUST_POSITION_THRESHOLD)
1592        assert_eq!(positions[3].size, dec!(0.005));
1593        assert_eq!(positions[3].avg_price, Some(dec!(0.7)));
1594    }
1595
1596    #[rstest]
1597    fn test_data_api_page_deserializes_null_and_missing_data_as_empty() {
1598        // A documented miss is `data: null` or an empty list, never an error.
1599        let null_data = r#"{
1600            "data": null,
1601            "pagination": {"limit": 500, "offset": 0, "has_more": false, "next_cursor": null}
1602        }"#;
1603        let page: DataApiPage<DataApiTrade> = serde_json::from_str(null_data).unwrap();
1604        assert!(page.data.is_empty());
1605        assert!(!page.pagination.has_more);
1606        assert!(page.pagination.next_cursor.is_none());
1607
1608        let missing_data = r#"{
1609            "pagination": {"limit": 500, "offset": 0, "has_more": false, "next_cursor": null}
1610        }"#;
1611        let page: DataApiPage<DataApiTrade> = serde_json::from_str(missing_data).unwrap();
1612        assert!(page.data.is_empty());
1613    }
1614
1615    #[rstest]
1616    fn test_data_api_trade_deserialization() {
1617        let trades: Vec<DataApiTrade> =
1618            load::<DataApiPage<DataApiTrade>>("data_api_trades_captured_response.json").data;
1619
1620        assert_eq!(trades.len(), 3);
1621        assert_eq!(
1622            trades[0].asset,
1623            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1624        );
1625        assert_eq!(
1626            trades[0].condition_id,
1627            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1628        );
1629        assert_eq!(trades[0].side, PolymarketOrderSide::Sell);
1630        assert_eq!(trades[0].price, dec!(0.7));
1631        assert_eq!(trades[0].size, dec!(92.59));
1632        assert_eq!(trades[0].timestamp, 1786179735);
1633        assert_eq!(
1634            trades[0].transaction_hash,
1635            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1636        );
1637
1638        assert_eq!(trades[1].asset, trades[0].asset);
1639        assert_eq!(trades[1].condition_id, trades[0].condition_id);
1640        assert_eq!(trades[1].side, PolymarketOrderSide::Buy);
1641        assert_eq!(trades[1].price, dec!(0.709999959));
1642        assert_eq!(trades[1].size, dec!(1.464786));
1643        assert_eq!(trades[1].timestamp, 1786179730);
1644        assert_eq!(
1645            trades[1].transaction_hash,
1646            "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1647        );
1648        assert_eq!(
1649            trades[2].asset,
1650            "10000000000000000000000000000000000000000000000000000000000000000000000000002"
1651        );
1652        assert_eq!(trades[2].condition_id, trades[0].condition_id);
1653        assert_eq!(trades[2].side, PolymarketOrderSide::Buy);
1654        assert_eq!(trades[2].price, dec!(0.2972581967));
1655        assert_eq!(trades[2].size, dec!(244));
1656        assert_eq!(trades[2].timestamp, 1786179726);
1657        assert_eq!(
1658            trades[2].transaction_hash,
1659            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1660        );
1661
1662        for (trade, outcome, outcome_index) in [
1663            (&trades[0], "T1", 0),
1664            (&trades[1], "T1", 0),
1665            (&trades[2], "Hanwha Life Esports", 1),
1666        ] {
1667            assert_eq!(
1668                trade.proxy_wallet.as_deref(),
1669                Some("0x1111111111111111111111111111111111111111")
1670            );
1671            assert_eq!(
1672                trade.title.as_deref(),
1673                Some("LoL: T1 vs Hanwha Life Esports (BO3) - LCK Round 3-4 Legend Group")
1674            );
1675            assert_eq!(trade.slug.as_deref(), Some("sanitized-market"));
1676            assert_eq!(
1677                trade.icon.as_deref(),
1678                Some("https://example.com/sanitized-market.png")
1679            );
1680            assert_eq!(trade.event_slug.as_deref(), Some("sanitized-event"));
1681            assert_eq!(trade.outcome.as_deref(), Some(outcome));
1682            assert_eq!(trade.outcome_index, Some(outcome_index));
1683            assert_eq!(trade.name.as_deref(), Some("Sanitized trader"));
1684            assert_eq!(trade.pseudonym.as_deref(), Some("sanitized-trader"));
1685            assert_eq!(trade.bio.as_deref(), Some("Sanitized profile"));
1686            assert_eq!(
1687                trade.profile_image.as_deref(),
1688                Some("https://example.com/sanitized-profile.png")
1689            );
1690            assert_eq!(
1691                trade.profile_image_optimized.as_deref(),
1692                Some("https://example.com/sanitized-profile-optimized.png")
1693            );
1694        }
1695    }
1696
1697    #[rstest]
1698    fn test_data_api_trade_decimal_fields_preserve_precision() {
1699        let json = r#"{
1700            "token_id":"token-1",
1701            "condition_id":"0xcondition",
1702            "side":"BUY",
1703            "price":0.1234567890123456789012345678,
1704            "size":123456789.1234567890123456789,
1705            "timestamp":1786179735,
1706            "transaction_hash":"0xtransaction"
1707        }"#;
1708        let trade: DataApiTrade = serde_json::from_str(json).unwrap();
1709
1710        assert_eq!(
1711            trade.price,
1712            Decimal::from_str_exact("0.1234567890123456789012345678").unwrap()
1713        );
1714        assert_eq!(
1715            trade.size,
1716            Decimal::from_str_exact("123456789.1234567890123456789").unwrap()
1717        );
1718    }
1719
1720    #[rstest]
1721    fn test_gamma_market_fee_fields() {
1722        let market: GammaMarket = load("gamma_market.json");
1723
1724        assert_eq!(market.fees_enabled, Some(true));
1725        assert_eq!(market.fee_type.as_deref(), Some("crypto_fees"));
1726        assert!(market.tags.is_none());
1727        assert!(market.sports_market_type.is_none());
1728    }
1729
1730    #[rstest]
1731    fn test_gamma_market_sports_fee_fields() {
1732        let market: GammaMarket = load("gamma_market_sports_market_money_line.json");
1733
1734        assert_eq!(market.fees_enabled, Some(true));
1735        assert_eq!(market.fee_type.as_deref(), Some("sports_fees_v2"));
1736    }
1737
1738    #[rstest]
1739    fn test_gamma_event_tags() {
1740        let events: Vec<GammaEvent> = load("gamma_event_sports_composite_game_id.json");
1741        let tags = events[0].tags.as_ref().unwrap();
1742
1743        assert!(tags.iter().any(|tag| tag.slug.as_deref() == Some("sports")));
1744    }
1745
1746    #[rstest]
1747    fn test_fee_rate_response_to_rate() {
1748        let zero: FeeRateResponse = load("clob_fee_rate_response_zero.json");
1749        let nonzero: FeeRateResponse = load("clob_fee_rate_response_nonzero.json");
1750        let numeric: FeeRateResponse = serde_json::from_str(r#"{"base_fee":700}"#).unwrap();
1751
1752        assert_eq!(zero.to_rate(), Decimal::ZERO);
1753        assert_eq!(nonzero.to_rate(), dec!(0.015));
1754        assert_eq!(numeric.to_rate(), dec!(0.07));
1755    }
1756}