1use 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#[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 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(×tamp, 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 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 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(¶ms), 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 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 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 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(¶ms), 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 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(¶ms), 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 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 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 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 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 pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
415 self.send_delete(PATH_CANCEL_ALL, None).await
416 }
417
418 pub async fn cancel_market_orders(
420 &self,
421 params: CancelMarketOrdersParams,
422 ) -> Result<BatchCancelResponse> {
423 let body_bytes = serde_json::to_vec(¶ms).map_err(Error::Serde)?;
424 self.send_delete(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
425 .await
426 }
427
428 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(¶ms), false).await
432 }
433
434 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(¶ms), false).await
438 }
439
440 pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
442 let params = [("token_id", token_id)];
443 self.send_get("/book", Some(¶ms), false).await
444 }
445}
446
447#[derive(Debug, Clone)]
452pub struct PolymarketClobPublicClient {
453 client: HttpClient,
454 base_url: String,
455}
456
457impl PolymarketClobPublicClient {
458 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 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(¶ms), 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 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}