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::{collections::HashMap, result::Result as StdResult, str::from_utf8};
19
20use nautilus_core::{
21    consts::NAUTILUS_USER_AGENT,
22    time::{AtomicTime, get_atomic_clock_realtime},
23};
24use nautilus_model::{
25    data::BookOrder,
26    enums::{BookType, OrderSide},
27    identifiers::InstrumentId,
28    orderbook::OrderBook,
29};
30use nautilus_network::http::{HttpClient, HttpClientError, Method, USER_AGENT};
31use serde::{Serialize, de::DeserializeOwned};
32
33use crate::{
34    common::{credential::Credential, enums::PolymarketOrderType, urls::clob_http_url},
35    http::{
36        error::{Error, Result},
37        models::{
38            ClobBookResponse, FeeRateResponse, PolymarketOpenOrder, PolymarketOrder,
39            PolymarketTradeReport, TickSizeResponse,
40        },
41        query::{
42            BalanceAllowance, BatchCancelResponse, CancelMarketOrdersParams, CancelResponse,
43            GetBalanceAllowanceParams, GetOrdersParams, GetTradesParams, OrderResponse,
44            PaginatedResponse,
45        },
46        rate_limits::POLYMARKET_CLOB_REST_QUOTA,
47    },
48    websocket::parse::{parse_price, parse_quantity},
49};
50
51const CURSOR_START: &str = "MA==";
52const CURSOR_END: &str = "LTE=";
53
54const PATH_ORDERS: &str = "/data/orders";
55const PATH_TRADES: &str = "/data/trades";
56const PATH_BALANCE_ALLOWANCE: &str = "/balance-allowance";
57const PATH_POST_ORDER: &str = "/order";
58const PATH_POST_ORDERS: &str = "/orders";
59const PATH_CANCEL_ALL: &str = "/cancel-all";
60const PATH_CANCEL_MARKET_ORDERS: &str = "/cancel-market-orders";
61
62#[derive(Serialize)]
63#[serde(rename_all = "camelCase")]
64struct PostOrderBody<'a> {
65    order: &'a PolymarketOrder,
66    owner: &'a str,
67    order_type: PolymarketOrderType,
68    #[serde(skip_serializing_if = "std::ops::Not::not")]
69    post_only: bool,
70}
71
72#[derive(Serialize)]
73struct CancelOrderBody<'a> {
74    #[serde(rename = "orderID")]
75    order_id: &'a str,
76}
77
78/// Provides an authenticated HTTP client for the Polymarket CLOB REST API.
79///
80/// Handles HTTP transport, L2 HMAC-SHA256 auth signing, pagination, and raw
81/// API calls that closely match Polymarket endpoint specifications.
82/// Credential is always present: the CLOB API requires authentication.
83#[derive(Debug, Clone)]
84pub struct PolymarketClobHttpClient {
85    client: HttpClient,
86    base_url: String,
87    credential: Credential,
88    address: String,
89    clock: &'static AtomicTime,
90}
91
92impl PolymarketClobHttpClient {
93    /// Creates a new authenticated [`PolymarketClobHttpClient`].
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the HTTP client cannot be created.
98    pub fn new(
99        credential: Credential,
100        address: String,
101        base_url: Option<String>,
102        timeout_secs: u64,
103    ) -> StdResult<Self, HttpClientError> {
104        Ok(Self {
105            client: HttpClient::new(
106                Self::default_headers(),
107                vec![],
108                vec![],
109                Some(*POLYMARKET_CLOB_REST_QUOTA),
110                Some(timeout_secs),
111                None,
112            )?,
113            base_url: base_url
114                .unwrap_or_else(|| clob_http_url().to_string())
115                .trim_end_matches('/')
116                .to_string(),
117            credential,
118            address,
119            clock: get_atomic_clock_realtime(),
120        })
121    }
122
123    fn default_headers() -> HashMap<String, String> {
124        HashMap::from([
125            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
126            ("Content-Type".to_string(), "application/json".to_string()),
127        ])
128    }
129
130    fn url(&self, path: &str) -> String {
131        format!("{}{path}", self.base_url)
132    }
133
134    fn timestamp(&self) -> String {
135        (self.clock.get_time_ns().as_u64() / 1_000_000_000).to_string()
136    }
137
138    fn auth_headers(&self, method: &str, path: &str, body: &str) -> HashMap<String, String> {
139        let timestamp = self.timestamp();
140        let signature = self.credential.sign(&timestamp, method, path, body);
141
142        HashMap::from([
143            ("POLY_ADDRESS".to_string(), self.address.clone()),
144            ("POLY_SIGNATURE".to_string(), signature),
145            ("POLY_TIMESTAMP".to_string(), timestamp),
146            (
147                "POLY_API_KEY".to_string(),
148                self.credential.api_key().to_string(),
149            ),
150            (
151                "POLY_PASSPHRASE".to_string(),
152                self.credential.passphrase().to_string(),
153            ),
154        ])
155    }
156
157    async fn send_get<P: Serialize, T: DeserializeOwned>(
158        &self,
159        path: &str,
160        params: Option<&P>,
161        auth: bool,
162    ) -> Result<T> {
163        let headers = if auth {
164            Some(self.auth_headers("GET", path, ""))
165        } else {
166            None
167        };
168        let url = self.url(path);
169        let response = self
170            .client
171            .request_with_params(Method::GET, url, params, headers, None, None, None)
172            .await
173            .map_err(Error::from_http_client)?;
174
175        if response.status.is_success() {
176            serde_json::from_slice(&response.body).map_err(Error::Serde)
177        } else {
178            Err(Error::from_status_code(
179                response.status.as_u16(),
180                &response.body,
181            ))
182        }
183    }
184
185    /// Like [`send_get`] but returns `Ok(None)` for empty or `null` response bodies
186    /// instead of a serde deserialization error.
187    async fn send_get_optional<P: Serialize, T: DeserializeOwned>(
188        &self,
189        path: &str,
190        params: Option<&P>,
191        auth: bool,
192    ) -> Result<Option<T>> {
193        let headers = if auth {
194            Some(self.auth_headers("GET", path, ""))
195        } else {
196            None
197        };
198        let url = self.url(path);
199        let response = self
200            .client
201            .request_with_params(Method::GET, url, params, headers, None, None, None)
202            .await
203            .map_err(Error::from_http_client)?;
204
205        if response.status.is_success() {
206            if response.body.is_empty() || response.body.as_ref() == b"null" {
207                Ok(None)
208            } else {
209                serde_json::from_slice(&response.body)
210                    .map(Some)
211                    .map_err(Error::Serde)
212            }
213        } else {
214            Err(Error::from_status_code(
215                response.status.as_u16(),
216                &response.body,
217            ))
218        }
219    }
220
221    async fn send_post<T: DeserializeOwned>(&self, path: &str, body_bytes: Vec<u8>) -> Result<T> {
222        let body_str =
223            from_utf8(&body_bytes).map_err(|e| Error::decode(format!("UTF-8 error: {e}")))?;
224        let headers = Some(self.auth_headers("POST", path, body_str));
225        let url = self.url(path);
226        let response = self
227            .client
228            .request(
229                Method::POST,
230                url,
231                None,
232                headers,
233                Some(body_bytes),
234                None,
235                None,
236            )
237            .await
238            .map_err(Error::from_http_client)?;
239
240        if response.status.is_success() {
241            serde_json::from_slice(&response.body).map_err(Error::Serde)
242        } else {
243            Err(Error::from_status_code(
244                response.status.as_u16(),
245                &response.body,
246            ))
247        }
248    }
249
250    async fn send_delete<T: DeserializeOwned>(
251        &self,
252        path: &str,
253        body_bytes: Option<Vec<u8>>,
254    ) -> Result<T> {
255        let body_str = body_bytes
256            .as_deref()
257            .map(|b| from_utf8(b).map_err(|e| Error::decode(format!("UTF-8 error: {e}"))))
258            .transpose()?
259            .unwrap_or("");
260        let headers = Some(self.auth_headers("DELETE", path, body_str));
261        let url = self.url(path);
262        let response = self
263            .client
264            .request(Method::DELETE, url, None, headers, body_bytes, None, None)
265            .await
266            .map_err(Error::from_http_client)?;
267
268        if response.status.is_success() {
269            serde_json::from_slice(&response.body).map_err(Error::Serde)
270        } else {
271            Err(Error::from_status_code(
272                response.status.as_u16(),
273                &response.body,
274            ))
275        }
276    }
277
278    /// Fetches all open orders matching the given parameters (auto-paginated).
279    pub async fn get_orders(
280        &self,
281        mut params: GetOrdersParams,
282    ) -> Result<Vec<PolymarketOpenOrder>> {
283        if params.next_cursor.is_none() {
284            params.next_cursor = Some(CURSOR_START.to_string());
285        }
286        let mut all = Vec::new();
287
288        loop {
289            let page: PaginatedResponse<PolymarketOpenOrder> =
290                self.send_get(PATH_ORDERS, Some(&params), true).await?;
291            all.extend(page.data);
292            if page.next_cursor == CURSOR_END {
293                break;
294            }
295            params.next_cursor = Some(page.next_cursor);
296        }
297        Ok(all)
298    }
299
300    /// Fetches a single open order by ID, returning `None` for empty/null responses.
301    pub async fn get_order_optional(&self, order_id: &str) -> Result<Option<PolymarketOpenOrder>> {
302        let path = format!("/data/order/{order_id}");
303        self.send_get_optional::<(), _>(&path, None::<&()>, true)
304            .await
305    }
306
307    /// Fetches a single open order by ID.
308    ///
309    /// Returns an error if the order is not found (empty/null response).
310    pub async fn get_order(&self, order_id: &str) -> Result<PolymarketOpenOrder> {
311        self.get_order_optional(order_id)
312            .await?
313            .ok_or_else(|| Error::decode(format!("Order {order_id} not found (empty response)")))
314    }
315
316    /// Fetches all trades matching the given parameters (auto-paginated).
317    pub async fn get_trades(
318        &self,
319        mut params: GetTradesParams,
320    ) -> Result<Vec<PolymarketTradeReport>> {
321        if params.next_cursor.is_none() {
322            params.next_cursor = Some(CURSOR_START.to_string());
323        }
324        let mut all = Vec::new();
325
326        loop {
327            let page: PaginatedResponse<PolymarketTradeReport> =
328                self.send_get(PATH_TRADES, Some(&params), true).await?;
329            all.extend(page.data);
330            if page.next_cursor == CURSOR_END {
331                break;
332            }
333            params.next_cursor = Some(page.next_cursor);
334        }
335        Ok(all)
336    }
337
338    /// Fetches balance and allowance for the given parameters.
339    pub async fn get_balance_allowance(
340        &self,
341        params: GetBalanceAllowanceParams,
342    ) -> Result<BalanceAllowance> {
343        let headers = Some(self.auth_headers("GET", PATH_BALANCE_ALLOWANCE, ""));
344        let url = self.url(PATH_BALANCE_ALLOWANCE);
345        let response = self
346            .client
347            .request_with_params(Method::GET, url, Some(&params), headers, None, None, None)
348            .await
349            .map_err(Error::from_http_client)?;
350
351        if response.status.is_success() {
352            serde_json::from_slice(&response.body).map_err(Error::Serde)
353        } else {
354            Err(Error::from_status_code(
355                response.status.as_u16(),
356                &response.body,
357            ))
358        }
359    }
360
361    /// Submits a single signed order to the exchange.
362    pub async fn post_order(
363        &self,
364        order: &PolymarketOrder,
365        order_type: PolymarketOrderType,
366        post_only: bool,
367    ) -> Result<OrderResponse> {
368        let owner = self.credential.api_key().to_string();
369        let body = PostOrderBody {
370            order,
371            owner: &owner,
372            order_type,
373            post_only,
374        };
375        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
376        self.send_post(PATH_POST_ORDER, body_bytes).await
377    }
378
379    /// Submits a batch of signed orders to the exchange.
380    ///
381    /// Each entry is `(order, order_type, post_only)`.
382    pub async fn post_orders(
383        &self,
384        orders: &[(&PolymarketOrder, PolymarketOrderType, bool)],
385    ) -> Result<Vec<OrderResponse>> {
386        let owner = self.credential.api_key().to_string();
387        let entries: Vec<PostOrderBody<'_>> = orders
388            .iter()
389            .map(|(order, order_type, post_only)| PostOrderBody {
390                order,
391                owner: &owner,
392                order_type: *order_type,
393                post_only: *post_only,
394            })
395            .collect();
396        let body_bytes = serde_json::to_vec(&entries).map_err(Error::Serde)?;
397        self.send_post(PATH_POST_ORDERS, body_bytes).await
398    }
399
400    /// Cancels a single order by ID.
401    pub async fn cancel_order(&self, order_id: &str) -> Result<CancelResponse> {
402        let body = CancelOrderBody { order_id };
403        let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
404        self.send_delete("/order", Some(body_bytes)).await
405    }
406
407    /// Cancels multiple orders by ID.
408    pub async fn cancel_orders(&self, order_ids: &[&str]) -> Result<BatchCancelResponse> {
409        let body_bytes = serde_json::to_vec(order_ids).map_err(Error::Serde)?;
410        self.send_delete("/orders", Some(body_bytes)).await
411    }
412
413    /// Cancels all open orders.
414    pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
415        self.send_delete(PATH_CANCEL_ALL, None).await
416    }
417
418    /// Cancels all orders for a specific market.
419    pub async fn cancel_market_orders(
420        &self,
421        params: CancelMarketOrdersParams,
422    ) -> Result<BatchCancelResponse> {
423        let body_bytes = serde_json::to_vec(&params).map_err(Error::Serde)?;
424        self.send_delete(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
425            .await
426    }
427
428    /// Fetches the tick size for a token from the CLOB API.
429    pub async fn get_tick_size(&self, token_id: &str) -> Result<TickSizeResponse> {
430        let params = [("token_id", token_id)];
431        self.send_get("/tick-size", Some(&params), false).await
432    }
433
434    /// Fetches the fee rate (in basis points) for a token from the CLOB API.
435    pub async fn get_fee_rate(&self, token_id: &str) -> Result<FeeRateResponse> {
436        let params = [("token_id", token_id)];
437        self.send_get("/fee-rate", Some(&params), false).await
438    }
439
440    /// Fetches the order book for a token from the CLOB API (public endpoint).
441    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
442        let params = [("token_id", token_id)];
443        self.send_get("/book", Some(&params), false).await
444    }
445}
446
447/// Provides an unauthenticated HTTP client for public CLOB endpoints.
448///
449/// Unlike [`PolymarketClobHttpClient`], this client does not require credentials
450/// and is suitable for the data client which only needs public market data.
451#[derive(Debug, Clone)]
452pub struct PolymarketClobPublicClient {
453    client: HttpClient,
454    base_url: String,
455}
456
457impl PolymarketClobPublicClient {
458    /// Creates a new [`PolymarketClobPublicClient`].
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if the HTTP client cannot be created.
463    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
464        Ok(Self {
465            client: HttpClient::new(
466                HashMap::from([
467                    (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
468                    ("Content-Type".to_string(), "application/json".to_string()),
469                ]),
470                vec![],
471                vec![],
472                Some(*POLYMARKET_CLOB_REST_QUOTA),
473                Some(timeout_secs),
474                None,
475            )?,
476            base_url: base_url
477                .unwrap_or_else(|| clob_http_url().to_string())
478                .trim_end_matches('/')
479                .to_string(),
480        })
481    }
482
483    /// Fetches the order book for a token from the CLOB API.
484    pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
485        let params = [("token_id", token_id)];
486        let url = format!("{}/book", self.base_url);
487        let response = self
488            .client
489            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
490            .await
491            .map_err(Error::from_http_client)?;
492
493        if response.status.is_success() {
494            serde_json::from_slice(&response.body).map_err(Error::Serde)
495        } else {
496            Err(Error::from_status_code(
497                response.status.as_u16(),
498                &response.body,
499            ))
500        }
501    }
502
503    /// Requests an order book snapshot and builds an [`OrderBook`].
504    pub async fn request_book_snapshot(
505        &self,
506        instrument_id: InstrumentId,
507        token_id: &str,
508        price_precision: u8,
509        size_precision: u8,
510    ) -> anyhow::Result<OrderBook> {
511        let resp = self
512            .get_book(token_id)
513            .await
514            .map_err(|e| anyhow::anyhow!(e))?;
515
516        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
517
518        for (i, level) in resp.bids.iter().enumerate() {
519            let price = parse_price(&level.price, price_precision)?;
520            let size = parse_quantity(&level.size, size_precision)?;
521            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
522            book.add(order, 0, i as u64, Default::default());
523        }
524
525        let bids_len = resp.bids.len();
526        for (i, level) in resp.asks.iter().enumerate() {
527            let price = parse_price(&level.price, price_precision)?;
528            let size = parse_quantity(&level.size, size_precision)?;
529            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
530            book.add(order, 0, (bids_len + i) as u64, Default::default());
531        }
532
533        log::info!(
534            "Fetched order book for {} with {} bids and {} asks",
535            instrument_id,
536            resp.bids.len(),
537            resp.asks.len(),
538        );
539
540        Ok(book)
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use nautilus_model::{
547        enums::{BookType, OrderSide},
548        identifiers::InstrumentId,
549        types::{Price, Quantity},
550    };
551    use rstest::rstest;
552
553    use super::*;
554    use crate::http::models::{ClobBookLevel, ClobBookResponse};
555
556    fn build_book_from_response(resp: &ClobBookResponse) -> OrderBook {
557        let instrument_id = InstrumentId::from("TEST.POLYMARKET");
558        let price_precision = 2u8;
559        let size_precision = 2u8;
560        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
561
562        for (i, level) in resp.bids.iter().enumerate() {
563            let price = parse_price(&level.price, price_precision).unwrap();
564            let size = parse_quantity(&level.size, size_precision).unwrap();
565            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
566            book.add(order, 0, i as u64, Default::default());
567        }
568
569        let bids_len = resp.bids.len();
570        for (i, level) in resp.asks.iter().enumerate() {
571            let price = parse_price(&level.price, price_precision).unwrap();
572            let size = parse_quantity(&level.size, size_precision).unwrap();
573            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
574            book.add(order, 0, (bids_len + i) as u64, Default::default());
575        }
576
577        book
578    }
579
580    #[rstest]
581    fn test_build_order_book_from_clob_response() {
582        let resp = ClobBookResponse {
583            bids: vec![
584                ClobBookLevel {
585                    price: "0.48".to_string(),
586                    size: "100.00".to_string(),
587                },
588                ClobBookLevel {
589                    price: "0.49".to_string(),
590                    size: "200.00".to_string(),
591                },
592                ClobBookLevel {
593                    price: "0.50".to_string(),
594                    size: "150.00".to_string(),
595                },
596            ],
597            asks: vec![
598                ClobBookLevel {
599                    price: "0.51".to_string(),
600                    size: "120.00".to_string(),
601                },
602                ClobBookLevel {
603                    price: "0.52".to_string(),
604                    size: "180.00".to_string(),
605                },
606            ],
607        };
608
609        let book = build_book_from_response(&resp);
610
611        assert_eq!(book.instrument_id, InstrumentId::from("TEST.POLYMARKET"));
612        assert_eq!(book.book_type, BookType::L2_MBP);
613        assert_eq!(book.best_bid_price(), Some(Price::from("0.50")));
614        assert_eq!(book.best_ask_price(), Some(Price::from("0.51")));
615        assert_eq!(book.best_bid_size(), Some(Quantity::from("150.00")));
616        assert_eq!(book.best_ask_size(), Some(Quantity::from("120.00")));
617        assert_eq!(book.bids(None).count(), 3);
618        assert_eq!(book.asks(None).count(), 2);
619    }
620
621    #[rstest]
622    fn test_build_order_book_empty_response() {
623        let resp = ClobBookResponse {
624            bids: vec![],
625            asks: vec![],
626        };
627
628        let book = build_book_from_response(&resp);
629
630        assert!(book.best_bid_price().is_none());
631        assert!(book.best_ask_price().is_none());
632    }
633}