Skip to main content

nautilus_polymarket/http/
clob.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//! Provides the HTTP client for the Polymarket CLOB REST API.
17
18use std::{
19    collections::HashMap, convert::Infallible, result::Result as StdResult, str::from_utf8,
20    sync::Arc,
21};
22
23use nautilus_core::{
24    consts::NAUTILUS_USER_AGENT,
25    time::{AtomicTime, get_atomic_clock_realtime},
26};
27use nautilus_model::{
28    data::BookOrder,
29    enums::{BookType, OrderSide},
30    identifiers::InstrumentId,
31    orderbook::OrderBook,
32};
33use nautilus_network::{
34    http::{HttpClient, HttpClientError, Method, USER_AGENT},
35    websocket::proxy::ProxyUrl,
36};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize, de::DeserializeOwned};
39
40use crate::{
41    common::{
42        credential::Credential, enums::PolymarketOrderType, parse::deserialize_decimal_from_str,
43        urls::clob_http_url,
44    },
45    http::{
46        error::{Error, Result},
47        models::{
48            ClobBookResponse, ClobMarketResponse, FeeRateResponse, PolymarketOpenOrder,
49            PolymarketOrder, PolymarketTradeReport, TickSizeResponse,
50        },
51        pagination::{CollectAll, Completion, CursorProtocol, FetchOutcome, Paginator},
52        query::{
53            BalanceAllowance, BatchCancelResponse, CancelMarketOrdersParams, CancelResponse,
54            ClobVersionResponse, GetBalanceAllowanceParams, GetOrdersParams, GetTradesParams,
55            OrderResponse, PaginatedResponse,
56        },
57        rate_limits::{PolymarketRateLimiter, RateLimitHeaders, TradingBucket},
58    },
59    websocket::parse::{parse_price, parse_quantity},
60};
61
62const CURSOR_START: &str = "MA==";
63const CURSOR_END: &str = "LTE=";
64
65const PATH_ORDERS: &str = "/data/orders";
66const PATH_TRADES: &str = "/data/trades";
67const PATH_VERSION: &str = "/version";
68const PATH_BALANCE_ALLOWANCE: &str = "/balance-allowance";
69const PATH_BALANCE_ALLOWANCE_UPDATE: &str = "/balance-allowance/update";
70const PATH_POST_ORDER: &str = "/order";
71const PATH_POST_ORDERS: &str = "/orders";
72const PATH_CANCEL_ALL: &str = "/cancel-all";
73const PATH_CANCEL_MARKET_ORDERS: &str = "/cancel-market-orders";
74const PATH_HEARTBEATS: &str = "/v1/heartbeats";
75
76const CLOB_CANCEL_BATCH_LIMIT: usize = 1_000;
77
78#[derive(Serialize)]
79#[serde(rename_all = "camelCase")]
80struct PostOrderBody<'a> {
81    order: &'a PolymarketOrder,
82    owner: &'a str,
83    order_type: PolymarketOrderType,
84    #[serde(skip_serializing_if = "std::ops::Not::not")]
85    post_only: bool,
86}
87
88#[derive(Serialize)]
89struct CancelOrderBody<'a> {
90    #[serde(rename = "orderID")]
91    order_id: &'a str,
92}
93
94#[derive(Serialize)]
95struct HeartbeatRequest<'a> {
96    heartbeat_id: &'a str,
97}
98
99#[derive(Deserialize)]
100struct HeartbeatWireResponse {
101    heartbeat_id: Option<String>,
102}
103
104#[derive(Deserialize)]
105struct BalanceResponse {
106    #[serde(deserialize_with = "deserialize_decimal_from_str")]
107    balance: Decimal,
108}
109
110/// Outcome from an authenticated CLOB order-safety heartbeat.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum HeartbeatResponse {
113    /// The heartbeat was acknowledged and the venue returned the next ID for chaining.
114    Acknowledged(String),
115    /// The supplied ID was stale and the venue returned the current ID.
116    Resynchronize(String),
117}
118
119/// Provides an authenticated HTTP client for the Polymarket CLOB REST API.
120///
121/// Handles HTTP transport, L2 HMAC-SHA256 auth signing, pagination, and raw
122/// API calls that closely match Polymarket endpoint specifications.
123/// Credential is always present: the CLOB API requires authentication.
124#[derive(Debug, Clone)]
125pub struct PolymarketClobHttpClient {
126    client: HttpClient,
127    rate_limiter: Arc<PolymarketRateLimiter>,
128    base_url: String,
129    credential: Credential,
130    address: String,
131    clock: &'static AtomicTime,
132}
133
134impl PolymarketClobHttpClient {
135    /// Creates a new authenticated [`PolymarketClobHttpClient`].
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the HTTP client cannot be created.
140    pub fn new(
141        credential: Credential,
142        address: String,
143        base_url: Option<String>,
144        timeout_secs: u64,
145    ) -> StdResult<Self, HttpClientError> {
146        Self::new_with_proxy(credential, address, base_url, timeout_secs, None)
147    }
148
149    /// Creates a new authenticated client with an optional validated proxy URL.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the HTTP client cannot be created.
154    pub fn new_with_proxy(
155        credential: Credential,
156        address: String,
157        base_url: Option<String>,
158        timeout_secs: u64,
159        proxy_url: Option<ProxyUrl>,
160    ) -> StdResult<Self, HttpClientError> {
161        let rate_limiter = PolymarketRateLimiter::for_signer(&address);
162        Ok(Self {
163            client: HttpClient::builder()
164                .headers(Self::default_headers())
165                .header_keys(RateLimitHeaders::names())
166                .timeout_secs(timeout_secs)
167                .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
168                .build()?,
169            rate_limiter,
170            base_url: base_url
171                .unwrap_or_else(|| clob_http_url().to_string())
172                .trim_end_matches('/')
173                .to_string(),
174            credential,
175            address,
176            clock: get_atomic_clock_realtime(),
177        })
178    }
179
180    fn default_headers() -> HashMap<String, String> {
181        HashMap::from([
182            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
183            ("Content-Type".to_string(), "application/json".to_string()),
184        ])
185    }
186
187    fn url(&self, path: &str) -> String {
188        format!("{}{path}", self.base_url)
189    }
190
191    fn timestamp(&self) -> String {
192        (self.clock.get_time_ns().as_u64() / 1_000_000_000).to_string()
193    }
194
195    fn auth_headers(&self, method: &str, path: &str, body: &str) -> HashMap<String, String> {
196        let timestamp = self.timestamp();
197        let signature = self.credential.sign(&timestamp, method, path, body);
198
199        HashMap::from([
200            ("POLY_ADDRESS".to_string(), self.address.clone()),
201            ("POLY_SIGNATURE".to_string(), signature),
202            ("POLY_TIMESTAMP".to_string(), timestamp),
203            (
204                "POLY_API_KEY".to_string(),
205                self.credential.api_key().to_string(),
206            ),
207            (
208                "POLY_PASSPHRASE".to_string(),
209                self.credential.passphrase().to_string(),
210            ),
211        ])
212    }
213
214    async fn send_get<P: Serialize, T: DeserializeOwned>(
215        &self,
216        path: &str,
217        params: Option<&P>,
218        auth: bool,
219    ) -> Result<T> {
220        let headers = if auth {
221            Some(self.auth_headers("GET", path, ""))
222        } else {
223            None
224        };
225        let url = self.url(path);
226        let response = self
227            .client
228            .request_with_params(Method::GET, url, params, headers, None, None, None)
229            .await
230            .map_err(Error::from_http_client)?;
231
232        if response.status.is_success() {
233            serde_json::from_slice(&response.body).map_err(Error::Serde)
234        } else {
235            Err(Error::from_status_code(
236                response.status.as_u16(),
237                &response.body,
238            ))
239        }
240    }
241
242    /// Like [`send_get`] but returns `Ok(None)` for empty or `null` response bodies
243    /// instead of a serde deserialization error.
244    async fn send_get_optional<P: Serialize, T: DeserializeOwned>(
245        &self,
246        path: &str,
247        params: Option<&P>,
248        auth: bool,
249    ) -> Result<Option<T>> {
250        let headers = if auth {
251            Some(self.auth_headers("GET", path, ""))
252        } else {
253            None
254        };
255        let url = self.url(path);
256        let response = self
257            .client
258            .request_with_params(Method::GET, url, params, headers, None, None, None)
259            .await
260            .map_err(Error::from_http_client)?;
261
262        if response.status.is_success() {
263            if response.body.is_empty() || response.body.as_ref() == b"null" {
264                Ok(None)
265            } else {
266                serde_json::from_slice(&response.body)
267                    .map(Some)
268                    .map_err(Error::Serde)
269            }
270        } else {
271            Err(Error::from_status_code(
272                response.status.as_u16(),
273                &response.body,
274            ))
275        }
276    }
277
278    async fn send_post<T: DeserializeOwned>(
279        &self,
280        path: &'static str,
281        body_bytes: Vec<u8>,
282        cost: u32,
283    ) -> Result<T> {
284        self.send_trading(
285            Method::POST,
286            path,
287            Some(body_bytes),
288            TradingBucket::Order,
289            cost,
290            |_| 0,
291        )
292        .await
293    }
294
295    async fn send_delete<T: DeserializeOwned>(
296        &self,
297        path: &'static str,
298        body_bytes: Option<Vec<u8>>,
299        cost: u32,
300    ) -> Result<T> {
301        self.send_trading(
302            Method::DELETE,
303            path,
304            body_bytes,
305            TradingBucket::Cancel,
306            cost,
307            |_| 0,
308        )
309        .await
310    }
311
312    async fn send_delete_with_cancel_debit(
313        &self,
314        path: &'static str,
315        body_bytes: Option<Vec<u8>>,
316    ) -> Result<BatchCancelResponse> {
317        self.send_trading(
318            Method::DELETE,
319            path,
320            body_bytes,
321            TradingBucket::Cancel,
322            1,
323            canceled_count,
324        )
325        .await
326    }
327
328    async fn send_trading<T: DeserializeOwned>(
329        &self,
330        method: Method,
331        path: &'static str,
332        body_bytes: Option<Vec<u8>>,
333        bucket: TradingBucket,
334        cost: u32,
335        post_response_cost: impl FnOnce(&T) -> u32,
336    ) -> Result<T> {
337        self.rate_limiter.acquire(path, bucket, cost).await?;
338
339        let body_str = body_bytes
340            .as_deref()
341            .map(|b| from_utf8(b).map_err(|e| Error::decode(format!("UTF-8 error: {e}"))))
342            .transpose()?
343            .unwrap_or("");
344        let headers = Some(self.auth_headers(method.as_str(), path, body_str));
345        let url = self.url(path);
346        let response = self
347            .client
348            .request(method, url, None, headers, body_bytes, None, None)
349            .await
350            .map_err(Error::from_http_client)?;
351        let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
352
353        if response.status.is_success() {
354            let decoded = serde_json::from_slice(&response.body);
355            let post_response_cost = decoded.as_ref().map_or(0, post_response_cost);
356            self.rate_limiter
357                .observe_response(
358                    path,
359                    bucket,
360                    cost,
361                    post_response_cost,
362                    &rate_limit_headers,
363                    false,
364                )
365                .await;
366            decoded.map_err(Error::Serde)
367        } else {
368            let rate_limited = response.status.as_u16() == 429;
369            self.rate_limiter
370                .observe_response(path, bucket, cost, 0, &rate_limit_headers, rate_limited)
371                .await;
372
373            if rate_limited {
374                Err(Error::rate_limit_from_body(
375                    path,
376                    cost,
377                    rate_limit_headers.retry_after_ms(),
378                    &response.body,
379                    rate_limit_headers.has_signer_headers(),
380                ))
381            } else {
382                Err(Error::from_status_code(
383                    response.status.as_u16(),
384                    &response.body,
385                ))
386            }
387        }
388    }
389
390    /// Returns the CLOB protocol version reported by the venue.
391    pub async fn get_version(&self) -> Result<ClobVersionResponse> {
392        self.send_get::<(), _>(PATH_VERSION, None, false).await
393    }
394
395    /// Sends an authenticated order-safety heartbeat.
396    pub async fn post_heartbeat(&self, heartbeat_id: &str) -> Result<HeartbeatResponse> {
397        let body = HeartbeatRequest { heartbeat_id };
398        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
399        let body_str =
400            from_utf8(&body_bytes).map_err(|e| Error::decode(format!("UTF-8 error: {e}")))?;
401        let headers = Some(self.auth_headers("POST", PATH_HEARTBEATS, body_str));
402        let response = self
403            .client
404            .request(
405                Method::POST,
406                self.url(PATH_HEARTBEATS),
407                None,
408                headers,
409                Some(body_bytes),
410                None,
411                None,
412            )
413            .await
414            .map_err(Error::from_http_client)?;
415
416        if response.status.as_u16() == 429 {
417            let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
418            return Err(Error::rate_limit_from_body(
419                PATH_HEARTBEATS,
420                0,
421                rate_limit_headers.retry_after_ms(),
422                &response.body,
423                rate_limit_headers.has_signer_headers(),
424            ));
425        }
426
427        let wire = serde_json::from_slice::<HeartbeatWireResponse>(&response.body);
428        let next_id = |wire: HeartbeatWireResponse| {
429            wire.heartbeat_id
430                .filter(|heartbeat_id| !heartbeat_id.is_empty())
431        };
432
433        if response.status.is_success() {
434            if let Some(heartbeat_id) = next_id(wire.map_err(Error::Serde)?) {
435                return Ok(HeartbeatResponse::Acknowledged(heartbeat_id));
436            }
437
438            return Err(Error::exchange("Heartbeat acknowledgment was invalid"));
439        }
440
441        if response.status.as_u16() == 400
442            && let Ok(wire) = wire
443            && let Some(heartbeat_id) = next_id(wire)
444        {
445            return Ok(HeartbeatResponse::Resynchronize(heartbeat_id));
446        }
447
448        Err(Error::from_status_code(
449            response.status.as_u16(),
450            &response.body,
451        ))
452    }
453
454    /// Fetches all open orders matching the given parameters (auto-paginated).
455    pub async fn get_orders(&self, params: GetOrdersParams) -> Result<Vec<PolymarketOpenOrder>> {
456        let initial_cursor = params
457            .next_cursor
458            .clone()
459            .unwrap_or_else(|| CURSOR_START.to_string());
460        let protocol = CursorProtocol::<Infallible>::clob(PATH_ORDERS, initial_cursor, CURSOR_END);
461        let paginator = Paginator::new(PATH_ORDERS, protocol, CollectAll::new());
462        let completed = paginator
463            .run(
464                |position| {
465                    let mut request = params.clone();
466                    request.next_cursor =
467                        position.as_ref().map(|cursor| cursor.as_ref().to_string());
468                    async move {
469                        let page: PaginatedResponse<PolymarketOpenOrder> =
470                            self.send_get(PATH_ORDERS, Some(&request), true).await?;
471                        Ok(FetchOutcome::Page {
472                            rows: page.data,
473                            wire: page.next_cursor,
474                        })
475                    }
476                },
477                |e| Error::decode(e.to_string()),
478            )
479            .await?;
480
481        match completed.completion {
482            Completion::WireExhausted => Ok(completed.output),
483            Completion::Stopped(never) => match never {},
484        }
485    }
486
487    /// Fetches a single order by ID, returning `None` for empty/null responses.
488    pub async fn get_order_optional(&self, order_id: &str) -> Result<Option<PolymarketOpenOrder>> {
489        let path = format!("/data/order/{order_id}");
490        self.send_get_optional::<(), _>(&path, None::<&()>, true)
491            .await
492    }
493
494    /// Fetches a single order by ID.
495    ///
496    /// Returns an error if the order is not found (empty/null response).
497    pub async fn get_order(&self, order_id: &str) -> Result<PolymarketOpenOrder> {
498        self.get_order_optional(order_id)
499            .await?
500            .ok_or_else(|| Error::decode(format!("Order {order_id} not found (empty response)")))
501    }
502
503    /// Fetches all trades matching the given parameters (auto-paginated).
504    pub async fn get_trades(&self, params: GetTradesParams) -> Result<Vec<PolymarketTradeReport>> {
505        let initial_cursor = params
506            .next_cursor
507            .clone()
508            .unwrap_or_else(|| CURSOR_START.to_string());
509        let protocol = CursorProtocol::<Infallible>::clob(PATH_TRADES, initial_cursor, CURSOR_END);
510        let paginator = Paginator::new(PATH_TRADES, protocol, CollectAll::new());
511        let completed = paginator
512            .run(
513                |position| {
514                    let mut request = params.clone();
515                    request.next_cursor =
516                        position.as_ref().map(|cursor| cursor.as_ref().to_string());
517                    async move {
518                        let page: PaginatedResponse<PolymarketTradeReport> =
519                            self.send_get(PATH_TRADES, Some(&request), true).await?;
520                        Ok(FetchOutcome::Page {
521                            rows: page.data,
522                            wire: page.next_cursor,
523                        })
524                    }
525                },
526                |e| Error::decode(e.to_string()),
527            )
528            .await?;
529
530        match completed.completion {
531            Completion::WireExhausted => Ok(completed.output),
532            Completion::Stopped(never) => match never {},
533        }
534    }
535
536    /// Fetches strict V2 balance and allowance evidence for the given parameters.
537    ///
538    /// The response must contain a plural spender map with canonical, unique EVM addresses.
539    /// A non-null legacy singular allowance is rejected as conflicting authority. Internal
540    /// balance-only consumers use the private projection instead.
541    pub async fn get_balance_allowance(
542        &self,
543        params: GetBalanceAllowanceParams,
544    ) -> Result<BalanceAllowance> {
545        self.send_get(PATH_BALANCE_ALLOWANCE, Some(&params), true)
546            .await
547    }
548
549    /// Fetches balance for internal account refresh and market-buy fee adjustment without consuming
550    /// allowance evidence.
551    ///
552    /// Allowance fields are intentionally ignored and cannot grant authority through this return
553    /// type.
554    pub(crate) async fn get_balance(&self, params: GetBalanceAllowanceParams) -> Result<Decimal> {
555        let response: BalanceResponse = self
556            .send_get(PATH_BALANCE_ALLOWANCE, Some(&params), true)
557            .await?;
558        Ok(response.balance)
559    }
560
561    /// Refreshes the CLOB backend's cached balance and allowance data.
562    pub async fn update_balance_allowance(&self, params: GetBalanceAllowanceParams) -> Result<()> {
563        self.send_get_optional::<_, serde_json::Value>(
564            PATH_BALANCE_ALLOWANCE_UPDATE,
565            Some(&params),
566            true,
567        )
568        .await?;
569        Ok(())
570    }
571
572    /// Submits a single signed order to the exchange.
573    pub async fn post_order(
574        &self,
575        order: &PolymarketOrder,
576        order_type: PolymarketOrderType,
577        post_only: bool,
578    ) -> Result<OrderResponse> {
579        let owner = self.credential.api_key().to_string();
580        let body = PostOrderBody {
581            order,
582            owner: &owner,
583            order_type,
584            post_only,
585        };
586        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
587        self.send_post(PATH_POST_ORDER, body_bytes, 1).await
588    }
589
590    /// Submits a batch of signed orders to the exchange.
591    ///
592    /// Each entry is `(order, order_type, post_only)`.
593    pub async fn post_orders(
594        &self,
595        orders: &[(&PolymarketOrder, PolymarketOrderType, bool)],
596    ) -> Result<Vec<OrderResponse>> {
597        let owner = self.credential.api_key().to_string();
598        let entries: Vec<PostOrderBody<'_>> = orders
599            .iter()
600            .map(|(order, order_type, post_only)| PostOrderBody {
601                order,
602                owner: &owner,
603                order_type: *order_type,
604                post_only: *post_only,
605            })
606            .collect();
607        let body_bytes = serde_json::to_vec(&entries).map_err(Error::Serde)?;
608        let cost = batch_cost(PATH_POST_ORDERS, entries.len())?;
609        self.send_post(PATH_POST_ORDERS, body_bytes, cost).await
610    }
611
612    /// Cancels a single order by ID.
613    pub async fn cancel_order(&self, order_id: &str) -> Result<CancelResponse> {
614        let body = CancelOrderBody { order_id };
615        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
616        self.send_delete(PATH_POST_ORDER, Some(body_bytes), 1).await
617    }
618
619    /// Cancels multiple orders by ID.
620    pub async fn cancel_orders(&self, order_ids: &[&str]) -> Result<BatchCancelResponse> {
621        let body_bytes = serde_json::to_vec(order_ids).map_err(Error::Serde)?;
622        let cost = batch_cost(PATH_POST_ORDERS, order_ids.len())?;
623        self.send_delete(PATH_POST_ORDERS, Some(body_bytes), cost)
624            .await
625    }
626
627    pub(crate) async fn cancel_batch_limit(&self) -> usize {
628        cancel_batch_limit(self.rate_limiter.burst(TradingBucket::Cancel).await)
629    }
630
631    /// Cancels all open orders.
632    pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
633        self.send_delete_with_cancel_debit(PATH_CANCEL_ALL, None)
634            .await
635    }
636
637    /// Cancels all orders for a specific market.
638    pub async fn cancel_market_orders(
639        &self,
640        params: CancelMarketOrdersParams,
641    ) -> Result<BatchCancelResponse> {
642        let body_bytes = serde_json::to_vec(&params).map_err(Error::Serde)?;
643        self.send_delete_with_cancel_debit(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
644            .await
645    }
646
647    /// Fetches the tick size for a token from the CLOB API.
648    pub async fn get_tick_size(&self, token_id: &str) -> Result<TickSizeResponse> {
649        let params = [("token_id", token_id)];
650        self.send_get("/tick-size", Some(&params), false).await
651    }
652
653    /// Fetches the fee rate (in basis points) for a token from the CLOB API.
654    pub async fn get_fee_rate(&self, token_id: &str) -> Result<FeeRateResponse> {
655        let params = [("token_id", token_id)];
656        self.send_get("/fee-rate", Some(&params), false).await
657    }
658
659    /// Fetches the order book for a token from the CLOB API (public endpoint).
660    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
661        let params = [("token_id", token_id)];
662        self.send_get("/book", Some(&params), false).await
663    }
664}
665
666/// Provides an unauthenticated HTTP client for public CLOB endpoints.
667///
668/// Unlike [`PolymarketClobHttpClient`], this client does not require credentials
669/// and is suitable for the data client which only needs public market data.
670#[derive(Debug, Clone)]
671pub struct PolymarketClobPublicClient {
672    client: HttpClient,
673    base_url: String,
674}
675
676impl PolymarketClobPublicClient {
677    /// Creates a new [`PolymarketClobPublicClient`].
678    ///
679    /// # Errors
680    ///
681    /// Returns an error if the HTTP client cannot be created.
682    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
683        Self::new_with_proxy(base_url, timeout_secs, None)
684    }
685
686    /// Creates a new public client with an optional validated proxy URL.
687    ///
688    /// # Errors
689    ///
690    /// Returns an error if the HTTP client cannot be created.
691    pub fn new_with_proxy(
692        base_url: Option<String>,
693        timeout_secs: u64,
694        proxy_url: Option<ProxyUrl>,
695    ) -> StdResult<Self, HttpClientError> {
696        Ok(Self {
697            client: HttpClient::builder()
698                .headers(HashMap::from([
699                    (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
700                    ("Content-Type".to_string(), "application/json".to_string()),
701                ]))
702                .timeout_secs(timeout_secs)
703                .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
704                .build()?,
705            base_url: base_url
706                .unwrap_or_else(|| clob_http_url().to_string())
707                .trim_end_matches('/')
708                .to_string(),
709        })
710    }
711
712    /// Fetches the order book for a token from the CLOB API.
713    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
714        let params = [("token_id", token_id)];
715        let url = format!("{}/book", self.base_url);
716        let response = self
717            .client
718            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
719            .await
720            .map_err(Error::from_http_client)?;
721
722        if response.status.is_success() {
723            serde_json::from_slice(&response.body).map_err(Error::Serde)
724        } else {
725            Err(Error::from_status_code(
726                response.status.as_u16(),
727                &response.body,
728            ))
729        }
730    }
731
732    /// Fetches a single market by condition ID from the CLOB API.
733    pub async fn get_market(&self, condition_id: &str) -> Result<ClobMarketResponse> {
734        let url = format!("{}/markets/{condition_id}", self.base_url);
735        let response = self
736            .client
737            .request_with_params(Method::GET, url, None::<&()>, None, None, None, None)
738            .await
739            .map_err(Error::from_http_client)?;
740
741        if response.status.is_success() {
742            serde_json::from_slice(&response.body).map_err(Error::Serde)
743        } else {
744            Err(Error::from_status_code(
745                response.status.as_u16(),
746                &response.body,
747            ))
748        }
749    }
750
751    /// Requests an order book snapshot and builds an [`OrderBook`].
752    pub async fn request_book_snapshot(
753        &self,
754        instrument_id: InstrumentId,
755        token_id: &str,
756        price_precision: u8,
757        size_precision: u8,
758    ) -> anyhow::Result<OrderBook> {
759        let resp = self
760            .get_book(token_id)
761            .await
762            .map_err(|e| anyhow::anyhow!(e))?;
763
764        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
765
766        for (i, level) in resp.bids.iter().enumerate() {
767            let price = parse_price(&level.price, price_precision)?;
768            let size = parse_quantity(&level.size, size_precision)?;
769            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
770            book.add(order, 0, i as u64, Default::default());
771        }
772
773        let bids_len = resp.bids.len();
774        for (i, level) in resp.asks.iter().enumerate() {
775            let price = parse_price(&level.price, price_precision)?;
776            let size = parse_quantity(&level.size, size_precision)?;
777            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
778            book.add(order, 0, (bids_len + i) as u64, Default::default());
779        }
780
781        log::debug!(
782            "Fetched order book for {} with {} bids and {} asks",
783            instrument_id,
784            resp.bids.len(),
785            resp.asks.len(),
786        );
787
788        Ok(book)
789    }
790}
791
792fn batch_cost(endpoint: &'static str, len: usize) -> Result<u32> {
793    let cost = u32::try_from(len)
794        .map_err(|_| Error::bad_request(format!("{endpoint} batch length exceeds u32")))?;
795    if cost == 0 {
796        return Err(Error::bad_request(format!(
797            "{endpoint} batch must not be empty"
798        )));
799    }
800    Ok(cost)
801}
802
803fn cancel_batch_limit(burst: u32) -> usize {
804    usize::try_from(burst)
805        .unwrap_or(usize::MAX)
806        .min(CLOB_CANCEL_BATCH_LIMIT)
807}
808
809fn canceled_count(response: &BatchCancelResponse) -> u32 {
810    u32::try_from(response.canceled.len()).unwrap_or(u32::MAX)
811}
812
813#[cfg(test)]
814mod tests {
815    use nautilus_model::{
816        enums::{BookType, OrderSide},
817        identifiers::InstrumentId,
818        types::{Price, Quantity},
819    };
820    use rstest::rstest;
821
822    use super::*;
823    use crate::http::models::{ClobBookLevel, ClobBookResponse};
824
825    fn build_book_from_response(resp: &ClobBookResponse) -> OrderBook {
826        let instrument_id = InstrumentId::from("TEST.POLYMARKET");
827        let price_precision = 2u8;
828        let size_precision = 2u8;
829        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
830
831        for (i, level) in resp.bids.iter().enumerate() {
832            let price = parse_price(&level.price, price_precision).unwrap();
833            let size = parse_quantity(&level.size, size_precision).unwrap();
834            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
835            book.add(order, 0, i as u64, Default::default());
836        }
837
838        let bids_len = resp.bids.len();
839        for (i, level) in resp.asks.iter().enumerate() {
840            let price = parse_price(&level.price, price_precision).unwrap();
841            let size = parse_quantity(&level.size, size_precision).unwrap();
842            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
843            book.add(order, 0, (bids_len + i) as u64, Default::default());
844        }
845
846        book
847    }
848
849    #[rstest]
850    fn test_build_order_book_from_clob_response() {
851        let resp = ClobBookResponse {
852            bids: vec![
853                ClobBookLevel {
854                    price: "0.48".to_string(),
855                    size: "100.00".to_string(),
856                },
857                ClobBookLevel {
858                    price: "0.49".to_string(),
859                    size: "200.00".to_string(),
860                },
861                ClobBookLevel {
862                    price: "0.50".to_string(),
863                    size: "150.00".to_string(),
864                },
865            ],
866            asks: vec![
867                ClobBookLevel {
868                    price: "0.51".to_string(),
869                    size: "120.00".to_string(),
870                },
871                ClobBookLevel {
872                    price: "0.52".to_string(),
873                    size: "180.00".to_string(),
874                },
875            ],
876        };
877
878        let book = build_book_from_response(&resp);
879
880        assert_eq!(book.instrument_id, InstrumentId::from("TEST.POLYMARKET"));
881        assert_eq!(book.book_type, BookType::L2_MBP);
882        assert_eq!(book.best_bid_price(), Some(Price::from("0.50")));
883        assert_eq!(book.best_ask_price(), Some(Price::from("0.51")));
884        assert_eq!(book.best_bid_size(), Some(Quantity::from("150.00")));
885        assert_eq!(book.best_ask_size(), Some(Quantity::from("120.00")));
886        assert_eq!(book.bids(None).count(), 3);
887        assert_eq!(book.asks(None).count(), 2);
888    }
889
890    #[rstest]
891    fn test_build_order_book_empty_response() {
892        let resp = ClobBookResponse {
893            bids: vec![],
894            asks: vec![],
895        };
896
897        let book = build_book_from_response(&resp);
898
899        assert!(book.best_bid_price().is_none());
900        assert!(book.best_ask_price().is_none());
901    }
902
903    #[rstest]
904    fn test_batch_cost_uses_entry_count_and_rejects_empty_batch() {
905        assert_eq!(batch_cost(PATH_POST_ORDERS, 15).unwrap(), 15);
906        assert_eq!(
907            batch_cost(PATH_POST_ORDERS, 0).unwrap_err().to_string(),
908            "bad request: /orders batch must not be empty"
909        );
910    }
911
912    #[rstest]
913    fn test_canceled_count_uses_only_successful_cancellations() {
914        let response = BatchCancelResponse {
915            canceled: vec!["order-1".to_string(), "order-2".to_string()],
916            not_canceled: ahash::AHashMap::from_iter([(
917                "order-3".to_string(),
918                Some("already canceled".to_string()),
919            )]),
920        };
921
922        assert_eq!(canceled_count(&response), 2);
923    }
924
925    #[rstest]
926    #[case::standard(120, 120)]
927    #[case::silver(600, 600)]
928    #[case::gold(1_200, 1_000)]
929    #[case::elite(1_800, 1_000)]
930    fn test_cancel_batch_limit_uses_tier_burst_and_venue_ceiling(
931        #[case] burst: u32,
932        #[case] expected: usize,
933    ) {
934        assert_eq!(cancel_batch_limit(burst), expected);
935    }
936}