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