1use 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#[derive(Clone, Debug, PartialEq, Eq)]
111pub enum HeartbeatResponse {
112 Acknowledged(String),
114 Resynchronize(String),
116}
117
118#[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 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 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(×tamp, 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 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 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 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 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 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 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 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 pub async fn get_balance_allowance(
555 &self,
556 params: GetBalanceAllowanceParams,
557 ) -> Result<BalanceAllowance> {
558 self.send_get(PATH_BALANCE_ALLOWANCE, Some(¶ms), true)
559 .await
560 }
561
562 pub(crate) async fn get_balance(&self, params: GetBalanceAllowanceParams) -> Result<Decimal> {
568 let response: BalanceResponse = self
569 .send_get(PATH_BALANCE_ALLOWANCE, Some(¶ms), true)
570 .await?;
571 Ok(response.balance)
572 }
573
574 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(¶ms),
579 true,
580 )
581 .await?;
582 Ok(())
583 }
584
585 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 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 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 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 pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
665 self.send_delete_with_cancel_debit(PATH_CANCEL_ALL, None)
666 .await
667 }
668
669 pub async fn cancel_market_orders(
671 &self,
672 params: CancelMarketOrdersParams,
673 ) -> Result<BatchCancelResponse> {
674 let body_bytes = serde_json::to_vec(¶ms).map_err(Error::Serde)?;
675 self.send_delete_with_cancel_debit(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
676 .await
677 }
678
679 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(¶ms), false).await
683 }
684
685 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(¶ms), false).await
689 }
690
691 pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
693 let params = [("token_id", token_id)];
694 self.send_get("/book", Some(¶ms), false).await
695 }
696}
697
698#[derive(Debug, Clone)]
703pub struct PolymarketClobPublicClient {
704 client: HttpClient,
705 base_url: String,
706}
707
708impl PolymarketClobPublicClient {
709 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 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 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(¶ms), None, None, None, None)
752 .await
753 .map_err(Error::from_http_client)?;
754
755 decode_response(&response)
756 }
757
758 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 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(¶ms), None, None, None, None)
777 .await
778 .map_err(Error::from_http_client)?;
779
780 decode_response(&response)
781 }
782
783 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}