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