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