Skip to main content

nautilus_polymarket/http/
query.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 query and response model types for the Polymarket CLOB API.
17
18use std::collections::{HashMap, HashSet};
19
20use ahash::{AHashMap, AHashSet};
21use alloy_primitives::Address;
22use derive_builder::Builder;
23use jiff::{Timestamp, civil::Date, tz::Offset};
24use rust_decimal::Decimal;
25use serde::{
26    Deserialize, Deserializer, Serialize,
27    de::{Error, IgnoredAny, MapAccess, Visitor},
28};
29
30use crate::{
31    common::{
32        enums::{PolymarketOrderType, SignatureType},
33        parse::{deserialize_decimal_from_str, deserialize_optional_decimal_from_str},
34    },
35    http::models::PolymarketOrder,
36};
37
38/// Query parameters for `GET /data/orders`.
39#[derive(Clone, Debug, Default, Serialize, Builder)]
40#[builder(setter(into, strip_option), default)]
41pub struct GetOrdersParams {
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub id: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub market: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub asset_id: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub next_cursor: Option<String>,
50}
51
52/// Query parameters for `GET /data/trades`.
53#[derive(Clone, Debug, Default, Serialize, Builder)]
54#[builder(setter(into, strip_option), default)]
55pub struct GetTradesParams {
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub id: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub maker_address: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub market: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub asset_id: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub before: Option<u64>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub after: Option<u64>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub next_cursor: Option<String>,
70}
71
72/// Query parameters for `GET /balance-allowance` and `GET /balance-allowance/update`.
73#[derive(Clone, Debug, Default, Serialize, Builder)]
74#[builder(setter(into, strip_option), default)]
75pub struct GetBalanceAllowanceParams {
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub asset_type: Option<AssetType>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub token_id: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub signature_type: Option<SignatureType>,
82}
83
84/// Body parameters for `DELETE /cancel-market-orders`.
85#[derive(Clone, Debug, Default, Serialize, Builder)]
86#[builder(setter(into, strip_option), default)]
87pub struct CancelMarketOrdersParams {
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub market: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub asset_id: Option<String>,
92}
93
94/// Asset type for balance and allowance requests.
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
97pub enum AssetType {
98    Collateral,
99    Conditional,
100}
101
102/// Strict balance and allowance response for callers that require allowance evidence from
103/// `GET /balance-allowance`.
104///
105/// The plural [`Self::allowances`] map is the sole allowance authority. The legacy singular
106/// [`Self::allowance`] field remains public for source compatibility, but non-null wire values are
107/// rejected. Internal adapter balance-only consumers do not use this type.
108#[derive(Clone, Debug, Deserialize)]
109pub struct BalanceAllowance {
110    #[serde(deserialize_with = "deserialize_decimal_from_str")]
111    pub balance: Decimal,
112    /// Legacy singular field retained for Rust source compatibility.
113    ///
114    /// Deserialization accepts only an absent or null value; use [`Self::allowances`] for evidence.
115    #[serde(default, deserialize_with = "deserialize_rejected_legacy_allowance")]
116    pub allowance: Option<Decimal>,
117    #[serde(deserialize_with = "deserialize_spender_allowances")]
118    pub allowances: HashMap<String, String>,
119}
120
121fn deserialize_rejected_legacy_allowance<'de, D>(
122    deserializer: D,
123) -> Result<Option<Decimal>, D::Error>
124where
125    D: Deserializer<'de>,
126{
127    match Option::<IgnoredAny>::deserialize(deserializer)? {
128        None => Ok(None),
129        Some(_) => Err(D::Error::custom(
130            "legacy singular `allowance` is not accepted; use plural `allowances` evidence",
131        )),
132    }
133}
134
135struct CanonicalSpenderKey {
136    raw: String,
137    address: Address,
138}
139
140impl<'de> Deserialize<'de> for CanonicalSpenderKey {
141    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
142    where
143        D: Deserializer<'de>,
144    {
145        let raw = String::deserialize(deserializer)?;
146        let invalid_spender = format!("invalid spender `{raw}` in allowance evidence");
147        let address = raw
148            .parse::<Address>()
149            .map_err(|_| D::Error::custom(&invalid_spender))?;
150        let is_canonical = [format!("{address:#x}"), address.to_checksum(None)]
151            .into_iter()
152            .any(|candidate| candidate == raw);
153
154        is_canonical
155            .then_some(Self { raw, address })
156            .ok_or_else(|| D::Error::custom(invalid_spender))
157    }
158}
159
160fn deserialize_spender_allowances<'de, D>(
161    deserializer: D,
162) -> Result<HashMap<String, String>, D::Error>
163where
164    D: Deserializer<'de>,
165{
166    struct SpenderAllowancesVisitor;
167
168    impl<'de> Visitor<'de> for SpenderAllowancesVisitor {
169        type Value = HashMap<String, String>;
170
171        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172            formatter.write_str("a spender-to-allowance map without duplicate spenders")
173        }
174
175        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
176        where
177            A: MapAccess<'de>,
178        {
179            let mut allowances = HashMap::new();
180            let mut seen_spenders = HashSet::new();
181            while let Some(spender) = map.next_key::<CanonicalSpenderKey>()? {
182                if !seen_spenders.insert(spender.address) {
183                    return Err(A::Error::custom(format!(
184                        "duplicate spender `{}` in allowance evidence",
185                        spender.raw,
186                    )));
187                }
188                let allowance = map.next_value::<String>()?;
189                allowances.insert(spender.raw, allowance);
190            }
191            Ok(allowances)
192        }
193    }
194
195    deserializer.deserialize_map(SpenderAllowancesVisitor)
196}
197
198/// CLOB protocol version response from `GET /version`.
199#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
200pub struct ClobVersionResponse {
201    pub version: u8,
202}
203
204/// Status returned after an order submission is processed.
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
206pub enum OrderResponseStatus {
207    Live,
208    Matched,
209    Delayed,
210    Unmatched,
211}
212
213/// Order submission response from `POST /order` and `POST /orders`.
214#[derive(Clone, Debug, Deserialize)]
215pub struct OrderResponse {
216    pub success: bool,
217    #[serde(rename = "orderID")]
218    pub order_id: Option<String>,
219    #[serde(
220        default,
221        deserialize_with = "deserialize_optional_order_response_status"
222    )]
223    pub status: Option<OrderResponseStatus>,
224    #[serde(
225        default,
226        rename = "makingAmount",
227        deserialize_with = "deserialize_optional_decimal_from_str"
228    )]
229    pub making_amount: Option<Decimal>,
230    #[serde(
231        default,
232        rename = "takingAmount",
233        deserialize_with = "deserialize_optional_decimal_from_str"
234    )]
235    pub taking_amount: Option<Decimal>,
236    #[serde(rename = "transactionsHashes")]
237    pub transaction_hashes: Option<Vec<String>>,
238    #[serde(rename = "tradeIDs")]
239    pub trade_ids: Option<Vec<String>>,
240    #[serde(rename = "errorMsg")]
241    pub error_msg: Option<String>,
242}
243
244fn deserialize_optional_order_response_status<'de, D>(
245    deserializer: D,
246) -> Result<Option<OrderResponseStatus>, D::Error>
247where
248    D: Deserializer<'de>,
249{
250    match Option::<String>::deserialize(deserializer)?.as_deref() {
251        None | Some("") => Ok(None),
252        Some("live") => Ok(Some(OrderResponseStatus::Live)),
253        Some("matched") => Ok(Some(OrderResponseStatus::Matched)),
254        Some("delayed") => Ok(Some(OrderResponseStatus::Delayed)),
255        Some("unmatched") => Ok(Some(OrderResponseStatus::Unmatched)),
256        Some(value) => Err(D::Error::unknown_variant(
257            value,
258            &["live", "matched", "delayed", "unmatched"],
259        )),
260    }
261}
262
263/// Cancel response from all cancel endpoints (`DELETE /order`, `/orders`,
264/// `/cancel-all`, `/cancel-market-orders`).
265///
266/// All endpoints return the same format:
267/// `{ "canceled": ["0x..."], "not_canceled": {"0x...": "reason"} }`
268#[derive(Clone, Debug, Default, Deserialize)]
269pub struct CancelResponse {
270    #[serde(default)]
271    pub canceled: Vec<String>,
272    #[serde(default)]
273    pub not_canceled: AHashMap<String, Option<String>>,
274}
275
276impl CancelResponse {
277    pub(crate) fn merge(&mut self, mut response: Self) {
278        self.canceled.append(&mut response.canceled);
279        self.not_canceled.extend(response.not_canceled);
280    }
281}
282
283/// Type alias for backwards compatibility.
284pub type BatchCancelResponse = CancelResponse;
285
286/// Parameters for `POST /order`.
287#[derive(Clone, Debug, Serialize)]
288#[serde(rename_all = "camelCase")]
289pub struct PostOrderParams {
290    pub order_type: PolymarketOrderType,
291    #[serde(skip_serializing_if = "std::ops::Not::not")]
292    pub post_only: bool,
293}
294
295/// One order entry for `POST /orders`.
296#[derive(Clone, Debug, Serialize)]
297#[serde(rename_all = "camelCase")]
298pub struct OrderSubmission {
299    pub order: PolymarketOrder,
300    pub order_type: PolymarketOrderType,
301    #[serde(skip_serializing_if = "std::ops::Not::not")]
302    pub post_only: bool,
303}
304
305/// Query parameters for Gamma API `GET /markets/keyset`.
306#[derive(Clone, Debug, Default, Serialize, Builder)]
307#[builder(setter(into, strip_option), default)]
308pub struct GetGammaMarketsParams {
309    /// Compatibility filter retained from the legacy Gamma market query.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub active: Option<bool>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub closed: Option<bool>,
314    /// Compatibility filter retained from the legacy Gamma market query.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub archived: Option<bool>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub id: Option<Vec<u64>>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub limit: Option<u32>,
321    /// Client-side initial offset. Keyset requests never send this field.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub offset: Option<u32>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub order: Option<String>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub ascending: Option<bool>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub slug: Option<Vec<String>>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub clob_token_ids: Option<Vec<String>>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub condition_ids: Option<Vec<String>>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub question_ids: Option<Vec<String>>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub market_maker_address: Option<Vec<String>>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub liquidity_num_min: Option<Decimal>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub liquidity_num_max: Option<Decimal>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub volume_num_min: Option<Decimal>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub volume_num_max: Option<Decimal>,
346    /// ISO 8601 date or RFC 3339 date-time.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub start_date_min: Option<String>,
349    /// ISO 8601 date or RFC 3339 date-time.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub start_date_max: Option<String>,
352    /// ISO 8601 date or RFC 3339 date-time.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub end_date_min: Option<String>,
355    /// ISO 8601 date or RFC 3339 date-time.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub end_date_max: Option<String>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub tag_id: Option<Vec<u64>>,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub related_tags: Option<bool>,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub tag_match: Option<String>,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub decimalized: Option<bool>,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub cyom: Option<bool>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub rfq_enabled: Option<bool>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub uma_resolution_status: Option<String>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub game_id: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub sports_market_types: Option<Vec<String>>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub include_tag: Option<bool>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub locale: Option<String>,
380    /// Client-side cap on total markets to fetch across all pages.
381    /// Not sent to the API, only used by the paginator to stop early.
382    /// Each market produces 2 instruments (Yes/No outcomes).
383    #[serde(skip)]
384    pub max_markets: Option<u32>,
385}
386
387/// Query parameters for Gamma API `GET /events/keyset`.
388#[derive(Clone, Debug, Default, Serialize, Builder)]
389#[builder(setter(into, strip_option), default)]
390pub struct GetGammaEventsParams {
391    /// Compatibility filter retained from the legacy Gamma event query.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub active: Option<bool>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub closed: Option<bool>,
396    /// Compatibility filter retained from the legacy Gamma event query.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub archived: Option<bool>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub id: Option<Vec<u64>>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub slug: Option<Vec<String>>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub live: Option<bool>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub featured: Option<bool>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub cyom: Option<bool>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub title_search: Option<String>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub liquidity_min: Option<Decimal>,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub liquidity_max: Option<Decimal>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub volume_min: Option<Decimal>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub volume_max: Option<Decimal>,
419    /// ISO 8601 date or RFC 3339 date-time.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub start_date_min: Option<String>,
422    /// ISO 8601 date or RFC 3339 date-time.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub start_date_max: Option<String>,
425    /// ISO 8601 date or RFC 3339 date-time.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub end_date_min: Option<String>,
428    /// ISO 8601 date or RFC 3339 date-time.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub end_date_max: Option<String>,
431    /// ISO 8601 date or RFC 3339 date-time.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub start_time_min: Option<String>,
434    /// ISO 8601 date or RFC 3339 date-time.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub start_time_max: Option<String>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub tag_id: Option<Vec<u64>>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub tag_slug: Option<String>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub exclude_tag_id: Option<Vec<u64>>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub related_tags: Option<bool>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub tag_match: Option<String>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub series_id: Option<Vec<u64>>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub game_id: Option<Vec<u64>>,
451    /// ISO 8601 date or RFC 3339 date-time.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub event_date: Option<String>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub event_week: Option<u32>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub featured_order: Option<bool>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub recurrence: Option<String>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub created_by: Option<Vec<String>>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub parent_event_id: Option<u64>,
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub include_children: Option<bool>,
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub partner_slug: Option<String>,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub include_chat: Option<bool>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub include_template: Option<bool>,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub include_best_lines: Option<bool>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub locale: Option<String>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub order: Option<String>,
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub ascending: Option<bool>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub limit: Option<u32>,
482    /// Client-side initial offset. Keyset requests never send this field.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub offset: Option<u32>,
485    /// Client-side cap on total events to fetch across all pages.
486    #[serde(skip)]
487    pub max_events: Option<u32>,
488}
489
490impl GetGammaMarketsParams {
491    /// Validates values and combinations used by the Gamma market keyset endpoint.
492    pub fn validate_keyset(&self) -> Result<(), String> {
493        validate_limit(self.limit, 100, "market")?;
494        validate_non_empty_values(self.id.as_deref(), "id")?;
495        validate_non_empty_list(self.slug.as_deref(), "slug")?;
496        validate_non_empty_list(self.clob_token_ids.as_deref(), "clob_token_ids")?;
497        validate_non_empty_list(self.condition_ids.as_deref(), "condition_ids")?;
498        validate_non_empty_list(self.question_ids.as_deref(), "question_ids")?;
499        validate_non_empty_list(self.market_maker_address.as_deref(), "market_maker_address")?;
500        validate_non_empty_values(self.tag_id.as_deref(), "tag_id")?;
501        validate_non_empty_list(self.sports_market_types.as_deref(), "sports_market_types")?;
502
503        if self
504            .condition_ids
505            .as_ref()
506            .is_some_and(|ids| ids.len() > 100)
507        {
508            return Err("condition_ids accepts at most 100 values".to_string());
509        }
510
511        validate_decimal_bounds(
512            self.liquidity_num_min,
513            self.liquidity_num_max,
514            "liquidity_num",
515        )?;
516        validate_decimal_bounds(self.volume_num_min, self.volume_num_max, "volume_num")?;
517        validate_date_bounds(
518            self.start_date_min.as_deref(),
519            self.start_date_max.as_deref(),
520            "start_date",
521        )?;
522        validate_date_bounds(
523            self.end_date_min.as_deref(),
524            self.end_date_max.as_deref(),
525            "end_date",
526        )?;
527        validate_non_empty_string(self.order.as_deref(), "order")?;
528        validate_non_empty_string(self.tag_match.as_deref(), "tag_match")?;
529        validate_non_empty_string(
530            self.uma_resolution_status.as_deref(),
531            "uma_resolution_status",
532        )?;
533        validate_non_empty_string(self.game_id.as_deref(), "game_id")?;
534        validate_non_empty_string(self.locale.as_deref(), "locale")
535    }
536}
537
538impl GetGammaEventsParams {
539    /// Validates values and combinations used by the Gamma event keyset endpoint.
540    pub fn validate_keyset(&self) -> Result<(), String> {
541        validate_limit(self.limit, 500, "event")?;
542        validate_non_empty_values(self.id.as_deref(), "id")?;
543        validate_non_empty_list(self.slug.as_deref(), "slug")?;
544        validate_non_empty_values(self.tag_id.as_deref(), "tag_id")?;
545        validate_non_empty_values(self.exclude_tag_id.as_deref(), "exclude_tag_id")?;
546        validate_non_empty_values(self.series_id.as_deref(), "series_id")?;
547        validate_non_empty_values(self.game_id.as_deref(), "game_id")?;
548        validate_non_empty_list(self.created_by.as_deref(), "created_by")?;
549
550        if let (Some(tag_ids), Some(excluded_ids)) = (&self.tag_id, &self.exclude_tag_id) {
551            let tag_ids: AHashSet<u64> = tag_ids.iter().copied().collect();
552            if excluded_ids.iter().any(|id| tag_ids.contains(id)) {
553                return Err("tag_id and exclude_tag_id cannot overlap".to_string());
554            }
555        }
556
557        validate_decimal_bounds(self.liquidity_min, self.liquidity_max, "liquidity")?;
558        validate_decimal_bounds(self.volume_min, self.volume_max, "volume")?;
559        validate_date_bounds(
560            self.start_date_min.as_deref(),
561            self.start_date_max.as_deref(),
562            "start_date",
563        )?;
564        validate_date_bounds(
565            self.end_date_min.as_deref(),
566            self.end_date_max.as_deref(),
567            "end_date",
568        )?;
569        validate_date_bounds(
570            self.start_time_min.as_deref(),
571            self.start_time_max.as_deref(),
572            "start_time",
573        )?;
574        validate_date_value(self.event_date.as_deref(), "event_date")?;
575        validate_non_empty_string(self.order.as_deref(), "order")?;
576        validate_non_empty_string(self.title_search.as_deref(), "title_search")?;
577        validate_non_empty_string(self.tag_slug.as_deref(), "tag_slug")?;
578        validate_non_empty_string(self.tag_match.as_deref(), "tag_match")?;
579        validate_non_empty_string(self.recurrence.as_deref(), "recurrence")?;
580        validate_non_empty_string(self.partner_slug.as_deref(), "partner_slug")?;
581        validate_non_empty_string(self.locale.as_deref(), "locale")
582    }
583}
584
585fn validate_limit(limit: Option<u32>, ceiling: u32, endpoint: &str) -> Result<(), String> {
586    if let Some(limit) = limit
587        && !(1..=ceiling).contains(&limit)
588    {
589        return Err(format!(
590            "{endpoint} limit must be between 1 and {ceiling}, was {limit}"
591        ));
592    }
593    Ok(())
594}
595
596fn validate_non_empty_list(values: Option<&[String]>, name: &str) -> Result<(), String> {
597    if let Some(values) = values
598        && (values.is_empty() || values.iter().any(|value| value.trim().is_empty()))
599    {
600        return Err(format!("{name} must contain non-empty values"));
601    }
602    Ok(())
603}
604
605fn validate_non_empty_values<T>(values: Option<&[T]>, name: &str) -> Result<(), String> {
606    if values.is_some_and(<[T]>::is_empty) {
607        return Err(format!("{name} must contain at least one value"));
608    }
609    Ok(())
610}
611
612fn validate_decimal_bounds(
613    min: Option<Decimal>,
614    max: Option<Decimal>,
615    name: &str,
616) -> Result<(), String> {
617    if let (Some(min), Some(max)) = (min, max)
618        && min > max
619    {
620        return Err(format!("{name}_min cannot exceed {name}_max"));
621    }
622    Ok(())
623}
624
625fn validate_date_bounds(min: Option<&str>, max: Option<&str>, name: &str) -> Result<(), String> {
626    let min = parse_date_value(min, &format!("{name}_min"))?;
627    let max = parse_date_value(max, &format!("{name}_max"))?;
628    if let (Some(min), Some(max)) = (min, max)
629        && min > max
630    {
631        return Err(format!("{name}_min cannot exceed {name}_max"));
632    }
633    Ok(())
634}
635
636fn validate_date_value(value: Option<&str>, name: &str) -> Result<(), String> {
637    parse_date_value(value, name).map(|_| ())
638}
639
640fn parse_date_value(value: Option<&str>, name: &str) -> Result<Option<Timestamp>, String> {
641    value
642        .map(|value| {
643            if value.as_bytes().get(10) == Some(&b'T') {
644                return value
645                    .parse::<Timestamp>()
646                    .map_err(|_| format!("{name} must be an ISO 8601 date or RFC 3339 date-time"));
647            }
648
649            if value.len() == 10
650                && value.as_bytes().get(4) == Some(&b'-')
651                && value.as_bytes().get(7) == Some(&b'-')
652            {
653                return value
654                    .parse::<Date>()
655                    .and_then(|date| Offset::UTC.to_timestamp(date.at(0, 0, 0, 0)))
656                    .map_err(|_| format!("{name} must be an ISO 8601 date or RFC 3339 date-time"));
657            }
658
659            Err(format!(
660                "{name} must be an ISO 8601 date or RFC 3339 date-time"
661            ))
662        })
663        .transpose()
664}
665
666fn validate_non_empty_string(value: Option<&str>, name: &str) -> Result<(), String> {
667    if value.is_some_and(|value| value.trim().is_empty()) {
668        return Err(format!("{name} cannot be empty"));
669    }
670    Ok(())
671}
672
673/// Query parameters for Gamma API `GET /public-search`.
674#[derive(Clone, Debug, Default, Serialize, Builder)]
675#[builder(setter(into, strip_option), default)]
676pub struct GetSearchParams {
677    /// Free-text search query.
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub q: Option<String>,
680    /// Filter events by status ("active", "closed", etc.).
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub events_status: Option<String>,
683    /// Filter by event tag.
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub events_tag: Option<String>,
686    /// Sort field ("volume", "liquidity", etc.).
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub sort: Option<String>,
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub ascending: Option<bool>,
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub limit_per_type: Option<u32>,
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub page: Option<u32>,
695    #[serde(skip_serializing_if = "Option::is_none")]
696    pub keep_closed_markets: Option<bool>,
697}
698
699/// Paginated response wrapper for CLOB list endpoints.
700#[derive(Clone, Debug, Deserialize)]
701pub struct PaginatedResponse<T> {
702    pub data: Vec<T>,
703    pub next_cursor: Option<String>,
704}
705
706#[cfg(test)]
707mod tests {
708    use rstest::rstest;
709    use rust_decimal_macros::dec;
710    use serde::de::value::{Error as ValueError, MapDeserializer};
711
712    use super::*;
713    use crate::{
714        common::enums::{PolymarketOrderSide, PolymarketOrderType},
715        http::models::{PolymarketOpenOrder, PolymarketTradeReport},
716    };
717
718    const MAX_ALLOWANCE: &str =
719        "115792089237316195423570985008687907853269984665640564039457584007913129639935";
720
721    struct OversizedSizeHint<I>(I);
722
723    impl<I> Iterator for OversizedSizeHint<I>
724    where
725        I: Iterator,
726    {
727        type Item = I::Item;
728
729        fn next(&mut self) -> Option<Self::Item> {
730            self.0.next()
731        }
732
733        fn size_hint(&self) -> (usize, Option<usize>) {
734            (usize::MAX, Some(usize::MAX))
735        }
736    }
737
738    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
739        let path = format!("test_data/{filename}");
740        let content = std::fs::read_to_string(path).expect("Failed to read test data");
741        serde_json::from_str(&content).expect("Failed to parse test data")
742    }
743
744    #[rstest]
745    fn test_paginated_orders_page() {
746        let page: PaginatedResponse<PolymarketOpenOrder> = load("http_open_orders_page.json");
747
748        assert_eq!(page.data.len(), 2);
749        assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
750        assert_eq!(page.data[0].side, PolymarketOrderSide::Buy);
751        assert_eq!(page.data[1].side, PolymarketOrderSide::Sell);
752    }
753
754    #[rstest]
755    fn test_paginated_trades_page() {
756        let page: PaginatedResponse<PolymarketTradeReport> = load("http_trades_page.json");
757
758        assert_eq!(page.data.len(), 1);
759        assert_eq!(page.next_cursor.as_deref(), Some("LTE="));
760        assert_eq!(page.data[0].id, "trade-0x001");
761    }
762
763    #[rstest]
764    fn test_balance_allowance_with_allowance() {
765        let ba: BalanceAllowance = load("http_balance_allowance_collateral.json");
766
767        assert_eq!(ba.balance, dec!(37_506_152));
768        assert!(ba.allowance.is_none());
769        assert_eq!(
770            ba.allowances,
771            std::collections::HashMap::from([
772                (
773                    "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
774                    MAX_ALLOWANCE.to_string(),
775                ),
776                (
777                    "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
778                    MAX_ALLOWANCE.to_string(),
779                ),
780                (
781                    "0xcccccccccccccccccccccccccccccccccccccccc".to_string(),
782                    MAX_ALLOWANCE.to_string(),
783                ),
784            ])
785        );
786    }
787
788    #[rstest]
789    fn test_balance_allowance_conditional() {
790        let ba: BalanceAllowance = load("http_balance_allowance_conditional.json");
791
792        assert_eq!(ba.balance, Decimal::ZERO);
793        assert!(ba.allowance.is_none());
794        assert_eq!(ba.allowances.len(), 3);
795        assert!(ba.allowances.values().all(|value| value == MAX_ALLOWANCE));
796    }
797
798    #[rstest]
799    fn test_balance_allowance_rejects_missing_allowances() {
800        let result = serde_json::from_str::<BalanceAllowance>(include_str!(concat!(
801            env!("CARGO_MANIFEST_DIR"),
802            "/test_data/http_balance_allowance_no_allowance.json"
803        )));
804
805        assert!(result.unwrap_err().to_string().contains("missing field"));
806    }
807
808    #[rstest]
809    fn test_balance_allowance_rejects_legacy_singular_without_allowances() {
810        let result =
811            serde_json::from_str::<BalanceAllowance>(r#"{"balance":"250.5","allowance":"1000"}"#);
812
813        assert!(
814            result
815                .unwrap_err()
816                .to_string()
817                .contains("legacy singular `allowance`")
818        );
819    }
820
821    #[rstest]
822    fn test_balance_allowance_rejects_conflicting_singular_and_plural_allowances() {
823        let result = serde_json::from_str::<BalanceAllowance>(
824            r#"{
825                "balance":"250.5",
826                "allowance":"0",
827                "allowances":{
828                    "0xe111180000d2663c0091e4f400237545b87b996b":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
829                }
830            }"#,
831        );
832
833        assert!(
834            result
835                .unwrap_err()
836                .to_string()
837                .contains("legacy singular `allowance`")
838        );
839    }
840
841    #[rstest]
842    fn test_balance_allowance_accepts_null_legacy_marker() {
843        let result = serde_json::from_str::<BalanceAllowance>(
844            r#"{
845                "balance":"250.5",
846                "allowance":null,
847                "allowances":{
848                    "0xe111180000d2663c0091e4f400237545b87b996b":"1000"
849                }
850            }"#,
851        )
852        .unwrap();
853
854        assert!(result.allowance.is_none());
855        assert_eq!(result.allowances.len(), 1);
856    }
857
858    #[rstest]
859    fn test_balance_allowance_rejects_duplicate_spender() {
860        let duplicate = serde_json::from_str::<BalanceAllowance>(
861            r#"{
862                "balance":"250.5",
863                "allowances":{
864                    "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa":"0",
865                    "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
866                }
867            }"#,
868        )
869        .expect_err("duplicate spender evidence must be rejected before map construction");
870
871        assert!(duplicate.to_string().contains("duplicate spender"));
872    }
873
874    #[rstest]
875    fn test_balance_allowance_rejects_case_variant_spender_alias() {
876        let duplicate = serde_json::from_str::<BalanceAllowance>(
877            r#"{
878                "balance":"250.5",
879                "allowances":{
880                    "0xada2005600dec949baf300f4c6120000bdb6eaab":"0",
881                    "0xadA2005600Dec949baf300f4C6120000bDB6eAab":"115792089237316195423570985008687907853269984665640564039457584007913129639935"
882                }
883            }"#,
884        )
885        .expect_err("case variants of one EVM spender must be rejected as duplicates");
886
887        assert!(duplicate.to_string().contains("duplicate spender"));
888    }
889
890    #[rstest]
891    fn test_balance_allowance_rejects_malformed_spender() {
892        let malformed = serde_json::from_str::<BalanceAllowance>(
893            r#"{
894                "balance":"250.5",
895                "allowances":{
896                    "exchange":"1000"
897                }
898            }"#,
899        )
900        .expect_err("allowance spender must be an EVM address");
901
902        assert!(malformed.to_string().contains("invalid spender"));
903    }
904
905    #[rstest]
906    #[case::missing_prefix("ada2005600dec949baf300f4c6120000bdb6eaab")]
907    #[case::uppercase_prefix("0Xada2005600dec949baf300f4c6120000bdb6eaab")]
908    #[case::invalid_checksum("0xAdA2005600Dec949baf300f4C6120000bDB6eAab")]
909    fn test_balance_allowance_rejects_noncanonical_spender(#[case] spender: &str) {
910        let payload = format!(r#"{{"balance":"250.5","allowances":{{"{spender}":"1000"}}}}"#,);
911
912        let result = serde_json::from_str::<BalanceAllowance>(&payload);
913
914        assert!(result.unwrap_err().to_string().contains("invalid spender"));
915    }
916
917    #[rstest]
918    fn test_balance_allowance_preserves_checksummed_spender() {
919        let spender = "0xadA2005600Dec949baf300f4C6120000bDB6eAab";
920        let payload = format!(r#"{{"balance":"250.5","allowances":{{"{spender}":"1000"}}}}"#,);
921
922        let balance_allowance = serde_json::from_str::<BalanceAllowance>(&payload).unwrap();
923
924        assert_eq!(
925            balance_allowance.allowances.get(spender),
926            Some(&"1000".to_string())
927        );
928    }
929
930    #[rstest]
931    fn test_spender_allowances_ignores_untrusted_size_hint() {
932        let spender = "0xada2005600dec949baf300f4c6120000bdb6eaab";
933        let entries = [(spender, "1000")];
934        let deserializer =
935            MapDeserializer::<_, ValueError>::new(OversizedSizeHint(entries.into_iter()));
936
937        let allowances = deserialize_spender_allowances(deserializer).unwrap();
938
939        assert_eq!(allowances.get(spender).map(String::as_str), Some("1000"));
940    }
941
942    #[rstest]
943    fn test_order_response_success() {
944        let resp: OrderResponse = load("http_order_response_ok.json");
945
946        assert!(resp.success);
947        assert_eq!(
948            resp.order_id.as_deref(),
949            Some("0x1111111111111111111111111111111111111111111111111111111111111111")
950        );
951        assert_eq!(resp.status, Some(OrderResponseStatus::Delayed));
952        assert!(resp.making_amount.is_none());
953        assert!(resp.taking_amount.is_none());
954        assert!(resp.transaction_hashes.is_none());
955        assert!(resp.trade_ids.is_none());
956        assert_eq!(resp.error_msg.as_deref(), Some(""));
957    }
958
959    #[rstest]
960    fn test_order_response_failure() {
961        // Constructed compatibility case for a legacy failure response
962        let resp: OrderResponse = load("http_order_response_failed.json");
963
964        assert!(!resp.success);
965        assert!(resp.order_id.is_none());
966        assert!(resp.status.is_none());
967        assert!(resp.making_amount.is_none());
968        assert!(resp.taking_amount.is_none());
969        assert!(resp.transaction_hashes.is_none());
970        assert!(resp.trade_ids.is_none());
971        assert_eq!(resp.error_msg.as_deref(), Some("Insufficient balance"));
972    }
973
974    #[rstest]
975    fn test_order_response_empty_status() {
976        // Constructed from the documented post-only response, which uses empty strings
977        let json = r#"{
978            "success":true,
979            "orderID":"",
980            "status":"",
981            "makingAmount":"",
982            "takingAmount":"",
983            "errorMsg":"post-only mode"
984        }"#;
985        let resp: OrderResponse = serde_json::from_str(json).unwrap();
986
987        assert!(resp.success);
988        assert_eq!(resp.order_id.as_deref(), Some(""));
989        assert!(resp.status.is_none());
990        assert!(resp.making_amount.is_none());
991        assert!(resp.taking_amount.is_none());
992        assert!(resp.transaction_hashes.is_none());
993        assert!(resp.trade_ids.is_none());
994        assert_eq!(resp.error_msg.as_deref(), Some("post-only mode"));
995    }
996
997    #[rstest]
998    fn test_order_response_unmatched_status() {
999        // Constructed compatibility case for a failed delayed placement
1000        let json = r#"{
1001            "success":false,
1002            "orderID":"",
1003            "status":"unmatched",
1004            "makingAmount":"",
1005            "takingAmount":"",
1006            "errorMsg":"placement failed"
1007        }"#;
1008        let resp: OrderResponse = serde_json::from_str(json).unwrap();
1009
1010        assert!(!resp.success);
1011        assert_eq!(resp.order_id.as_deref(), Some(""));
1012        assert_eq!(resp.status, Some(OrderResponseStatus::Unmatched));
1013        assert!(resp.making_amount.is_none());
1014        assert!(resp.taking_amount.is_none());
1015        assert!(resp.transaction_hashes.is_none());
1016        assert!(resp.trade_ids.is_none());
1017        assert_eq!(resp.error_msg.as_deref(), Some("placement failed"));
1018    }
1019
1020    #[rstest]
1021    fn test_order_response_matched_fields() {
1022        // Constructed documented shape; commit 031318184d only established that these fields
1023        // were ignored without breaking decoding
1024        let resp: OrderResponse = load("http_order_response_async_exec.json");
1025
1026        assert!(resp.success);
1027        assert_eq!(
1028            resp.order_id.as_deref(),
1029            Some("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12")
1030        );
1031        assert_eq!(resp.status, Some(OrderResponseStatus::Matched));
1032        assert_eq!(resp.making_amount, Some(dec!(100_000_000)));
1033        assert_eq!(resp.taking_amount, Some(dec!(200_000_000)));
1034        assert_eq!(
1035            resp.transaction_hashes.as_deref(),
1036            Some(
1037                &[
1038                    "0xaaaa000000000000000000000000000000000000000000000000000000000000"
1039                        .to_string(),
1040                    "0xbbbb000000000000000000000000000000000000000000000000000000000000"
1041                        .to_string(),
1042                ][..]
1043            )
1044        );
1045        assert_eq!(
1046            resp.trade_ids.as_deref(),
1047            Some(&["trade-0x001".to_string(), "trade-0x002".to_string()][..])
1048        );
1049        assert!(resp.error_msg.is_none());
1050    }
1051
1052    #[rstest]
1053    fn test_order_response_trade_ids_without_transaction_hashes() {
1054        // Constructed compatibility case for a matched response without transaction hashes
1055        let resp: OrderResponse = load("http_order_response_trade_ids_only.json");
1056
1057        assert!(resp.success);
1058        assert_eq!(
1059            resp.order_id.as_deref(),
1060            Some("0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210fe")
1061        );
1062        assert_eq!(resp.status, Some(OrderResponseStatus::Matched));
1063        assert_eq!(resp.making_amount, Some(dec!(25_000_000)));
1064        assert_eq!(resp.taking_amount, Some(dec!(50_000_000)));
1065        assert!(resp.transaction_hashes.is_none());
1066        assert_eq!(
1067            resp.trade_ids.as_deref(),
1068            Some(&["trade-0x101".to_string(), "trade-0x102".to_string()][..])
1069        );
1070        assert!(resp.error_msg.is_none());
1071    }
1072
1073    #[rstest]
1074    fn test_batch_order_response_legs() {
1075        // Sanitized mainnet capture from `POST /orders`
1076        let resps: Vec<OrderResponse> = load("http_batch_order_response.json");
1077
1078        assert_eq!(resps.len(), 2);
1079
1080        for (index, resp) in resps.iter().enumerate() {
1081            assert!(resp.success);
1082            assert_eq!(
1083                resp.order_id.as_deref(),
1084                Some(if index == 0 {
1085                    "0x1111111111111111111111111111111111111111111111111111111111111111"
1086                } else {
1087                    "0x2222222222222222222222222222222222222222222222222222222222222222"
1088                })
1089            );
1090            assert_eq!(resp.status, Some(OrderResponseStatus::Delayed));
1091            assert!(resp.making_amount.is_none());
1092            assert!(resp.taking_amount.is_none());
1093            assert!(resp.transaction_hashes.is_none());
1094            assert!(resp.trade_ids.is_none());
1095            assert_eq!(resp.error_msg.as_deref(), Some(""));
1096        }
1097    }
1098
1099    #[rstest]
1100    fn test_cancel_response_ok() {
1101        let resp: CancelResponse = load("http_cancel_response_ok.json");
1102
1103        assert_eq!(resp.canceled.len(), 1);
1104        assert!(resp.not_canceled.is_empty());
1105    }
1106
1107    #[rstest]
1108    fn test_cancel_response_failed() {
1109        let resp: CancelResponse = load("http_cancel_response_failed.json");
1110
1111        assert!(resp.canceled.is_empty());
1112        assert_eq!(resp.not_canceled.len(), 1);
1113        let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
1114        assert_eq!(reason, Some("already canceled or matched"));
1115    }
1116
1117    #[rstest]
1118    fn test_batch_cancel_response() {
1119        let resp: BatchCancelResponse = load("http_batch_cancel_response.json");
1120
1121        assert_eq!(resp.canceled.len(), 2);
1122        assert!(resp.canceled[0].contains("1111"));
1123        assert!(resp.canceled[1].contains("2222"));
1124        assert_eq!(resp.not_canceled.len(), 1);
1125        let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
1126        assert_eq!(reason, Some("already canceled or matched"));
1127    }
1128
1129    #[rstest]
1130    fn test_cancel_response_merge_preserves_canceled_and_not_canceled_results() {
1131        let mut merged = CancelResponse {
1132            canceled: vec!["order-1".to_string()],
1133            not_canceled: AHashMap::from_iter([(
1134                "order-2".to_string(),
1135                Some("already canceled".to_string()),
1136            )]),
1137        };
1138        merged.merge(CancelResponse {
1139            canceled: vec!["order-3".to_string()],
1140            not_canceled: AHashMap::from_iter([(
1141                "order-4".to_string(),
1142                Some("order not found".to_string()),
1143            )]),
1144        });
1145
1146        assert_eq!(
1147            merged.canceled,
1148            vec!["order-1".to_string(), "order-3".to_string()]
1149        );
1150        assert_eq!(
1151            merged.not_canceled,
1152            AHashMap::from_iter([
1153                ("order-2".to_string(), Some("already canceled".to_string())),
1154                ("order-4".to_string(), Some("order not found".to_string())),
1155            ])
1156        );
1157    }
1158
1159    #[rstest]
1160    fn test_asset_type_serializes_screaming_snake() {
1161        assert_eq!(
1162            serde_json::to_string(&AssetType::Collateral).unwrap(),
1163            "\"COLLATERAL\""
1164        );
1165        assert_eq!(
1166            serde_json::to_string(&AssetType::Conditional).unwrap(),
1167            "\"CONDITIONAL\""
1168        );
1169    }
1170
1171    #[rstest]
1172    fn test_asset_type_deserializes() {
1173        assert_eq!(
1174            serde_json::from_str::<AssetType>("\"COLLATERAL\"").unwrap(),
1175            AssetType::Collateral
1176        );
1177        assert_eq!(
1178            serde_json::from_str::<AssetType>("\"CONDITIONAL\"").unwrap(),
1179            AssetType::Conditional
1180        );
1181    }
1182
1183    #[rstest]
1184    fn test_get_orders_params_skips_none() {
1185        let params = GetOrdersParams::default();
1186        let json = serde_json::to_string(&params).unwrap();
1187        assert_eq!(json, "{}");
1188    }
1189
1190    #[rstest]
1191    fn test_get_orders_params_serializes_set_fields() {
1192        let params = GetOrdersParams {
1193            market: Some("0xmarket".to_string()),
1194            asset_id: None,
1195            next_cursor: Some("MA==".to_string()),
1196            ..Default::default()
1197        };
1198        let json = serde_json::to_string(&params).unwrap();
1199        assert!(json.contains("\"market\""));
1200        assert!(json.contains("\"next_cursor\""));
1201        assert!(!json.contains("\"asset_id\""));
1202    }
1203
1204    #[rstest]
1205    fn test_get_orders_params_id_filter() {
1206        let params = GetOrdersParams {
1207            id: Some("0xorder123".to_string()),
1208            ..Default::default()
1209        };
1210        let json = serde_json::to_string(&params).unwrap();
1211        assert!(json.contains("\"id\""));
1212        assert!(json.contains("0xorder123"));
1213    }
1214
1215    #[rstest]
1216    fn test_get_gamma_markets_params_slug() {
1217        let params = GetGammaMarketsParams {
1218            slug: Some(vec!["btc-updown-15m-1741500000".to_string()]),
1219            ..Default::default()
1220        };
1221        let json = serde_json::to_string(&params).unwrap();
1222        assert!(json.contains("\"slug\""));
1223        assert!(json.contains("btc-updown-15m-1741500000"));
1224        assert!(!json.contains("\"active\""));
1225    }
1226
1227    #[rstest]
1228    fn test_get_gamma_markets_params_skips_none_slug() {
1229        let params = GetGammaMarketsParams {
1230            active: Some(true),
1231            ..Default::default()
1232        };
1233        let json = serde_json::to_string(&params).unwrap();
1234        assert!(!json.contains("\"slug\""));
1235        assert!(json.contains("\"active\""));
1236    }
1237
1238    #[rstest]
1239    fn test_get_gamma_markets_params_new_filter_fields() {
1240        let params = GetGammaMarketsParams {
1241            volume_num_min: Some(dec!(1000.0)),
1242            tag_id: Some(vec![123]),
1243            end_date_min: Some("2025-06-01T00:00:00Z".to_string()),
1244            ..Default::default()
1245        };
1246        let json = serde_json::to_string(&params).unwrap();
1247        assert!(json.contains("\"volume_num_min\":\"1000.0\""));
1248        assert!(json.contains("\"tag_id\":[123]"));
1249        assert!(json.contains("\"end_date_min\":\"2025-06-01T00:00:00Z\""));
1250        assert!(!json.contains("\"active\""));
1251        assert!(!json.contains("\"archived\""));
1252    }
1253
1254    #[rstest]
1255    fn test_get_gamma_markets_params_condition_ids() {
1256        let params = GetGammaMarketsParams {
1257            condition_ids: Some(vec!["0xcond1".to_string(), "0xcond2".to_string()]),
1258            liquidity_num_min: Some(dec!(500.0)),
1259            ..Default::default()
1260        };
1261        let json = serde_json::to_string(&params).unwrap();
1262        assert!(json.contains("\"condition_ids\":[\"0xcond1\",\"0xcond2\"]"));
1263        assert!(json.contains("\"liquidity_num_min\":\"500.0\""));
1264    }
1265
1266    #[rstest]
1267    fn test_get_trades_params_skips_none() {
1268        let params = GetTradesParams::default();
1269        let json = serde_json::to_string(&params).unwrap();
1270        assert_eq!(json, "{}");
1271    }
1272
1273    #[rstest]
1274    fn test_post_order_params_skips_post_only_when_false() {
1275        let params = PostOrderParams {
1276            order_type: PolymarketOrderType::GTC,
1277            post_only: false,
1278        };
1279        let json = serde_json::to_string(&params).unwrap();
1280        assert!(!json.contains("post_only"));
1281        assert!(!json.contains("postOnly"));
1282    }
1283
1284    #[rstest]
1285    fn test_post_order_params_includes_post_only_when_true() {
1286        let params = PostOrderParams {
1287            order_type: PolymarketOrderType::GTC,
1288            post_only: true,
1289        };
1290        let json = serde_json::to_string(&params).unwrap();
1291        assert!(json.contains("postOnly"));
1292    }
1293}