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        guard: impl Fn() -> Result<()>,
281    ) -> Result<T> {
282        self.send_trading(
283            Method::POST,
284            path,
285            Some(body_bytes),
286            TradingBucket::Order,
287            cost,
288            |_| 0,
289            guard,
290        )
291        .await
292    }
293
294    async fn send_delete<T: DeserializeOwned>(
295        &self,
296        path: &'static str,
297        body_bytes: Option<Vec<u8>>,
298        cost: u32,
299    ) -> Result<T> {
300        self.send_trading(
301            Method::DELETE,
302            path,
303            body_bytes,
304            TradingBucket::Cancel,
305            cost,
306            |_| 0,
307            || Ok(()),
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            || Ok(()),
325        )
326        .await
327    }
328
329    #[expect(
330        clippy::too_many_arguments,
331        reason = "trading requests share rate-limit accounting and admission"
332    )]
333    async fn send_trading<T: DeserializeOwned>(
334        &self,
335        method: Method,
336        path: &'static str,
337        body_bytes: Option<Vec<u8>>,
338        bucket: TradingBucket,
339        cost: u32,
340        post_response_cost: impl FnOnce(&T) -> u32,
341        guard: impl Fn() -> Result<()>,
342    ) -> Result<T> {
343        guard()?;
344        self.rate_limiter.acquire(path, bucket, cost).await?;
345
346        let body_str = body_bytes
347            .as_deref()
348            .map(|b| from_utf8(b).map_err(|e| Error::decode(format!("UTF-8 error: {e}"))))
349            .transpose()?
350            .unwrap_or("");
351        let headers = Some(self.auth_headers(method.as_str(), path, body_str));
352        let url = self.url(path);
353        guard()?;
354        let response = self
355            .client
356            .request(method, url, None, headers, body_bytes, None, None)
357            .await
358            .map_err(Error::from_http_client)?;
359        let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
360
361        if response.status.is_success() {
362            let decoded = serde_json::from_slice(&response.body);
363            let post_response_cost = decoded.as_ref().map_or(0, post_response_cost);
364            self.rate_limiter
365                .observe_response(
366                    path,
367                    bucket,
368                    cost,
369                    post_response_cost,
370                    &rate_limit_headers,
371                    false,
372                )
373                .await;
374            decoded.map_err(Error::Serde)
375        } else {
376            let rate_limited = response.status.as_u16() == 429;
377            self.rate_limiter
378                .observe_response(path, bucket, cost, 0, &rate_limit_headers, rate_limited)
379                .await;
380
381            if rate_limited {
382                Err(Error::rate_limit_from_body(
383                    path,
384                    cost,
385                    rate_limit_headers.retry_after_ms(),
386                    &response.body,
387                    rate_limit_headers.has_signer_headers(),
388                ))
389            } else {
390                Err(Error::from_status_code(
391                    response.status.as_u16(),
392                    &response.body,
393                ))
394            }
395        }
396    }
397
398    /// Returns the CLOB service version reported by the venue.
399    pub async fn get_version(&self) -> Result<ClobVersionResponse> {
400        self.send_get::<(), _>(PATH_VERSION, None, false).await
401    }
402
403    pub(crate) async fn get_market(&self, condition_id: &str) -> Result<ClobMarketResponse> {
404        let path = format!("/markets/{condition_id}");
405        self.send_get::<(), _>(&path, None, false).await
406    }
407
408    /// Sends an authenticated order-safety heartbeat.
409    pub async fn post_heartbeat(&self, heartbeat_id: &str) -> Result<HeartbeatResponse> {
410        let body = HeartbeatRequest { heartbeat_id };
411        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
412        let body_str =
413            from_utf8(&body_bytes).map_err(|e| Error::decode(format!("UTF-8 error: {e}")))?;
414        let headers = Some(self.auth_headers("POST", PATH_HEARTBEATS, body_str));
415        let response = self
416            .client
417            .request(
418                Method::POST,
419                self.url(PATH_HEARTBEATS),
420                None,
421                headers,
422                Some(body_bytes),
423                None,
424                None,
425            )
426            .await
427            .map_err(Error::from_http_client)?;
428
429        if response.status.as_u16() == 429 {
430            let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
431            return Err(Error::rate_limit_from_body(
432                PATH_HEARTBEATS,
433                0,
434                rate_limit_headers.retry_after_ms(),
435                &response.body,
436                rate_limit_headers.has_signer_headers(),
437            ));
438        }
439
440        let wire = serde_json::from_slice::<HeartbeatWireResponse>(&response.body);
441        let next_id = |wire: HeartbeatWireResponse| {
442            wire.heartbeat_id
443                .filter(|heartbeat_id| !heartbeat_id.is_empty())
444        };
445
446        if response.status.is_success() {
447            if let Some(heartbeat_id) = next_id(wire.map_err(Error::Serde)?) {
448                return Ok(HeartbeatResponse::Acknowledged(heartbeat_id));
449            }
450
451            return Err(Error::exchange("Heartbeat acknowledgment was invalid"));
452        }
453
454        if response.status.as_u16() == 400
455            && let Ok(wire) = wire
456            && let Some(heartbeat_id) = next_id(wire)
457        {
458            return Ok(HeartbeatResponse::Resynchronize(heartbeat_id));
459        }
460
461        Err(Error::from_status_code(
462            response.status.as_u16(),
463            &response.body,
464        ))
465    }
466
467    /// Fetches all open orders matching the given parameters (auto-paginated).
468    pub async fn get_orders(&self, params: GetOrdersParams) -> Result<Vec<PolymarketOpenOrder>> {
469        let initial_cursor = params
470            .next_cursor
471            .clone()
472            .unwrap_or_else(|| CURSOR_START.to_string());
473        let protocol = CursorProtocol::<Infallible>::clob(PATH_ORDERS, initial_cursor, CURSOR_END);
474        let paginator = Paginator::new(PATH_ORDERS, protocol, CollectAll::new());
475        let completed = paginator
476            .run(
477                |position| {
478                    let mut request = params.clone();
479                    request.next_cursor =
480                        position.as_ref().map(|cursor| cursor.as_ref().to_string());
481                    async move {
482                        let page: PaginatedResponse<PolymarketOpenOrder> =
483                            self.send_get(PATH_ORDERS, Some(&request), true).await?;
484                        Ok(FetchOutcome::Page {
485                            rows: page.data,
486                            wire: page.next_cursor,
487                        })
488                    }
489                },
490                |e| Error::decode(e.to_string()),
491            )
492            .await?;
493
494        match completed.completion {
495            Completion::WireExhausted => Ok(completed.output),
496            Completion::Stopped(never) => match never {},
497        }
498    }
499
500    /// Fetches a single order by ID, returning `None` for empty/null responses.
501    pub async fn get_order_optional(&self, order_id: &str) -> Result<Option<PolymarketOpenOrder>> {
502        let path = format!("/data/order/{order_id}");
503        self.send_get_optional::<(), _>(&path, None::<&()>, true)
504            .await
505    }
506
507    /// Fetches a single order by ID.
508    ///
509    /// Returns an error if the order is not found (empty/null response).
510    pub async fn get_order(&self, order_id: &str) -> Result<PolymarketOpenOrder> {
511        self.get_order_optional(order_id)
512            .await?
513            .ok_or_else(|| Error::decode(format!("Order {order_id} not found (empty response)")))
514    }
515
516    /// Fetches all trades matching the given parameters (auto-paginated).
517    pub async fn get_trades(&self, params: GetTradesParams) -> Result<Vec<PolymarketTradeReport>> {
518        let initial_cursor = params
519            .next_cursor
520            .clone()
521            .unwrap_or_else(|| CURSOR_START.to_string());
522        let protocol = CursorProtocol::<Infallible>::clob(PATH_TRADES, initial_cursor, CURSOR_END);
523        let paginator = Paginator::new(PATH_TRADES, protocol, CollectAll::new());
524        let completed = paginator
525            .run(
526                |position| {
527                    let mut request = params.clone();
528                    request.next_cursor =
529                        position.as_ref().map(|cursor| cursor.as_ref().to_string());
530                    async move {
531                        let page: PaginatedResponse<PolymarketTradeReport> =
532                            self.send_get(PATH_TRADES, Some(&request), true).await?;
533                        Ok(FetchOutcome::Page {
534                            rows: page.data,
535                            wire: page.next_cursor,
536                        })
537                    }
538                },
539                |e| Error::decode(e.to_string()),
540            )
541            .await?;
542
543        match completed.completion {
544            Completion::WireExhausted => Ok(completed.output),
545            Completion::Stopped(never) => match never {},
546        }
547    }
548
549    /// Fetches strict V2 balance and allowance evidence for the given parameters.
550    ///
551    /// The response must contain a plural spender map with canonical, unique EVM addresses.
552    /// A non-null legacy singular allowance is rejected as conflicting authority. Internal
553    /// balance-only consumers use the private projection instead.
554    pub async fn get_balance_allowance(
555        &self,
556        params: GetBalanceAllowanceParams,
557    ) -> Result<BalanceAllowance> {
558        self.send_get(PATH_BALANCE_ALLOWANCE, Some(&params), true)
559            .await
560    }
561
562    /// Fetches balance for internal account refresh and market-buy fee adjustment without consuming
563    /// allowance evidence.
564    ///
565    /// Allowance fields are intentionally ignored and cannot grant authority through this return
566    /// type.
567    pub(crate) async fn get_balance(&self, params: GetBalanceAllowanceParams) -> Result<Decimal> {
568        let response: BalanceResponse = self
569            .send_get(PATH_BALANCE_ALLOWANCE, Some(&params), true)
570            .await?;
571        Ok(response.balance)
572    }
573
574    /// Refreshes the CLOB backend's cached balance and allowance data.
575    pub async fn update_balance_allowance(&self, params: GetBalanceAllowanceParams) -> Result<()> {
576        self.send_get_optional::<_, serde_json::Value>(
577            PATH_BALANCE_ALLOWANCE_UPDATE,
578            Some(&params),
579            true,
580        )
581        .await?;
582        Ok(())
583    }
584
585    /// Submits a single signed order to the exchange.
586    pub async fn post_order(
587        &self,
588        order: &PolymarketOrder,
589        order_type: PolymarketOrderType,
590        post_only: bool,
591    ) -> Result<OrderResponse> {
592        self.post_order_guarded(order, order_type, post_only, || Ok(()))
593            .await
594    }
595
596    pub(crate) async fn post_order_guarded(
597        &self,
598        order: &PolymarketOrder,
599        order_type: PolymarketOrderType,
600        post_only: bool,
601        guard: impl Fn() -> Result<()>,
602    ) -> Result<OrderResponse> {
603        let body = PostOrderBody {
604            order,
605            owner: self.credential.api_key_str(),
606            order_type,
607            post_only,
608        };
609        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
610        self.send_post(PATH_POST_ORDER, body_bytes, 1, guard).await
611    }
612
613    /// Submits a batch of signed orders to the exchange.
614    ///
615    /// Each entry is `(order, order_type, post_only)`.
616    pub async fn post_orders(
617        &self,
618        orders: &[(&PolymarketOrder, PolymarketOrderType, bool)],
619    ) -> Result<Vec<OrderResponse>> {
620        self.post_orders_guarded(orders, || Ok(())).await
621    }
622
623    pub(crate) async fn post_orders_guarded(
624        &self,
625        orders: &[(&PolymarketOrder, PolymarketOrderType, bool)],
626        guard: impl Fn() -> Result<()>,
627    ) -> Result<Vec<OrderResponse>> {
628        let owner = self.credential.api_key_str();
629        let entries: Vec<PostOrderBody<'_>> = orders
630            .iter()
631            .map(|(order, order_type, post_only)| PostOrderBody {
632                order,
633                owner,
634                order_type: *order_type,
635                post_only: *post_only,
636            })
637            .collect();
638        let body_bytes = serde_json::to_vec(&entries).map_err(Error::Serde)?;
639        let cost = batch_cost(PATH_POST_ORDERS, entries.len())?;
640        self.send_post(PATH_POST_ORDERS, body_bytes, cost, guard)
641            .await
642    }
643
644    /// Cancels a single order by ID.
645    pub async fn cancel_order(&self, order_id: &str) -> Result<CancelResponse> {
646        let body = CancelOrderBody { order_id };
647        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
648        self.send_delete(PATH_POST_ORDER, Some(body_bytes), 1).await
649    }
650
651    /// Cancels multiple orders by ID.
652    pub async fn cancel_orders(&self, order_ids: &[&str]) -> Result<BatchCancelResponse> {
653        let body_bytes = serde_json::to_vec(order_ids).map_err(Error::Serde)?;
654        let cost = batch_cost(PATH_POST_ORDERS, order_ids.len())?;
655        self.send_delete(PATH_POST_ORDERS, Some(body_bytes), cost)
656            .await
657    }
658
659    pub(crate) async fn cancel_batch_limit(&self) -> usize {
660        cancel_batch_limit(self.rate_limiter.burst(TradingBucket::Cancel).await)
661    }
662
663    /// Cancels all open orders.
664    pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
665        self.send_delete_with_cancel_debit(PATH_CANCEL_ALL, None)
666            .await
667    }
668
669    /// Cancels all orders for a specific market.
670    pub async fn cancel_market_orders(
671        &self,
672        params: CancelMarketOrdersParams,
673    ) -> Result<BatchCancelResponse> {
674        let body_bytes = serde_json::to_vec(&params).map_err(Error::Serde)?;
675        self.send_delete_with_cancel_debit(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
676            .await
677    }
678
679    /// Fetches the tick size for a token from the CLOB API.
680    pub async fn get_tick_size(&self, token_id: &str) -> Result<TickSizeResponse> {
681        let params = [("token_id", token_id)];
682        self.send_get("/tick-size", Some(&params), false).await
683    }
684
685    /// Fetches the legacy base fee in basis points, distinct from the effective taker rate.
686    pub async fn get_fee_rate(&self, token_id: &str) -> Result<FeeRateResponse> {
687        let params = [("token_id", token_id)];
688        self.send_get("/fee-rate", Some(&params), false).await
689    }
690
691    /// Fetches the order book for a token from the CLOB API (public endpoint).
692    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
693        let params = [("token_id", token_id)];
694        self.send_get("/book", Some(&params), false).await
695    }
696}
697
698/// Provides an unauthenticated HTTP client for public CLOB endpoints.
699///
700/// Unlike [`PolymarketClobHttpClient`], this client does not require credentials
701/// and is suitable for the data client which only needs public market data.
702#[derive(Debug, Clone)]
703pub struct PolymarketClobPublicClient {
704    client: HttpClient,
705    base_url: String,
706}
707
708impl PolymarketClobPublicClient {
709    /// Creates a new [`PolymarketClobPublicClient`].
710    ///
711    /// # Errors
712    ///
713    /// Returns an error if the HTTP client cannot be created.
714    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
715        Self::new_with_proxy(base_url, timeout_secs, None)
716    }
717
718    /// Creates a new public client with an optional validated proxy URL.
719    ///
720    /// # Errors
721    ///
722    /// Returns an error if the HTTP client cannot be created.
723    pub fn new_with_proxy(
724        base_url: Option<String>,
725        timeout_secs: u64,
726        proxy_url: Option<ProxyUrl>,
727    ) -> StdResult<Self, HttpClientError> {
728        let mut headers: HashMap<String, String> =
729            create_standard_nautilus_headers().into_iter().collect();
730        headers.insert("Content-Type".to_string(), "application/json".to_string());
731
732        Ok(Self {
733            client: HttpClient::builder()
734                .headers(headers)
735                .timeout_secs(timeout_secs)
736                .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
737                .build()?,
738            base_url: base_url
739                .unwrap_or_else(|| clob_http_url().to_string())
740                .trim_end_matches('/')
741                .to_string(),
742        })
743    }
744
745    /// Fetches the order book for a token from the CLOB API.
746    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
747        let params = [("token_id", token_id)];
748        let url = format!("{}/book", self.base_url);
749        let response = self
750            .client
751            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
752            .await
753            .map_err(Error::from_http_client)?;
754
755        decode_response(&response)
756    }
757
758    /// Fetches a single market by condition ID from the CLOB API.
759    pub async fn get_market(&self, condition_id: &str) -> Result<ClobMarketResponse> {
760        let url = format!("{}/markets/{condition_id}", self.base_url);
761        let response = self
762            .client
763            .request_with_params(Method::GET, url, None::<&()>, None, None, None, None)
764            .await
765            .map_err(Error::from_http_client)?;
766
767        decode_response(&response)
768    }
769
770    /// Fetches the legacy base fee in basis points, distinct from the effective taker rate.
771    pub async fn get_fee_rate(&self, token_id: &str) -> Result<FeeRateResponse> {
772        let params = [("token_id", token_id)];
773        let url = format!("{}/fee-rate", self.base_url);
774        let response = self
775            .client
776            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
777            .await
778            .map_err(Error::from_http_client)?;
779
780        decode_response(&response)
781    }
782
783    /// Requests an order book snapshot and builds an [`OrderBook`].
784    pub async fn request_book_snapshot(
785        &self,
786        instrument_id: InstrumentId,
787        token_id: &str,
788        price_precision: u8,
789        size_precision: u8,
790    ) -> anyhow::Result<OrderBook> {
791        let resp = self
792            .get_book(token_id)
793            .await
794            .map_err(|e| anyhow::anyhow!(e))?;
795
796        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
797
798        for (i, level) in resp.bids.iter().enumerate() {
799            let price = parse_price(&level.price, price_precision)?;
800            let size = parse_quantity(&level.size, size_precision)?;
801            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
802            book.add(order, 0, i as u64, Default::default());
803        }
804
805        let bids_len = resp.bids.len();
806        for (i, level) in resp.asks.iter().enumerate() {
807            let price = parse_price(&level.price, price_precision)?;
808            let size = parse_quantity(&level.size, size_precision)?;
809            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
810            book.add(order, 0, (bids_len + i) as u64, Default::default());
811        }
812
813        log::debug!(
814            "Fetched order book for {} with {} bids and {} asks",
815            instrument_id,
816            resp.bids.len(),
817            resp.asks.len(),
818        );
819
820        Ok(book)
821    }
822}
823
824fn batch_cost(endpoint: &'static str, len: usize) -> Result<u32> {
825    let cost = u32::try_from(len)
826        .map_err(|_| Error::bad_request(format!("{endpoint} batch length exceeds u32")))?;
827    if cost == 0 {
828        return Err(Error::bad_request(format!(
829            "{endpoint} batch must not be empty"
830        )));
831    }
832    Ok(cost)
833}
834
835fn cancel_batch_limit(burst: u32) -> usize {
836    usize::try_from(burst)
837        .unwrap_or(usize::MAX)
838        .min(CLOB_CANCEL_BATCH_LIMIT)
839}
840
841fn canceled_count(response: &BatchCancelResponse) -> u32 {
842    u32::try_from(response.canceled.len()).unwrap_or(u32::MAX)
843}
844
845#[cfg(test)]
846mod tests {
847    use nautilus_model::{
848        enums::{BookType, OrderSide},
849        identifiers::InstrumentId,
850        types::{Price, Quantity},
851    };
852    use rstest::rstest;
853
854    use super::*;
855    use crate::http::models::{ClobBookLevel, ClobBookResponse};
856
857    fn build_book_from_response(resp: &ClobBookResponse) -> OrderBook {
858        let instrument_id = InstrumentId::from("TEST.POLYMARKET");
859        let price_precision = 2u8;
860        let size_precision = 2u8;
861        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
862
863        for (i, level) in resp.bids.iter().enumerate() {
864            let price = parse_price(&level.price, price_precision).unwrap();
865            let size = parse_quantity(&level.size, size_precision).unwrap();
866            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
867            book.add(order, 0, i as u64, Default::default());
868        }
869
870        let bids_len = resp.bids.len();
871        for (i, level) in resp.asks.iter().enumerate() {
872            let price = parse_price(&level.price, price_precision).unwrap();
873            let size = parse_quantity(&level.size, size_precision).unwrap();
874            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
875            book.add(order, 0, (bids_len + i) as u64, Default::default());
876        }
877
878        book
879    }
880
881    #[rstest]
882    fn test_build_order_book_from_clob_response() {
883        let resp = ClobBookResponse {
884            bids: vec![
885                ClobBookLevel {
886                    price: "0.48".to_string(),
887                    size: "100.00".to_string(),
888                },
889                ClobBookLevel {
890                    price: "0.49".to_string(),
891                    size: "200.00".to_string(),
892                },
893                ClobBookLevel {
894                    price: "0.50".to_string(),
895                    size: "150.00".to_string(),
896                },
897            ],
898            asks: vec![
899                ClobBookLevel {
900                    price: "0.51".to_string(),
901                    size: "120.00".to_string(),
902                },
903                ClobBookLevel {
904                    price: "0.52".to_string(),
905                    size: "180.00".to_string(),
906                },
907            ],
908        };
909
910        let book = build_book_from_response(&resp);
911
912        assert_eq!(book.instrument_id, InstrumentId::from("TEST.POLYMARKET"));
913        assert_eq!(book.book_type, BookType::L2_MBP);
914        assert_eq!(book.best_bid_price(), Some(Price::from("0.50")));
915        assert_eq!(book.best_ask_price(), Some(Price::from("0.51")));
916        assert_eq!(book.best_bid_size(), Some(Quantity::from("150.00")));
917        assert_eq!(book.best_ask_size(), Some(Quantity::from("120.00")));
918        assert_eq!(book.bids(None).count(), 3);
919        assert_eq!(book.asks(None).count(), 2);
920    }
921
922    #[rstest]
923    fn test_build_order_book_empty_response() {
924        let resp = ClobBookResponse {
925            bids: vec![],
926            asks: vec![],
927        };
928
929        let book = build_book_from_response(&resp);
930
931        assert!(book.best_bid_price().is_none());
932        assert!(book.best_ask_price().is_none());
933    }
934
935    #[rstest]
936    fn test_batch_cost_uses_entry_count_and_rejects_empty_batch() {
937        assert_eq!(batch_cost(PATH_POST_ORDERS, 15).unwrap(), 15);
938        assert_eq!(
939            batch_cost(PATH_POST_ORDERS, 0).unwrap_err().to_string(),
940            "bad request: /orders batch must not be empty"
941        );
942    }
943
944    #[rstest]
945    fn test_canceled_count_uses_only_successful_cancellations() {
946        let response = BatchCancelResponse {
947            canceled: vec!["order-1".to_string(), "order-2".to_string()],
948            not_canceled: ahash::AHashMap::from_iter([(
949                "order-3".to_string(),
950                Some("already canceled".to_string()),
951            )]),
952        };
953
954        assert_eq!(canceled_count(&response), 2);
955    }
956
957    #[rstest]
958    #[case::standard(120, 120)]
959    #[case::silver(600, 600)]
960    #[case::gold(1_200, 1_000)]
961    #[case::elite(1_800, 1_000)]
962    fn test_cancel_batch_limit_uses_tier_burst_and_venue_ceiling(
963        #[case] burst: u32,
964        #[case] expected: usize,
965    ) {
966        assert_eq!(cancel_batch_limit(burst), expected);
967    }
968}