1use std::{
19 collections::HashMap, convert::Infallible, result::Result as StdResult, str::from_utf8,
20 sync::Arc,
21};
22
23use nautilus_core::{
24 consts::NAUTILUS_USER_AGENT,
25 time::{AtomicTime, get_atomic_clock_realtime},
26};
27use nautilus_model::{
28 data::BookOrder,
29 enums::{BookType, OrderSide},
30 identifiers::InstrumentId,
31 orderbook::OrderBook,
32};
33use nautilus_network::{
34 http::{HttpClient, HttpClientError, Method, USER_AGENT},
35 websocket::proxy::ProxyUrl,
36};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize, de::DeserializeOwned};
39
40use crate::{
41 common::{
42 credential::Credential, enums::PolymarketOrderType, parse::deserialize_decimal_from_str,
43 urls::clob_http_url,
44 },
45 http::{
46 error::{Error, Result},
47 models::{
48 ClobBookResponse, ClobMarketResponse, FeeRateResponse, PolymarketOpenOrder,
49 PolymarketOrder, PolymarketTradeReport, TickSizeResponse,
50 },
51 pagination::{CollectAll, Completion, CursorProtocol, FetchOutcome, Paginator},
52 query::{
53 BalanceAllowance, BatchCancelResponse, CancelMarketOrdersParams, CancelResponse,
54 ClobVersionResponse, GetBalanceAllowanceParams, GetOrdersParams, GetTradesParams,
55 OrderResponse, PaginatedResponse,
56 },
57 rate_limits::{PolymarketRateLimiter, RateLimitHeaders, TradingBucket},
58 },
59 websocket::parse::{parse_price, parse_quantity},
60};
61
62const CURSOR_START: &str = "MA==";
63const CURSOR_END: &str = "LTE=";
64
65const PATH_ORDERS: &str = "/data/orders";
66const PATH_TRADES: &str = "/data/trades";
67const PATH_VERSION: &str = "/version";
68const PATH_BALANCE_ALLOWANCE: &str = "/balance-allowance";
69const PATH_BALANCE_ALLOWANCE_UPDATE: &str = "/balance-allowance/update";
70const PATH_POST_ORDER: &str = "/order";
71const PATH_POST_ORDERS: &str = "/orders";
72const PATH_CANCEL_ALL: &str = "/cancel-all";
73const PATH_CANCEL_MARKET_ORDERS: &str = "/cancel-market-orders";
74const PATH_HEARTBEATS: &str = "/v1/heartbeats";
75
76const CLOB_CANCEL_BATCH_LIMIT: usize = 1_000;
77
78#[derive(Serialize)]
79#[serde(rename_all = "camelCase")]
80struct PostOrderBody<'a> {
81 order: &'a PolymarketOrder,
82 owner: &'a str,
83 order_type: PolymarketOrderType,
84 #[serde(skip_serializing_if = "std::ops::Not::not")]
85 post_only: bool,
86}
87
88#[derive(Serialize)]
89struct CancelOrderBody<'a> {
90 #[serde(rename = "orderID")]
91 order_id: &'a str,
92}
93
94#[derive(Serialize)]
95struct HeartbeatRequest<'a> {
96 heartbeat_id: &'a str,
97}
98
99#[derive(Deserialize)]
100struct HeartbeatWireResponse {
101 heartbeat_id: Option<String>,
102}
103
104#[derive(Deserialize)]
105struct BalanceResponse {
106 #[serde(deserialize_with = "deserialize_decimal_from_str")]
107 balance: Decimal,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum HeartbeatResponse {
113 Acknowledged(String),
115 Resynchronize(String),
117}
118
119#[derive(Debug, Clone)]
125pub struct PolymarketClobHttpClient {
126 client: HttpClient,
127 rate_limiter: Arc<PolymarketRateLimiter>,
128 base_url: String,
129 credential: Credential,
130 address: String,
131 clock: &'static AtomicTime,
132}
133
134impl PolymarketClobHttpClient {
135 pub fn new(
141 credential: Credential,
142 address: String,
143 base_url: Option<String>,
144 timeout_secs: u64,
145 ) -> StdResult<Self, HttpClientError> {
146 Self::new_with_proxy(credential, address, base_url, timeout_secs, None)
147 }
148
149 pub fn new_with_proxy(
155 credential: Credential,
156 address: String,
157 base_url: Option<String>,
158 timeout_secs: u64,
159 proxy_url: Option<ProxyUrl>,
160 ) -> StdResult<Self, HttpClientError> {
161 let rate_limiter = PolymarketRateLimiter::for_signer(&address);
162 Ok(Self {
163 client: HttpClient::builder()
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 HashMap::from([
182 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
183 ("Content-Type".to_string(), "application/json".to_string()),
184 ])
185 }
186
187 fn url(&self, path: &str) -> String {
188 format!("{}{path}", self.base_url)
189 }
190
191 fn timestamp(&self) -> String {
192 (self.clock.get_time_ns().as_u64() / 1_000_000_000).to_string()
193 }
194
195 fn auth_headers(&self, method: &str, path: &str, body: &str) -> HashMap<String, String> {
196 let timestamp = self.timestamp();
197 let signature = self.credential.sign(×tamp, method, path, body);
198
199 HashMap::from([
200 ("POLY_ADDRESS".to_string(), self.address.clone()),
201 ("POLY_SIGNATURE".to_string(), signature),
202 ("POLY_TIMESTAMP".to_string(), timestamp),
203 (
204 "POLY_API_KEY".to_string(),
205 self.credential.api_key().to_string(),
206 ),
207 (
208 "POLY_PASSPHRASE".to_string(),
209 self.credential.passphrase().to_string(),
210 ),
211 ])
212 }
213
214 async fn send_get<P: Serialize, T: DeserializeOwned>(
215 &self,
216 path: &str,
217 params: Option<&P>,
218 auth: bool,
219 ) -> Result<T> {
220 let headers = if auth {
221 Some(self.auth_headers("GET", path, ""))
222 } else {
223 None
224 };
225 let url = self.url(path);
226 let response = self
227 .client
228 .request_with_params(Method::GET, url, params, headers, None, None, None)
229 .await
230 .map_err(Error::from_http_client)?;
231
232 if response.status.is_success() {
233 serde_json::from_slice(&response.body).map_err(Error::Serde)
234 } else {
235 Err(Error::from_status_code(
236 response.status.as_u16(),
237 &response.body,
238 ))
239 }
240 }
241
242 async fn send_get_optional<P: Serialize, T: DeserializeOwned>(
245 &self,
246 path: &str,
247 params: Option<&P>,
248 auth: bool,
249 ) -> Result<Option<T>> {
250 let headers = if auth {
251 Some(self.auth_headers("GET", path, ""))
252 } else {
253 None
254 };
255 let url = self.url(path);
256 let response = self
257 .client
258 .request_with_params(Method::GET, url, params, headers, None, None, None)
259 .await
260 .map_err(Error::from_http_client)?;
261
262 if response.status.is_success() {
263 if response.body.is_empty() || response.body.as_ref() == b"null" {
264 Ok(None)
265 } else {
266 serde_json::from_slice(&response.body)
267 .map(Some)
268 .map_err(Error::Serde)
269 }
270 } else {
271 Err(Error::from_status_code(
272 response.status.as_u16(),
273 &response.body,
274 ))
275 }
276 }
277
278 async fn send_post<T: DeserializeOwned>(
279 &self,
280 path: &'static str,
281 body_bytes: Vec<u8>,
282 cost: u32,
283 ) -> Result<T> {
284 self.send_trading(
285 Method::POST,
286 path,
287 Some(body_bytes),
288 TradingBucket::Order,
289 cost,
290 |_| 0,
291 )
292 .await
293 }
294
295 async fn send_delete<T: DeserializeOwned>(
296 &self,
297 path: &'static str,
298 body_bytes: Option<Vec<u8>>,
299 cost: u32,
300 ) -> Result<T> {
301 self.send_trading(
302 Method::DELETE,
303 path,
304 body_bytes,
305 TradingBucket::Cancel,
306 cost,
307 |_| 0,
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 )
325 .await
326 }
327
328 async fn send_trading<T: DeserializeOwned>(
329 &self,
330 method: Method,
331 path: &'static str,
332 body_bytes: Option<Vec<u8>>,
333 bucket: TradingBucket,
334 cost: u32,
335 post_response_cost: impl FnOnce(&T) -> u32,
336 ) -> Result<T> {
337 self.rate_limiter.acquire(path, bucket, cost).await?;
338
339 let body_str = body_bytes
340 .as_deref()
341 .map(|b| from_utf8(b).map_err(|e| Error::decode(format!("UTF-8 error: {e}"))))
342 .transpose()?
343 .unwrap_or("");
344 let headers = Some(self.auth_headers(method.as_str(), path, body_str));
345 let url = self.url(path);
346 let response = self
347 .client
348 .request(method, url, None, headers, body_bytes, None, None)
349 .await
350 .map_err(Error::from_http_client)?;
351 let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
352
353 if response.status.is_success() {
354 let decoded = serde_json::from_slice(&response.body);
355 let post_response_cost = decoded.as_ref().map_or(0, post_response_cost);
356 self.rate_limiter
357 .observe_response(
358 path,
359 bucket,
360 cost,
361 post_response_cost,
362 &rate_limit_headers,
363 false,
364 )
365 .await;
366 decoded.map_err(Error::Serde)
367 } else {
368 let rate_limited = response.status.as_u16() == 429;
369 self.rate_limiter
370 .observe_response(path, bucket, cost, 0, &rate_limit_headers, rate_limited)
371 .await;
372
373 if rate_limited {
374 Err(Error::rate_limit_from_body(
375 path,
376 cost,
377 rate_limit_headers.retry_after_ms(),
378 &response.body,
379 rate_limit_headers.has_signer_headers(),
380 ))
381 } else {
382 Err(Error::from_status_code(
383 response.status.as_u16(),
384 &response.body,
385 ))
386 }
387 }
388 }
389
390 pub async fn get_version(&self) -> Result<ClobVersionResponse> {
392 self.send_get::<(), _>(PATH_VERSION, None, false).await
393 }
394
395 pub async fn post_heartbeat(&self, heartbeat_id: &str) -> Result<HeartbeatResponse> {
397 let body = HeartbeatRequest { heartbeat_id };
398 let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
399 let body_str =
400 from_utf8(&body_bytes).map_err(|e| Error::decode(format!("UTF-8 error: {e}")))?;
401 let headers = Some(self.auth_headers("POST", PATH_HEARTBEATS, body_str));
402 let response = self
403 .client
404 .request(
405 Method::POST,
406 self.url(PATH_HEARTBEATS),
407 None,
408 headers,
409 Some(body_bytes),
410 None,
411 None,
412 )
413 .await
414 .map_err(Error::from_http_client)?;
415
416 if response.status.as_u16() == 429 {
417 let rate_limit_headers = RateLimitHeaders::parse(&response.headers);
418 return Err(Error::rate_limit_from_body(
419 PATH_HEARTBEATS,
420 0,
421 rate_limit_headers.retry_after_ms(),
422 &response.body,
423 rate_limit_headers.has_signer_headers(),
424 ));
425 }
426
427 let wire = serde_json::from_slice::<HeartbeatWireResponse>(&response.body);
428 let next_id = |wire: HeartbeatWireResponse| {
429 wire.heartbeat_id
430 .filter(|heartbeat_id| !heartbeat_id.is_empty())
431 };
432
433 if response.status.is_success() {
434 if let Some(heartbeat_id) = next_id(wire.map_err(Error::Serde)?) {
435 return Ok(HeartbeatResponse::Acknowledged(heartbeat_id));
436 }
437
438 return Err(Error::exchange("Heartbeat acknowledgment was invalid"));
439 }
440
441 if response.status.as_u16() == 400
442 && let Ok(wire) = wire
443 && let Some(heartbeat_id) = next_id(wire)
444 {
445 return Ok(HeartbeatResponse::Resynchronize(heartbeat_id));
446 }
447
448 Err(Error::from_status_code(
449 response.status.as_u16(),
450 &response.body,
451 ))
452 }
453
454 pub async fn get_orders(&self, params: GetOrdersParams) -> Result<Vec<PolymarketOpenOrder>> {
456 let initial_cursor = params
457 .next_cursor
458 .clone()
459 .unwrap_or_else(|| CURSOR_START.to_string());
460 let protocol = CursorProtocol::<Infallible>::clob(PATH_ORDERS, initial_cursor, CURSOR_END);
461 let paginator = Paginator::new(PATH_ORDERS, protocol, CollectAll::new());
462 let completed = paginator
463 .run(
464 |position| {
465 let mut request = params.clone();
466 request.next_cursor =
467 position.as_ref().map(|cursor| cursor.as_ref().to_string());
468 async move {
469 let page: PaginatedResponse<PolymarketOpenOrder> =
470 self.send_get(PATH_ORDERS, Some(&request), true).await?;
471 Ok(FetchOutcome::Page {
472 rows: page.data,
473 wire: page.next_cursor,
474 })
475 }
476 },
477 |e| Error::decode(e.to_string()),
478 )
479 .await?;
480
481 match completed.completion {
482 Completion::WireExhausted => Ok(completed.output),
483 Completion::Stopped(never) => match never {},
484 }
485 }
486
487 pub async fn get_order_optional(&self, order_id: &str) -> Result<Option<PolymarketOpenOrder>> {
489 let path = format!("/data/order/{order_id}");
490 self.send_get_optional::<(), _>(&path, None::<&()>, true)
491 .await
492 }
493
494 pub async fn get_order(&self, order_id: &str) -> Result<PolymarketOpenOrder> {
498 self.get_order_optional(order_id)
499 .await?
500 .ok_or_else(|| Error::decode(format!("Order {order_id} not found (empty response)")))
501 }
502
503 pub async fn get_trades(&self, params: GetTradesParams) -> Result<Vec<PolymarketTradeReport>> {
505 let initial_cursor = params
506 .next_cursor
507 .clone()
508 .unwrap_or_else(|| CURSOR_START.to_string());
509 let protocol = CursorProtocol::<Infallible>::clob(PATH_TRADES, initial_cursor, CURSOR_END);
510 let paginator = Paginator::new(PATH_TRADES, protocol, CollectAll::new());
511 let completed = paginator
512 .run(
513 |position| {
514 let mut request = params.clone();
515 request.next_cursor =
516 position.as_ref().map(|cursor| cursor.as_ref().to_string());
517 async move {
518 let page: PaginatedResponse<PolymarketTradeReport> =
519 self.send_get(PATH_TRADES, Some(&request), true).await?;
520 Ok(FetchOutcome::Page {
521 rows: page.data,
522 wire: page.next_cursor,
523 })
524 }
525 },
526 |e| Error::decode(e.to_string()),
527 )
528 .await?;
529
530 match completed.completion {
531 Completion::WireExhausted => Ok(completed.output),
532 Completion::Stopped(never) => match never {},
533 }
534 }
535
536 pub async fn get_balance_allowance(
542 &self,
543 params: GetBalanceAllowanceParams,
544 ) -> Result<BalanceAllowance> {
545 self.send_get(PATH_BALANCE_ALLOWANCE, Some(¶ms), true)
546 .await
547 }
548
549 pub(crate) async fn get_balance(&self, params: GetBalanceAllowanceParams) -> Result<Decimal> {
555 let response: BalanceResponse = self
556 .send_get(PATH_BALANCE_ALLOWANCE, Some(¶ms), true)
557 .await?;
558 Ok(response.balance)
559 }
560
561 pub async fn update_balance_allowance(&self, params: GetBalanceAllowanceParams) -> Result<()> {
563 self.send_get_optional::<_, serde_json::Value>(
564 PATH_BALANCE_ALLOWANCE_UPDATE,
565 Some(¶ms),
566 true,
567 )
568 .await?;
569 Ok(())
570 }
571
572 pub async fn post_order(
574 &self,
575 order: &PolymarketOrder,
576 order_type: PolymarketOrderType,
577 post_only: bool,
578 ) -> Result<OrderResponse> {
579 let owner = self.credential.api_key().to_string();
580 let body = PostOrderBody {
581 order,
582 owner: &owner,
583 order_type,
584 post_only,
585 };
586 let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
587 self.send_post(PATH_POST_ORDER, body_bytes, 1).await
588 }
589
590 pub async fn post_orders(
594 &self,
595 orders: &[(&PolymarketOrder, PolymarketOrderType, bool)],
596 ) -> Result<Vec<OrderResponse>> {
597 let owner = self.credential.api_key().to_string();
598 let entries: Vec<PostOrderBody<'_>> = orders
599 .iter()
600 .map(|(order, order_type, post_only)| PostOrderBody {
601 order,
602 owner: &owner,
603 order_type: *order_type,
604 post_only: *post_only,
605 })
606 .collect();
607 let body_bytes = serde_json::to_vec(&entries).map_err(Error::Serde)?;
608 let cost = batch_cost(PATH_POST_ORDERS, entries.len())?;
609 self.send_post(PATH_POST_ORDERS, body_bytes, cost).await
610 }
611
612 pub async fn cancel_order(&self, order_id: &str) -> Result<CancelResponse> {
614 let body = CancelOrderBody { order_id };
615 let body_bytes = serde_json::to_vec(&body).map_err(Error::Serde)?;
616 self.send_delete(PATH_POST_ORDER, Some(body_bytes), 1).await
617 }
618
619 pub async fn cancel_orders(&self, order_ids: &[&str]) -> Result<BatchCancelResponse> {
621 let body_bytes = serde_json::to_vec(order_ids).map_err(Error::Serde)?;
622 let cost = batch_cost(PATH_POST_ORDERS, order_ids.len())?;
623 self.send_delete(PATH_POST_ORDERS, Some(body_bytes), cost)
624 .await
625 }
626
627 pub(crate) async fn cancel_batch_limit(&self) -> usize {
628 cancel_batch_limit(self.rate_limiter.burst(TradingBucket::Cancel).await)
629 }
630
631 pub async fn cancel_all(&self) -> Result<BatchCancelResponse> {
633 self.send_delete_with_cancel_debit(PATH_CANCEL_ALL, None)
634 .await
635 }
636
637 pub async fn cancel_market_orders(
639 &self,
640 params: CancelMarketOrdersParams,
641 ) -> Result<BatchCancelResponse> {
642 let body_bytes = serde_json::to_vec(¶ms).map_err(Error::Serde)?;
643 self.send_delete_with_cancel_debit(PATH_CANCEL_MARKET_ORDERS, Some(body_bytes))
644 .await
645 }
646
647 pub async fn get_tick_size(&self, token_id: &str) -> Result<TickSizeResponse> {
649 let params = [("token_id", token_id)];
650 self.send_get("/tick-size", Some(¶ms), false).await
651 }
652
653 pub async fn get_fee_rate(&self, token_id: &str) -> Result<FeeRateResponse> {
655 let params = [("token_id", token_id)];
656 self.send_get("/fee-rate", Some(¶ms), false).await
657 }
658
659 pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
661 let params = [("token_id", token_id)];
662 self.send_get("/book", Some(¶ms), false).await
663 }
664}
665
666#[derive(Debug, Clone)]
671pub struct PolymarketClobPublicClient {
672 client: HttpClient,
673 base_url: String,
674}
675
676impl PolymarketClobPublicClient {
677 pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
683 Self::new_with_proxy(base_url, timeout_secs, None)
684 }
685
686 pub fn new_with_proxy(
692 base_url: Option<String>,
693 timeout_secs: u64,
694 proxy_url: Option<ProxyUrl>,
695 ) -> StdResult<Self, HttpClientError> {
696 Ok(Self {
697 client: HttpClient::builder()
698 .headers(HashMap::from([
699 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
700 ("Content-Type".to_string(), "application/json".to_string()),
701 ]))
702 .timeout_secs(timeout_secs)
703 .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
704 .build()?,
705 base_url: base_url
706 .unwrap_or_else(|| clob_http_url().to_string())
707 .trim_end_matches('/')
708 .to_string(),
709 })
710 }
711
712 pub async fn get_book(&self, token_id: &str) -> Result<ClobBookResponse> {
714 let params = [("token_id", token_id)];
715 let url = format!("{}/book", self.base_url);
716 let response = self
717 .client
718 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
719 .await
720 .map_err(Error::from_http_client)?;
721
722 if response.status.is_success() {
723 serde_json::from_slice(&response.body).map_err(Error::Serde)
724 } else {
725 Err(Error::from_status_code(
726 response.status.as_u16(),
727 &response.body,
728 ))
729 }
730 }
731
732 pub async fn get_market(&self, condition_id: &str) -> Result<ClobMarketResponse> {
734 let url = format!("{}/markets/{condition_id}", self.base_url);
735 let response = self
736 .client
737 .request_with_params(Method::GET, url, None::<&()>, None, None, None, None)
738 .await
739 .map_err(Error::from_http_client)?;
740
741 if response.status.is_success() {
742 serde_json::from_slice(&response.body).map_err(Error::Serde)
743 } else {
744 Err(Error::from_status_code(
745 response.status.as_u16(),
746 &response.body,
747 ))
748 }
749 }
750
751 pub async fn request_book_snapshot(
753 &self,
754 instrument_id: InstrumentId,
755 token_id: &str,
756 price_precision: u8,
757 size_precision: u8,
758 ) -> anyhow::Result<OrderBook> {
759 let resp = self
760 .get_book(token_id)
761 .await
762 .map_err(|e| anyhow::anyhow!(e))?;
763
764 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
765
766 for (i, level) in resp.bids.iter().enumerate() {
767 let price = parse_price(&level.price, price_precision)?;
768 let size = parse_quantity(&level.size, size_precision)?;
769 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
770 book.add(order, 0, i as u64, Default::default());
771 }
772
773 let bids_len = resp.bids.len();
774 for (i, level) in resp.asks.iter().enumerate() {
775 let price = parse_price(&level.price, price_precision)?;
776 let size = parse_quantity(&level.size, size_precision)?;
777 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
778 book.add(order, 0, (bids_len + i) as u64, Default::default());
779 }
780
781 log::debug!(
782 "Fetched order book for {} with {} bids and {} asks",
783 instrument_id,
784 resp.bids.len(),
785 resp.asks.len(),
786 );
787
788 Ok(book)
789 }
790}
791
792fn batch_cost(endpoint: &'static str, len: usize) -> Result<u32> {
793 let cost = u32::try_from(len)
794 .map_err(|_| Error::bad_request(format!("{endpoint} batch length exceeds u32")))?;
795 if cost == 0 {
796 return Err(Error::bad_request(format!(
797 "{endpoint} batch must not be empty"
798 )));
799 }
800 Ok(cost)
801}
802
803fn cancel_batch_limit(burst: u32) -> usize {
804 usize::try_from(burst)
805 .unwrap_or(usize::MAX)
806 .min(CLOB_CANCEL_BATCH_LIMIT)
807}
808
809fn canceled_count(response: &BatchCancelResponse) -> u32 {
810 u32::try_from(response.canceled.len()).unwrap_or(u32::MAX)
811}
812
813#[cfg(test)]
814mod tests {
815 use nautilus_model::{
816 enums::{BookType, OrderSide},
817 identifiers::InstrumentId,
818 types::{Price, Quantity},
819 };
820 use rstest::rstest;
821
822 use super::*;
823 use crate::http::models::{ClobBookLevel, ClobBookResponse};
824
825 fn build_book_from_response(resp: &ClobBookResponse) -> OrderBook {
826 let instrument_id = InstrumentId::from("TEST.POLYMARKET");
827 let price_precision = 2u8;
828 let size_precision = 2u8;
829 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
830
831 for (i, level) in resp.bids.iter().enumerate() {
832 let price = parse_price(&level.price, price_precision).unwrap();
833 let size = parse_quantity(&level.size, size_precision).unwrap();
834 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
835 book.add(order, 0, i as u64, Default::default());
836 }
837
838 let bids_len = resp.bids.len();
839 for (i, level) in resp.asks.iter().enumerate() {
840 let price = parse_price(&level.price, price_precision).unwrap();
841 let size = parse_quantity(&level.size, size_precision).unwrap();
842 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
843 book.add(order, 0, (bids_len + i) as u64, Default::default());
844 }
845
846 book
847 }
848
849 #[rstest]
850 fn test_build_order_book_from_clob_response() {
851 let resp = ClobBookResponse {
852 bids: vec![
853 ClobBookLevel {
854 price: "0.48".to_string(),
855 size: "100.00".to_string(),
856 },
857 ClobBookLevel {
858 price: "0.49".to_string(),
859 size: "200.00".to_string(),
860 },
861 ClobBookLevel {
862 price: "0.50".to_string(),
863 size: "150.00".to_string(),
864 },
865 ],
866 asks: vec![
867 ClobBookLevel {
868 price: "0.51".to_string(),
869 size: "120.00".to_string(),
870 },
871 ClobBookLevel {
872 price: "0.52".to_string(),
873 size: "180.00".to_string(),
874 },
875 ],
876 };
877
878 let book = build_book_from_response(&resp);
879
880 assert_eq!(book.instrument_id, InstrumentId::from("TEST.POLYMARKET"));
881 assert_eq!(book.book_type, BookType::L2_MBP);
882 assert_eq!(book.best_bid_price(), Some(Price::from("0.50")));
883 assert_eq!(book.best_ask_price(), Some(Price::from("0.51")));
884 assert_eq!(book.best_bid_size(), Some(Quantity::from("150.00")));
885 assert_eq!(book.best_ask_size(), Some(Quantity::from("120.00")));
886 assert_eq!(book.bids(None).count(), 3);
887 assert_eq!(book.asks(None).count(), 2);
888 }
889
890 #[rstest]
891 fn test_build_order_book_empty_response() {
892 let resp = ClobBookResponse {
893 bids: vec![],
894 asks: vec![],
895 };
896
897 let book = build_book_from_response(&resp);
898
899 assert!(book.best_bid_price().is_none());
900 assert!(book.best_ask_price().is_none());
901 }
902
903 #[rstest]
904 fn test_batch_cost_uses_entry_count_and_rejects_empty_batch() {
905 assert_eq!(batch_cost(PATH_POST_ORDERS, 15).unwrap(), 15);
906 assert_eq!(
907 batch_cost(PATH_POST_ORDERS, 0).unwrap_err().to_string(),
908 "bad request: /orders batch must not be empty"
909 );
910 }
911
912 #[rstest]
913 fn test_canceled_count_uses_only_successful_cancellations() {
914 let response = BatchCancelResponse {
915 canceled: vec!["order-1".to_string(), "order-2".to_string()],
916 not_canceled: ahash::AHashMap::from_iter([(
917 "order-3".to_string(),
918 Some("already canceled".to_string()),
919 )]),
920 };
921
922 assert_eq!(canceled_count(&response), 2);
923 }
924
925 #[rstest]
926 #[case::standard(120, 120)]
927 #[case::silver(600, 600)]
928 #[case::gold(1_200, 1_000)]
929 #[case::elite(1_800, 1_000)]
930 fn test_cancel_batch_limit_uses_tier_burst_and_venue_ceiling(
931 #[case] burst: u32,
932 #[case] expected: usize,
933 ) {
934 assert_eq!(cancel_batch_limit(burst), expected);
935 }
936}