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 ahash::AHashMap;
19use derive_builder::Builder;
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22
23use crate::{
24    common::{
25        enums::{PolymarketOrderType, SignatureType},
26        parse::{deserialize_decimal_from_str, deserialize_optional_decimal_from_str},
27    },
28    http::models::PolymarketOrder,
29};
30
31/// Query parameters for `GET /data/orders`.
32#[derive(Clone, Debug, Default, Serialize, Builder)]
33#[builder(setter(into, strip_option), default)]
34pub struct GetOrdersParams {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub id: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub market: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub asset_id: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub next_cursor: Option<String>,
43}
44
45/// Query parameters for `GET /data/trades`.
46#[derive(Clone, Debug, Default, Serialize, Builder)]
47#[builder(setter(into, strip_option), default)]
48pub struct GetTradesParams {
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub id: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub maker_address: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub market: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub asset_id: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub before: Option<u64>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub after: Option<u64>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub next_cursor: Option<String>,
63}
64
65/// Query parameters for `GET /balance-allowance`.
66#[derive(Clone, Debug, Default, Serialize, Builder)]
67#[builder(setter(into, strip_option), default)]
68pub struct GetBalanceAllowanceParams {
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub asset_type: Option<AssetType>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub token_id: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub signature_type: Option<SignatureType>,
75}
76
77/// Body parameters for `DELETE /cancel-market-orders`.
78#[derive(Clone, Debug, Default, Serialize, Builder)]
79#[builder(setter(into, strip_option), default)]
80pub struct CancelMarketOrdersParams {
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub market: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub asset_id: Option<String>,
85}
86
87/// Asset type for balance and allowance requests.
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
90pub enum AssetType {
91    Collateral,
92    Conditional,
93}
94
95/// Balance and allowance response from `GET /balance-allowance`.
96#[derive(Clone, Debug, Deserialize)]
97pub struct BalanceAllowance {
98    #[serde(deserialize_with = "deserialize_decimal_from_str")]
99    pub balance: Decimal,
100    #[serde(default, deserialize_with = "deserialize_optional_decimal_from_str")]
101    pub allowance: Option<Decimal>,
102}
103
104/// Order submission response from `POST /order` and `POST /orders`.
105#[derive(Clone, Debug, Deserialize)]
106pub struct OrderResponse {
107    pub success: bool,
108    #[serde(rename = "orderID")]
109    pub order_id: Option<String>,
110    #[serde(rename = "errorMsg")]
111    pub error_msg: Option<String>,
112}
113
114/// Cancel response from all cancel endpoints (`DELETE /order`, `/orders`,
115/// `/cancel-all`, `/cancel-market-orders`).
116///
117/// All endpoints return the same format:
118/// `{ "canceled": ["0x..."], "not_canceled": {"0x...": "reason"} }`
119#[derive(Clone, Debug, Deserialize)]
120pub struct CancelResponse {
121    #[serde(default)]
122    pub canceled: Vec<String>,
123    #[serde(default)]
124    pub not_canceled: AHashMap<String, Option<String>>,
125}
126
127/// Type alias for backwards compatibility.
128pub type BatchCancelResponse = CancelResponse;
129
130/// Parameters for `POST /order`.
131#[derive(Clone, Debug, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct PostOrderParams {
134    pub order_type: PolymarketOrderType,
135    #[serde(skip_serializing_if = "std::ops::Not::not")]
136    pub post_only: bool,
137}
138
139/// One order entry for `POST /orders`.
140#[derive(Clone, Debug, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct OrderSubmission {
143    pub order: PolymarketOrder,
144    pub order_type: PolymarketOrderType,
145    #[serde(skip_serializing_if = "std::ops::Not::not")]
146    pub post_only: bool,
147}
148
149/// Query parameters for Gamma API `GET /markets`.
150#[derive(Clone, Debug, Default, Serialize, Builder)]
151#[builder(setter(into, strip_option), default)]
152pub struct GetGammaMarketsParams {
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub active: Option<bool>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub closed: Option<bool>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub archived: Option<bool>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub id: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub limit: Option<u32>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub offset: Option<u32>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub order: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub ascending: Option<bool>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub slug: Option<String>,
171    /// Comma-separated CLOB token IDs.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub clob_token_ids: Option<String>,
174    /// Comma-separated condition IDs (max 100).
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub condition_ids: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub liquidity_num_min: Option<f64>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub liquidity_num_max: Option<f64>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub volume_num_min: Option<f64>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub volume_num_max: Option<f64>,
185    /// ISO 8601 date string.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub start_date_min: Option<String>,
188    /// ISO 8601 date string.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub start_date_max: Option<String>,
191    /// ISO 8601 date string.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub end_date_min: Option<String>,
194    /// ISO 8601 date string.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub end_date_max: Option<String>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub tag_id: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub related_tags: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub rewards_min_size: Option<f64>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub include_tag: Option<bool>,
205    /// Comma-separated question IDs.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub question_ids: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub game_id: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub sports_market_types: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub market_maker_address: Option<String>,
214    /// Client-side cap on total markets to fetch across all pages.
215    /// Not sent to the API, only used by the paginator to stop early.
216    /// Each market produces 2 instruments (Yes/No outcomes).
217    #[serde(skip)]
218    pub max_markets: Option<u32>,
219}
220
221/// Query parameters for Gamma API `GET /events`.
222#[derive(Clone, Debug, Default, Serialize, Builder)]
223#[builder(setter(into, strip_option), default)]
224pub struct GetGammaEventsParams {
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub active: Option<bool>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub closed: Option<bool>,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub archived: Option<bool>,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub id: Option<String>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub slug: Option<String>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub tag_id: Option<String>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub tag_slug: Option<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub exclude_tag_id: Option<String>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub featured: Option<bool>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub liquidity_min: Option<f64>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub liquidity_max: Option<f64>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub volume_min: Option<f64>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub volume_max: Option<f64>,
251    /// ISO 8601 date string.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub start_date_min: Option<String>,
254    /// ISO 8601 date string.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub start_date_max: Option<String>,
257    /// ISO 8601 date string.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub end_date_min: Option<String>,
260    /// ISO 8601 date string.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub end_date_max: Option<String>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub order: Option<String>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub ascending: Option<bool>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub limit: Option<u32>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub offset: Option<u32>,
271    /// Client-side cap on total events to fetch across all pages.
272    #[serde(skip)]
273    pub max_events: Option<u32>,
274}
275
276/// Query parameters for Gamma API `GET /public-search`.
277#[derive(Clone, Debug, Default, Serialize, Builder)]
278#[builder(setter(into, strip_option), default)]
279pub struct GetSearchParams {
280    /// Free-text search query.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub q: Option<String>,
283    /// Filter events by status ("active", "closed", etc.).
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub events_status: Option<String>,
286    /// Filter by event tag.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub events_tag: Option<String>,
289    /// Sort field ("volume", "liquidity", etc.).
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub sort: Option<String>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub ascending: Option<bool>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub limit_per_type: Option<u32>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub page: Option<u32>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub keep_closed_markets: Option<bool>,
300}
301
302/// Paginated response wrapper for CLOB list endpoints.
303#[derive(Clone, Debug, Deserialize)]
304pub struct PaginatedResponse<T> {
305    pub data: Vec<T>,
306    pub next_cursor: String,
307}
308
309#[cfg(test)]
310mod tests {
311    use rstest::rstest;
312    use rust_decimal_macros::dec;
313
314    use super::*;
315    use crate::{
316        common::enums::{PolymarketOrderSide, PolymarketOrderType},
317        http::models::{PolymarketOpenOrder, PolymarketTradeReport},
318    };
319
320    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
321        let path = format!("test_data/{filename}");
322        let content = std::fs::read_to_string(path).expect("Failed to read test data");
323        serde_json::from_str(&content).expect("Failed to parse test data")
324    }
325
326    #[rstest]
327    fn test_paginated_orders_page() {
328        let page: PaginatedResponse<PolymarketOpenOrder> = load("http_open_orders_page.json");
329
330        assert_eq!(page.data.len(), 2);
331        assert_eq!(page.next_cursor, "LTE=");
332        assert_eq!(page.data[0].side, PolymarketOrderSide::Buy);
333        assert_eq!(page.data[1].side, PolymarketOrderSide::Sell);
334    }
335
336    #[rstest]
337    fn test_paginated_trades_page() {
338        let page: PaginatedResponse<PolymarketTradeReport> = load("http_trades_page.json");
339
340        assert_eq!(page.data.len(), 1);
341        assert_eq!(page.next_cursor, "LTE=");
342        assert_eq!(page.data[0].id, "trade-0x001");
343    }
344
345    #[rstest]
346    fn test_balance_allowance_with_allowance() {
347        // The Polymarket API returns balances and allowances as integer
348        // micro-pUSD strings (e.g. `"1000000000"` == 1000 pUSD).
349        let ba: BalanceAllowance = load("http_balance_allowance_collateral.json");
350
351        assert_eq!(ba.balance, dec!(1_000_000_000));
352        assert_eq!(ba.allowance, Some(dec!(999_999_999_000_000)));
353    }
354
355    #[rstest]
356    fn test_balance_allowance_no_allowance() {
357        let ba: BalanceAllowance = load("http_balance_allowance_no_allowance.json");
358
359        assert_eq!(ba.balance, dec!(250.500000));
360        assert!(ba.allowance.is_none());
361    }
362
363    #[rstest]
364    fn test_order_response_success() {
365        let resp: OrderResponse = load("http_order_response_ok.json");
366
367        assert!(resp.success);
368        assert_eq!(
369            resp.order_id.as_deref(),
370            Some("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12")
371        );
372        assert!(resp.error_msg.is_none());
373    }
374
375    #[rstest]
376    fn test_order_response_failure() {
377        let resp: OrderResponse = load("http_order_response_failed.json");
378
379        assert!(!resp.success);
380        assert!(resp.order_id.is_none());
381        assert_eq!(resp.error_msg.as_deref(), Some("Insufficient balance"));
382    }
383
384    #[rstest]
385    fn test_cancel_response_ok() {
386        let resp: CancelResponse = load("http_cancel_response_ok.json");
387
388        assert_eq!(resp.canceled.len(), 1);
389        assert!(resp.not_canceled.is_empty());
390    }
391
392    #[rstest]
393    fn test_cancel_response_failed() {
394        let resp: CancelResponse = load("http_cancel_response_failed.json");
395
396        assert!(resp.canceled.is_empty());
397        assert_eq!(resp.not_canceled.len(), 1);
398        let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
399        assert_eq!(reason, Some("already canceled or matched"));
400    }
401
402    #[rstest]
403    fn test_batch_cancel_response() {
404        let resp: BatchCancelResponse = load("http_batch_cancel_response.json");
405
406        assert_eq!(resp.canceled.len(), 2);
407        assert!(resp.canceled[0].contains("1111"));
408        assert!(resp.canceled[1].contains("2222"));
409        assert_eq!(resp.not_canceled.len(), 1);
410        let reason = resp.not_canceled.values().next().and_then(|v| v.as_deref());
411        assert_eq!(reason, Some("already canceled or matched"));
412    }
413
414    #[rstest]
415    fn test_asset_type_serializes_screaming_snake() {
416        assert_eq!(
417            serde_json::to_string(&AssetType::Collateral).unwrap(),
418            "\"COLLATERAL\""
419        );
420        assert_eq!(
421            serde_json::to_string(&AssetType::Conditional).unwrap(),
422            "\"CONDITIONAL\""
423        );
424    }
425
426    #[rstest]
427    fn test_asset_type_deserializes() {
428        assert_eq!(
429            serde_json::from_str::<AssetType>("\"COLLATERAL\"").unwrap(),
430            AssetType::Collateral
431        );
432        assert_eq!(
433            serde_json::from_str::<AssetType>("\"CONDITIONAL\"").unwrap(),
434            AssetType::Conditional
435        );
436    }
437
438    #[rstest]
439    fn test_get_orders_params_skips_none() {
440        let params = GetOrdersParams::default();
441        let json = serde_json::to_string(&params).unwrap();
442        assert_eq!(json, "{}");
443    }
444
445    #[rstest]
446    fn test_get_orders_params_serializes_set_fields() {
447        let params = GetOrdersParams {
448            market: Some("0xmarket".to_string()),
449            asset_id: None,
450            next_cursor: Some("MA==".to_string()),
451            ..Default::default()
452        };
453        let json = serde_json::to_string(&params).unwrap();
454        assert!(json.contains("\"market\""));
455        assert!(json.contains("\"next_cursor\""));
456        assert!(!json.contains("\"asset_id\""));
457    }
458
459    #[rstest]
460    fn test_get_orders_params_id_filter() {
461        let params = GetOrdersParams {
462            id: Some("0xorder123".to_string()),
463            ..Default::default()
464        };
465        let json = serde_json::to_string(&params).unwrap();
466        assert!(json.contains("\"id\""));
467        assert!(json.contains("0xorder123"));
468    }
469
470    #[rstest]
471    fn test_get_gamma_markets_params_slug() {
472        let params = GetGammaMarketsParams {
473            slug: Some("btc-updown-15m-1741500000".to_string()),
474            ..Default::default()
475        };
476        let json = serde_json::to_string(&params).unwrap();
477        assert!(json.contains("\"slug\""));
478        assert!(json.contains("btc-updown-15m-1741500000"));
479        assert!(!json.contains("\"active\""));
480    }
481
482    #[rstest]
483    fn test_get_gamma_markets_params_skips_none_slug() {
484        let params = GetGammaMarketsParams {
485            active: Some(true),
486            ..Default::default()
487        };
488        let json = serde_json::to_string(&params).unwrap();
489        assert!(!json.contains("\"slug\""));
490        assert!(json.contains("\"active\""));
491    }
492
493    #[rstest]
494    fn test_get_gamma_markets_params_new_filter_fields() {
495        let params = GetGammaMarketsParams {
496            volume_num_min: Some(1000.0),
497            tag_id: Some("politics".to_string()),
498            end_date_min: Some("2025-06-01T00:00:00Z".to_string()),
499            ..Default::default()
500        };
501        let json = serde_json::to_string(&params).unwrap();
502        assert!(json.contains("\"volume_num_min\":1000.0"));
503        assert!(json.contains("\"tag_id\":\"politics\""));
504        assert!(json.contains("\"end_date_min\":\"2025-06-01T00:00:00Z\""));
505        assert!(!json.contains("\"active\""));
506        assert!(!json.contains("\"archived\""));
507    }
508
509    #[rstest]
510    fn test_get_gamma_markets_params_condition_ids() {
511        let params = GetGammaMarketsParams {
512            condition_ids: Some("0xcond1,0xcond2".to_string()),
513            liquidity_num_min: Some(500.0),
514            ..Default::default()
515        };
516        let json = serde_json::to_string(&params).unwrap();
517        assert!(json.contains("\"condition_ids\":\"0xcond1,0xcond2\""));
518        assert!(json.contains("\"liquidity_num_min\":500.0"));
519    }
520
521    #[rstest]
522    fn test_get_trades_params_skips_none() {
523        let params = GetTradesParams::default();
524        let json = serde_json::to_string(&params).unwrap();
525        assert_eq!(json, "{}");
526    }
527
528    #[rstest]
529    fn test_post_order_params_skips_post_only_when_false() {
530        let params = PostOrderParams {
531            order_type: PolymarketOrderType::GTC,
532            post_only: false,
533        };
534        let json = serde_json::to_string(&params).unwrap();
535        assert!(!json.contains("post_only"));
536        assert!(!json.contains("postOnly"));
537    }
538
539    #[rstest]
540    fn test_post_order_params_includes_post_only_when_true() {
541        let params = PostOrderParams {
542            order_type: PolymarketOrderType::GTC,
543            post_only: true,
544        };
545        let json = serde_json::to_string(&params).unwrap();
546        assert!(json.contains("postOnly"));
547    }
548}