1use std::sync::atomic::{AtomicU64, Ordering};
31
32use nautilus_core::{UnixNanos, string::secret::SecretString, time::get_atomic_clock_realtime};
33use nautilus_model::{
34 enums::{OrderSide, OrderType, TimeInForce},
35 events::OrderDeniedReason,
36 identifiers::VenueOrderId,
37 orders::{Order, OrderAny},
38 types::Price,
39};
40use rust_decimal::{Decimal, RoundingStrategy};
41use ustr::Ustr;
42
43use crate::{
44 common::{
45 consts::{LOT_SIZE_SCALE, POLYMARKET_NAUTILUS_BUILDER_CODE, USDC_DECIMALS},
46 enums::{PolymarketOrderSide, PolymarketOrderType, PolymarketSignatureType},
47 },
48 http::models::PolymarketOrder,
49 signing::eip712::{OrderSigner, order_hash},
50};
51
52pub const ZERO_BYTES32: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";
54
55const GTD_EXPIRATION_BUFFER_SECS: u64 = 180;
57
58#[derive(Debug)]
64pub struct PolymarketOrderBuilder {
65 order_signer: OrderSigner,
66 signer_address: String,
67 maker_address: String,
68 signature_type: PolymarketSignatureType,
69 last_timestamp_ms: AtomicU64,
70}
71
72impl PolymarketOrderBuilder {
73 pub fn new(
75 order_signer: OrderSigner,
76 signer_address: String,
77 maker_address: String,
78 signature_type: PolymarketSignatureType,
79 ) -> Self {
80 Self {
81 order_signer,
82 signer_address,
83 maker_address,
84 signature_type,
85 last_timestamp_ms: AtomicU64::new(0),
86 }
87 }
88
89 fn next_timestamp_ms(&self) -> u64 {
92 let now_ms = get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000;
93
94 loop {
95 let prev = self.last_timestamp_ms.load(Ordering::Relaxed);
96 let candidate = prev.saturating_add(1).max(now_ms);
97
98 if self
99 .last_timestamp_ms
100 .compare_exchange_weak(prev, candidate, Ordering::Relaxed, Ordering::Relaxed)
101 .is_ok()
102 {
103 return candidate;
104 }
105 }
106 }
107
108 #[expect(clippy::too_many_arguments)]
118 pub fn build_limit_order(
119 &self,
120 token_id: &str,
121 side: PolymarketOrderSide,
122 price: Decimal,
123 quantity: Decimal,
124 order_type: PolymarketOrderType,
125 expiration: &str,
126 neg_risk: bool,
127 tick_decimals: u32,
128 ) -> anyhow::Result<PolymarketOrder> {
129 let (maker_amount, taker_amount) =
130 limit_order_maker_taker_amounts(side, price, quantity, order_type, tick_decimals)
131 .map_err(anyhow::Error::msg)?;
132 self.build_and_sign(
133 token_id,
134 side,
135 maker_amount,
136 taker_amount,
137 expiration,
138 neg_risk,
139 )
140 }
141
142 pub(crate) fn build_limit_order_from_collateral(
148 &self,
149 token_id: &str,
150 price: Decimal,
151 amount: Decimal,
152 expiration: &str,
153 neg_risk: bool,
154 tick_decimals: u32,
155 ) -> anyhow::Result<PolymarketOrder> {
156 anyhow::ensure!(
157 price > Decimal::ZERO,
158 "Polymarket collateral-sized limit BUY price must be positive"
159 );
160
161 let (maker_amount, taker_amount) =
162 compute_quote_buy_maker_taker_amounts(price, amount, tick_decimals);
163 anyhow::ensure!(
164 maker_amount > Decimal::ZERO,
165 "Polymarket collateral-sized limit BUY amount {} pUSD truncates to zero at {LOT_SIZE_SCALE} decimal places",
166 amount.normalize(),
167 );
168 anyhow::ensure!(
169 taker_amount > Decimal::ZERO,
170 "Polymarket collateral-sized limit BUY derives a zero share quantity"
171 );
172 anyhow::ensure!(
173 taker_amount * price == maker_amount,
174 "Polymarket collateral-sized limit BUY amount {} pUSD cannot preserve limit price {} after venue quantization",
175 amount.normalize(),
176 price.normalize(),
177 );
178
179 self.build_and_sign(
180 token_id,
181 PolymarketOrderSide::Buy,
182 maker_amount,
183 taker_amount,
184 expiration,
185 neg_risk,
186 )
187 }
188
189 pub fn build_market_order(
197 &self,
198 token_id: &str,
199 side: PolymarketOrderSide,
200 price: Decimal,
201 amount: Decimal,
202 neg_risk: bool,
203 tick_decimals: u32,
204 ) -> anyhow::Result<PolymarketOrder> {
205 if side == PolymarketOrderSide::Buy && amount.trunc_with_scale(LOT_SIZE_SCALE).is_zero() {
206 anyhow::bail!(
207 "Polymarket market BUY amount {} pUSD truncates to zero at {LOT_SIZE_SCALE} decimal places",
208 amount.normalize(),
209 );
210 }
211
212 let (maker_amount, taker_amount) =
213 compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
214 self.build_and_sign(token_id, side, maker_amount, taker_amount, "0", neg_risk)
215 }
216
217 pub fn expected_order_id(
219 &self,
220 order: &PolymarketOrder,
221 neg_risk: bool,
222 ) -> anyhow::Result<VenueOrderId> {
223 let hash = order_hash(order, neg_risk)
224 .map_err(|e| anyhow::anyhow!("Failed to derive order hash: {e}"))?;
225 let order_id = format!("{hash:#x}");
226 Ok(VenueOrderId::from(order_id.as_str()))
227 }
228
229 pub fn validate_limit_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
231 if order.is_reduce_only() {
232 return Err(validation_failed(
233 "Reduce-only orders not supported on Polymarket",
234 ));
235 }
236
237 if order.order_type() != OrderType::Limit {
238 return Err(OrderDeniedReason::UnsupportedOrderType {
239 order_type: order.order_type(),
240 });
241 }
242
243 if order.is_quote_quantity() && order.order_side() == OrderSide::Sell {
244 return Err(OrderDeniedReason::unsupported_quote_quantity());
245 }
246
247 let quantity = signed_limit_order_quantity(order.quantity().as_decimal());
248 if quantity.is_zero() {
249 let denomination = if order.is_quote_quantity() {
250 "pUSD"
251 } else {
252 "shares"
253 };
254 return Err(validation_failed(format!(
255 "Polymarket limit order amount {} {denomination} truncates to zero at {LOT_SIZE_SCALE} decimal places",
256 order.quantity(),
257 )));
258 }
259
260 let price = order
261 .price()
262 .ok_or_else(|| validation_failed("Limit orders require a price"))?;
263
264 let order_type = PolymarketOrderType::try_from(order.time_in_force())
265 .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
266
267 let side = PolymarketOrderSide::from(order.order_side());
268
269 if order.is_post_only()
270 && !matches!(order.time_in_force(), TimeInForce::Gtc | TimeInForce::Gtd)
271 {
272 return Err(OrderDeniedReason::unsupported_post_only());
273 }
274
275 if !order.is_quote_quantity()
276 && side == PolymarketOrderSide::Buy
277 && matches!(
278 order_type,
279 PolymarketOrderType::FAK | PolymarketOrderType::FOK
280 )
281 {
282 let notional = quantity * price.as_decimal();
283 if notional.trunc_with_scale(LOT_SIZE_SCALE).is_zero() {
284 return Err(validation_failed(format!(
285 "Polymarket {order_type} BUY maker amount {} pUSD truncates to zero at {LOT_SIZE_SCALE} decimal places",
286 notional.normalize(),
287 )));
288 }
289 }
290
291 Ok(())
292 }
293
294 pub(crate) fn validate_limit_price(
295 order: &OrderAny,
296 tick_size: Price,
297 ) -> Result<(), OrderDeniedReason> {
298 let price = order
299 .price()
300 .ok_or_else(|| validation_failed("Limit orders require a price"))?;
301 Self::validate_limit_price_value(price, tick_size)
302 }
303
304 pub(crate) fn validate_limit_price_value(
305 price: Price,
306 tick_size: Price,
307 ) -> Result<(), OrderDeniedReason> {
308 let tick = tick_size.as_decimal();
309 let price_decimal = price.as_decimal();
310
311 validate_price(price_decimal, tick, "Limit order price", &price.to_string())
312 .map_err(validation_failed)
313 }
314
315 pub(crate) fn validate_limit_expiration(
316 order: &OrderAny,
317 ts_now: UnixNanos,
318 ) -> Result<(), OrderDeniedReason> {
319 if order.time_in_force() != TimeInForce::Gtd {
320 return Ok(());
321 }
322
323 let expire_time = order
324 .expire_time()
325 .filter(|expire_time| !expire_time.is_zero())
326 .ok_or(OrderDeniedReason::MissingExpireTime)?;
327 let now_secs = ts_now.as_seconds();
328 let expire_secs = expire_time.as_seconds();
329 let minimum_expire_secs = now_secs.saturating_add(GTD_EXPIRATION_BUFFER_SECS);
330
331 if expire_secs < minimum_expire_secs {
332 return Err(validation_failed(format!(
333 "Polymarket GTD expiry must be at least {GTD_EXPIRATION_BUFFER_SECS} seconds in the future"
334 )));
335 }
336
337 Ok(())
338 }
339
340 pub fn validate_market_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
342 if order.is_reduce_only() {
343 return Err(validation_failed(
344 "Reduce-only orders not supported on Polymarket",
345 ));
346 }
347
348 if order.order_type() != OrderType::Market {
349 return Err(OrderDeniedReason::UnsupportedOrderType {
350 order_type: order.order_type(),
351 });
352 }
353
354 match order.order_side() {
357 OrderSide::Buy => {
358 if !order.is_quote_quantity() {
359 return Err(validation_failed(
360 "Market BUY orders require quote_quantity=true (amount in pUSD)",
361 ));
362 }
363 }
364 OrderSide::Sell => {
365 if order.is_quote_quantity() {
366 return Err(OrderDeniedReason::unsupported_quote_quantity());
367 }
368 }
369 }
370
371 if PolymarketOrderType::from_market_time_in_force(order.time_in_force()).is_err() {
372 return Err(OrderDeniedReason::UnsupportedTimeInForce(
373 order.time_in_force(),
374 ));
375 }
376
377 Ok(())
378 }
379
380 pub(crate) fn normalize_market_price(
381 price: Decimal,
382 tick_size: Price,
383 tick_decimals: u32,
384 ) -> Result<Decimal, String> {
385 let tick = tick_size.as_decimal();
386 let price = price.trunc_with_scale(tick_decimals);
387
388 validate_price(
389 price,
390 tick,
391 "Derived market price",
392 &price.normalize().to_string(),
393 )?;
394
395 Ok(price)
396 }
397
398 fn build_and_sign(
399 &self,
400 token_id: &str,
401 side: PolymarketOrderSide,
402 maker_amount: Decimal,
403 taker_amount: Decimal,
404 expiration: &str,
405 neg_risk: bool,
406 ) -> anyhow::Result<PolymarketOrder> {
407 let salt = generate_salt();
408 let timestamp_ms = self.next_timestamp_ms();
409
410 let mut poly_order = PolymarketOrder {
411 salt,
412 maker: self.maker_address.clone(),
413 signer: self.order_signer_address(),
414 token_id: Ustr::from(token_id),
415 maker_amount,
416 taker_amount,
417 side,
418 signature_type: self.signature_type,
419 expiration: expiration.to_string(),
420 timestamp: timestamp_ms.to_string(),
421 metadata: ZERO_BYTES32.to_string(),
422 builder: POLYMARKET_NAUTILUS_BUILDER_CODE.to_string(),
423 signature: SecretString::default(),
424 };
425
426 let signature = self
427 .order_signer
428 .sign_order(&poly_order, neg_risk)
429 .map_err(|e| anyhow::anyhow!("EIP-712 signing failed: {e}"))?;
430 poly_order.signature = SecretString::from(signature);
431
432 Ok(poly_order)
433 }
434
435 fn order_signer_address(&self) -> String {
436 match self.signature_type {
437 PolymarketSignatureType::Poly1271 => self.maker_address.clone(),
438 _ => self.signer_address.clone(),
439 }
440 }
441}
442
443fn validation_failed(detail: impl Into<String>) -> OrderDeniedReason {
444 OrderDeniedReason::ValidationFailed {
445 detail: detail.into(),
446 }
447}
448
449fn validate_price(
450 price: Decimal,
451 tick: Decimal,
452 label: &str,
453 price_display: &str,
454) -> Result<(), String> {
455 let tick = tick.normalize();
456 let max_price = Decimal::ONE - tick;
457
458 if price < tick || price > max_price {
459 return Err(format!(
460 "{label} {price_display} outside Polymarket range [{tick}, {max_price}]"
461 ));
462 }
463
464 if price % tick != Decimal::ZERO {
465 return Err(format!(
466 "{label} {price_display} does not conform to Polymarket tick size {tick}"
467 ));
468 }
469
470 Ok(())
471}
472
473fn to_fixed_decimal(d: Decimal) -> Decimal {
474 let mantissa = d.normalize().trunc_with_scale(USDC_DECIMALS).mantissa();
475 Decimal::from(mantissa)
476}
477
478pub(crate) fn signed_limit_order_quantity(quantity: Decimal) -> Decimal {
480 quantity.trunc_with_scale(LOT_SIZE_SCALE)
481}
482
483pub(crate) fn posted_limit_share_quantity(
489 side: PolymarketOrderSide,
490 price: Decimal,
491 quantity: Decimal,
492 order_type: PolymarketOrderType,
493 tick_decimals: u32,
494) -> Result<Decimal, String> {
495 let (maker_amount, taker_amount) =
496 limit_order_maker_taker_amounts(side, price, quantity, order_type, tick_decimals)?;
497 Ok(human_share_quantity(maker_amount, taker_amount, side))
498}
499
500fn immediate_buy_maker_taker_amounts(
506 price: Decimal,
507 quantity: Decimal,
508 order_type: PolymarketOrderType,
509 tick_decimals: u32,
510) -> Result<(Decimal, Decimal), String> {
511 let quantity = signed_limit_order_quantity(quantity);
512 let notional = quantity * price;
513 let maker_amount = notional.trunc_with_scale(LOT_SIZE_SCALE);
514 if maker_amount.is_zero() {
515 return Err(format!(
516 "Polymarket {order_type} BUY maker amount {} pUSD truncates to zero at {LOT_SIZE_SCALE} decimal places",
517 notional.normalize(),
518 ));
519 }
520
521 let amount_decimals = tick_decimals + LOT_SIZE_SCALE;
522 let taker_amount = (maker_amount / price)
523 .round_dp_with_strategy(amount_decimals, RoundingStrategy::ToPositiveInfinity);
524
525 if taker_amount.is_zero() {
526 return Err(format!(
527 "Polymarket {order_type} BUY derives a zero share quantity from maker amount {} pUSD",
528 maker_amount.normalize(),
529 ));
530 }
531
532 Ok((
533 to_fixed_decimal(maker_amount),
534 to_fixed_decimal(taker_amount),
535 ))
536}
537
538pub fn compute_maker_taker_amounts(
547 price: Decimal,
548 quantity: Decimal,
549 side: PolymarketOrderSide,
550 tick_decimals: u32,
551) -> (Decimal, Decimal) {
552 let precision = tick_decimals + LOT_SIZE_SCALE;
553 let qty = signed_limit_order_quantity(quantity);
554
555 match side {
556 PolymarketOrderSide::Buy => {
557 let maker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
558 let taker_amount = to_fixed_decimal(qty);
559 (maker_amount, taker_amount)
560 }
561 PolymarketOrderSide::Sell => {
562 let maker_amount = to_fixed_decimal(qty);
563 let taker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
564 (maker_amount, taker_amount)
565 }
566 }
567}
568
569fn limit_order_maker_taker_amounts(
570 side: PolymarketOrderSide,
571 price: Decimal,
572 quantity: Decimal,
573 order_type: PolymarketOrderType,
574 tick_decimals: u32,
575) -> Result<(Decimal, Decimal), String> {
576 if side == PolymarketOrderSide::Buy
577 && matches!(
578 order_type,
579 PolymarketOrderType::FAK | PolymarketOrderType::FOK
580 )
581 {
582 immediate_buy_maker_taker_amounts(price, quantity, order_type, tick_decimals)
583 } else {
584 Ok(compute_maker_taker_amounts(
585 price,
586 quantity,
587 side,
588 tick_decimals,
589 ))
590 }
591}
592
593fn human_share_quantity(
594 maker_amount: Decimal,
595 taker_amount: Decimal,
596 side: PolymarketOrderSide,
597) -> Decimal {
598 let share_scale = Decimal::from(1_000_000u32);
599 match side {
600 PolymarketOrderSide::Buy => taker_amount / share_scale,
601 PolymarketOrderSide::Sell => maker_amount / share_scale,
602 }
603}
604
605pub fn compute_market_maker_taker_amounts(
615 price: Decimal,
616 amount: Decimal,
617 side: PolymarketOrderSide,
618 tick_decimals: u32,
619) -> (Decimal, Decimal) {
620 let precision = tick_decimals + LOT_SIZE_SCALE;
621 let amt = amount.trunc_with_scale(LOT_SIZE_SCALE);
622
623 match side {
624 PolymarketOrderSide::Buy => {
625 compute_quote_buy_maker_taker_amounts(price, amount, tick_decimals)
626 }
627 PolymarketOrderSide::Sell => {
628 let maker_amount = to_fixed_decimal(amt);
629 let taker_amount = to_fixed_decimal((amt * price).trunc_with_scale(precision));
630 (maker_amount, taker_amount)
631 }
632 }
633}
634
635fn compute_quote_buy_maker_taker_amounts(
636 price: Decimal,
637 amount: Decimal,
638 tick_decimals: u32,
639) -> (Decimal, Decimal) {
640 let precision = tick_decimals + LOT_SIZE_SCALE;
641 let maker_amount = amount.trunc_with_scale(LOT_SIZE_SCALE);
642 let taker_amount = (maker_amount / price).trunc_with_scale(precision);
643 (
644 to_fixed_decimal(maker_amount),
645 to_fixed_decimal(taker_amount),
646 )
647}
648
649#[allow(
653 clippy::missing_panics_doc,
654 reason = "a UUID always contains enough bytes for the 8-byte slice"
655)]
656pub fn generate_salt() -> u64 {
657 let bytes = uuid::Uuid::new_v4().into_bytes();
658 u64::from_le_bytes(bytes[..8].try_into().unwrap()) & ((1u64 << 53) - 1)
659}
660
661#[cfg(test)]
662mod tests {
663 use nautilus_core::UUID4;
664 use nautilus_model::{
665 enums::{OrderSide, OrderType, TimeInForce},
666 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
667 orders::{LimitOrder, MarketOrder, OrderAny},
668 types::{Price, Quantity},
669 };
670 use rstest::rstest;
671 use rust_decimal::Decimal;
672 use rust_decimal_macros::dec;
673
674 use super::*;
675 use crate::{
676 common::{credential::EvmPrivateKey, enums::PolymarketOrderSide},
677 execution::parse::adjust_market_buy_amount,
678 signing::eip712::OrderSigner,
679 };
680
681 fn make_limit(
682 reduce_only: bool,
683 quote_quantity: bool,
684 post_only: bool,
685 tif: TimeInForce,
686 ) -> OrderAny {
687 make_limit_at_price(
688 reduce_only,
689 quote_quantity,
690 post_only,
691 tif,
692 Price::from("0.50"),
693 )
694 }
695
696 fn make_limit_at_price(
697 reduce_only: bool,
698 quote_quantity: bool,
699 post_only: bool,
700 tif: TimeInForce,
701 price: Price,
702 ) -> OrderAny {
703 make_limit_with_quantity(
704 OrderSide::Buy,
705 Quantity::from("10"),
706 reduce_only,
707 quote_quantity,
708 post_only,
709 tif,
710 price,
711 )
712 }
713
714 fn make_limit_with_quantity(
715 side: OrderSide,
716 quantity: Quantity,
717 reduce_only: bool,
718 quote_quantity: bool,
719 post_only: bool,
720 tif: TimeInForce,
721 price: Price,
722 ) -> OrderAny {
723 let expire_time = if tif == TimeInForce::Gtd {
724 Some(UnixNanos::from(2_000_000_000_000_000_000u64))
725 } else {
726 None
727 };
728 OrderAny::Limit(LimitOrder::new(
729 TraderId::from("TESTER-001"),
730 StrategyId::from("S-001"),
731 InstrumentId::from("TEST.POLYMARKET"),
732 ClientOrderId::from("O-001"),
733 side,
734 quantity,
735 price,
736 tif,
737 expire_time,
738 post_only,
739 reduce_only,
740 quote_quantity,
741 None,
742 None,
743 None,
744 None,
745 None,
746 None,
747 None,
748 None,
749 None,
750 None,
751 None,
752 UUID4::new(),
753 UnixNanos::default(),
754 ))
755 }
756
757 fn with_expire_time(mut order: OrderAny, expire_time: Option<UnixNanos>) -> OrderAny {
758 let OrderAny::Limit(limit) = &mut order else {
759 unreachable!("test order is limit")
760 };
761 limit.expire_time = expire_time;
762 order
763 }
764
765 fn make_market(side: OrderSide, quote_quantity: bool) -> OrderAny {
766 OrderAny::Market(MarketOrder::new(
767 TraderId::from("TESTER-001"),
768 StrategyId::from("S-001"),
769 InstrumentId::from("TEST.POLYMARKET"),
770 ClientOrderId::from("O-001"),
771 side,
772 Quantity::from("10"),
773 TimeInForce::Ioc,
774 UUID4::new(),
775 UnixNanos::default(),
776 false,
777 quote_quantity,
778 None,
779 None,
780 None,
781 None,
782 None,
783 None,
784 None,
785 None,
786 ))
787 }
788
789 #[rstest]
790 fn test_validate_limit_order_valid() {
791 let order = make_limit(false, false, false, TimeInForce::Gtc);
792 assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
793 }
794
795 #[rstest]
796 #[case("0.000", "0.001", "0.001", "0.999")]
797 #[case("1.000", "0.001", "0.001", "0.999")]
798 #[case("0.0000", "0.0001", "0.0001", "0.9999")]
799 #[case("1.0000", "0.0001", "0.0001", "0.9999")]
800 fn test_validate_limit_order_price_out_of_range_denied(
801 #[case] price: &str,
802 #[case] tick_size: &str,
803 #[case] min_price: &str,
804 #[case] max_price: &str,
805 ) {
806 let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
807 let err = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
808 .unwrap_err();
809 assert_eq!(
810 err,
811 OrderDeniedReason::ValidationFailed {
812 detail: format!(
813 "Limit order price {price} outside Polymarket range [{min_price}, {max_price}]"
814 ),
815 }
816 );
817 }
818
819 #[rstest]
820 #[case("0.001", "0.001")]
821 #[case("0.999", "0.001")]
822 #[case("0.0001", "0.0001")]
823 #[case("0.9999", "0.0001")]
824 #[case("0.005", "0.005")]
825 #[case("0.995", "0.005")]
826 #[case("0.0025", "0.0025")]
827 #[case("0.9975", "0.0025")]
828 fn test_validate_limit_order_price_boundary_allowed(
829 #[case] price: &str,
830 #[case] tick_size: &str,
831 ) {
832 let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
833 assert!(
834 PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
835 );
836 }
837
838 #[rstest]
839 #[case("0.505", "0.005")]
840 #[case("0.5025", "0.0025")]
841 fn test_validate_limit_order_price_aligned_allowed(
842 #[case] price: &str,
843 #[case] tick_size: &str,
844 ) {
845 let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
846 assert!(
847 PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
848 );
849 }
850
851 #[rstest]
852 #[case("0.501", "0.005")]
853 #[case("0.501", "0.0025")]
854 fn test_validate_limit_order_price_misaligned_denied(
855 #[case] price: &str,
856 #[case] tick_size: &str,
857 ) {
858 let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
859 let error = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
860 .unwrap_err();
861 assert_eq!(
862 error,
863 OrderDeniedReason::ValidationFailed {
864 detail: format!(
865 "Limit order price {price} does not conform to Polymarket tick size {tick_size}"
866 ),
867 }
868 );
869 }
870
871 #[rstest]
872 #[case::half_cent("0.5059", "0.005", 3, dec!(0.505))]
873 #[case::half_cent_p4("0.5059", "0.0050", 3, dec!(0.505))]
874 #[case::quarter_cent("0.50259", "0.0025", 4, dec!(0.5025))]
875 fn test_normalize_market_price(
876 #[case] price: &str,
877 #[case] tick_size: &str,
878 #[case] tick_decimals: u32,
879 #[case] expected: Decimal,
880 ) {
881 let normalized = PolymarketOrderBuilder::normalize_market_price(
882 price.parse::<Decimal>().unwrap(),
883 Price::from(tick_size),
884 tick_decimals,
885 )
886 .unwrap();
887
888 assert_eq!(normalized, expected);
889 }
890
891 #[rstest]
892 #[case::below_range(
893 "0.0049",
894 "0.005",
895 "Derived market price 0.004 outside Polymarket range [0.005, 0.995]"
896 )]
897 #[case::above_range(
898 "1.0000",
899 "0.0025",
900 "Derived market price 1 outside Polymarket range [0.0025, 0.9975]"
901 )]
902 #[case::off_half_cent(
903 "0.5019",
904 "0.005",
905 "Derived market price 0.501 does not conform to Polymarket tick size 0.005"
906 )]
907 #[case::off_quarter_cent(
908 "0.50129",
909 "0.0025",
910 "Derived market price 0.5012 does not conform to Polymarket tick size 0.0025"
911 )]
912 fn test_normalize_market_price_denied(
913 #[case] price: &str,
914 #[case] tick_size: &str,
915 #[case] expected: &str,
916 ) {
917 let tick_size = Price::from(tick_size);
918 let error = PolymarketOrderBuilder::normalize_market_price(
919 price.parse().unwrap(),
920 tick_size,
921 u32::from(tick_size.precision),
922 )
923 .unwrap_err();
924
925 assert_eq!(error, expected);
926 }
927
928 #[rstest]
929 #[case::below(
930 1_700_000_179_999_999_999,
931 Some("Polymarket GTD expiry must be at least 180 seconds in the future")
932 )]
933 #[case::equal(1_700_000_180_000_000_000, None)]
934 #[case::above(1_700_000_181_000_000_000, None)]
935 fn test_validate_limit_expiration_boundary(
936 #[case] expire_ns: u64,
937 #[case] expected_error: Option<&str>,
938 ) {
939 let order = with_expire_time(
940 make_limit(false, false, false, TimeInForce::Gtd),
941 Some(UnixNanos::from(expire_ns)),
942 );
943 let ts_now = UnixNanos::from(1_700_000_000_900_000_000u64);
944
945 let result = PolymarketOrderBuilder::validate_limit_expiration(&order, ts_now);
946
947 assert_eq!(
948 result.err().map(|reason| reason.to_string()),
949 expected_error.map(|detail| OrderDeniedReason::ValidationFailed {
950 detail: detail.to_string(),
951 }
952 .to_string())
953 );
954 }
955
956 #[rstest]
957 #[case::missing(None)]
958 #[case::zero(Some(UnixNanos::default()))]
959 fn test_validate_limit_expiration_requires_non_zero_expiry(
960 #[case] expire_time: Option<UnixNanos>,
961 ) {
962 let order = with_expire_time(
963 make_limit(false, false, false, TimeInForce::Gtd),
964 expire_time,
965 );
966
967 let error = PolymarketOrderBuilder::validate_limit_expiration(
968 &order,
969 UnixNanos::from(1_700_000_000_900_000_000u64),
970 )
971 .unwrap_err();
972
973 assert_eq!(error, OrderDeniedReason::MissingExpireTime);
974 }
975
976 #[rstest]
977 fn test_validate_limit_order_reduce_only_denied() {
978 let order = make_limit(true, false, false, TimeInForce::Gtc);
979 let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
980 assert!(err.to_string().contains("Reduce-only"));
981 }
982
983 #[rstest]
984 fn test_validate_limit_order_quote_quantity_buy_allowed() {
985 let order = make_limit(false, true, false, TimeInForce::Gtc);
986 assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
987 }
988
989 #[rstest]
990 fn test_validate_limit_order_quote_quantity_sell_denied() {
991 let order = make_limit_with_quantity(
992 OrderSide::Sell,
993 Quantity::from("10"),
994 false,
995 true,
996 false,
997 TimeInForce::Gtc,
998 Price::from("0.50"),
999 );
1000 let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
1001 assert_eq!(err, OrderDeniedReason::unsupported_quote_quantity());
1002 }
1003
1004 #[rstest]
1005 #[case::base(false, "shares")]
1006 #[case::collateral(true, "pUSD")]
1007 fn test_validate_limit_order_denies_quantity_below_signing_minimum(
1008 #[case] quote_quantity: bool,
1009 #[case] denomination: &str,
1010 ) {
1011 let order = make_limit_with_quantity(
1012 OrderSide::Buy,
1013 Quantity::from("0.009"),
1014 false,
1015 quote_quantity,
1016 false,
1017 TimeInForce::Gtc,
1018 Price::from("0.50"),
1019 );
1020
1021 let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
1022
1023 assert_eq!(
1024 err,
1025 validation_failed(format!(
1026 "Polymarket limit order amount 0.009 {denomination} truncates to zero at 2 decimal places"
1027 ))
1028 );
1029 }
1030
1031 #[rstest]
1032 fn test_validate_limit_order_post_only_ioc_denied() {
1033 let order = make_limit(false, false, true, TimeInForce::Ioc);
1034 let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
1035 assert_eq!(err, OrderDeniedReason::unsupported_post_only());
1036 }
1037
1038 #[rstest]
1039 fn test_validate_limit_order_post_only_gtc_allowed() {
1040 let order = make_limit(false, false, true, TimeInForce::Gtc);
1041 assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
1042 }
1043
1044 #[rstest]
1045 fn test_validate_market_order_buy_with_quote_qty() {
1046 let order = make_market(OrderSide::Buy, true);
1047 assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
1048 }
1049
1050 #[rstest]
1051 fn test_validate_market_order_buy_without_quote_qty_denied() {
1052 let order = make_market(OrderSide::Buy, false);
1053 let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
1054 assert!(err.to_string().contains("quote_quantity=true"));
1055 }
1056
1057 #[rstest]
1058 fn test_validate_market_order_sell_without_quote_qty() {
1059 let order = make_market(OrderSide::Sell, false);
1060 assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
1061 }
1062
1063 #[rstest]
1064 fn test_validate_market_order_sell_with_quote_qty_denied() {
1065 let order = make_market(OrderSide::Sell, true);
1066 let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
1067 assert_eq!(err, OrderDeniedReason::unsupported_quote_quantity());
1068 }
1069
1070 #[rstest]
1071 fn test_validate_market_order_wrong_type_denied() {
1072 let limit = make_limit(false, false, false, TimeInForce::Gtc);
1074 let err = PolymarketOrderBuilder::validate_market_order(&limit).unwrap_err();
1075 assert_eq!(
1076 err,
1077 OrderDeniedReason::UnsupportedOrderType {
1078 order_type: OrderType::Limit,
1079 }
1080 );
1081 }
1082
1083 fn make_test_builder() -> PolymarketOrderBuilder {
1084 let pk = EvmPrivateKey::new(
1085 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1086 )
1087 .unwrap();
1088 let signer = OrderSigner::new(&pk).unwrap();
1089 let addr = format!("{:#x}", signer.address());
1090 PolymarketOrderBuilder::new(signer, addr.clone(), addr, PolymarketSignatureType::Eoa)
1091 }
1092
1093 #[rstest]
1094 fn test_next_timestamp_ms_is_strictly_monotonic() {
1095 let builder = make_test_builder();
1096 let mut prev = builder.next_timestamp_ms();
1097 for _ in 0..1_000 {
1098 let next = builder.next_timestamp_ms();
1099 assert!(
1100 next > prev,
1101 "timestamp not strictly monotonic: {prev} >= {next}"
1102 );
1103 prev = next;
1104 }
1105 }
1106
1107 #[rstest]
1108 fn test_build_orders_produce_unique_timestamps() {
1109 let builder = make_test_builder();
1110 let mut timestamps = ahash::AHashSet::new();
1111
1112 for _ in 0..50 {
1113 let order = builder
1114 .build_limit_order(
1115 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1116 PolymarketOrderSide::Buy,
1117 dec!(0.50),
1118 dec!(10),
1119 PolymarketOrderType::GTC,
1120 "0",
1121 false,
1122 2,
1123 )
1124 .unwrap();
1125 assert!(
1126 timestamps.insert(order.timestamp.clone()),
1127 "duplicate timestamp {} in burst",
1128 order.timestamp,
1129 );
1130 }
1131 }
1132
1133 #[rstest]
1134 fn test_built_order_carries_nautilus_builder_code() {
1135 let builder = make_test_builder();
1136 let order = builder
1137 .build_limit_order(
1138 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1139 PolymarketOrderSide::Buy,
1140 dec!(0.50),
1141 dec!(10),
1142 PolymarketOrderType::GTC,
1143 "0",
1144 false,
1145 2,
1146 )
1147 .unwrap();
1148 assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
1149 }
1150
1151 #[rstest]
1152 fn test_poly_1271_order_uses_deposit_wallet_as_signer() {
1153 let pk = EvmPrivateKey::new(
1154 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1155 )
1156 .unwrap();
1157 let signer = OrderSigner::new(&pk).unwrap();
1158 let signer_address = format!("{:#x}", signer.address());
1159 let deposit_wallet = "0x1111111111111111111111111111111111111111".to_string();
1160 let builder = PolymarketOrderBuilder::new(
1161 signer,
1162 signer_address,
1163 deposit_wallet.clone(),
1164 PolymarketSignatureType::Poly1271,
1165 );
1166
1167 let order = builder
1168 .build_limit_order(
1169 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1170 PolymarketOrderSide::Buy,
1171 dec!(0.50),
1172 dec!(10),
1173 PolymarketOrderType::GTC,
1174 "0",
1175 false,
1176 2,
1177 )
1178 .unwrap();
1179
1180 assert_eq!(order.maker, deposit_wallet);
1181 assert_eq!(order.signer, deposit_wallet);
1182 assert_eq!(order.signature_type, PolymarketSignatureType::Poly1271);
1183 assert_eq!(order.signature.expose_secret().len(), 636);
1184 }
1185
1186 #[rstest]
1187 fn test_build_limit_order_expiration_passthrough() {
1188 let builder = make_test_builder();
1189 let order = builder
1190 .build_limit_order(
1191 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1192 PolymarketOrderSide::Buy,
1193 dec!(0.50),
1194 dec!(10),
1195 PolymarketOrderType::GTD,
1196 "1735689600",
1197 false,
1198 2,
1199 )
1200 .unwrap();
1201 assert_eq!(order.expiration, "1735689600");
1202 }
1203
1204 #[rstest]
1205 fn test_build_limit_order_amount_matrix(
1206 #[values(1, 2, 3, 4)] tick_decimals: u32,
1207 #[values(PolymarketOrderSide::Buy, PolymarketOrderSide::Sell)] side: PolymarketOrderSide,
1208 #[values(
1209 PolymarketOrderType::GTC,
1210 PolymarketOrderType::GTD,
1211 PolymarketOrderType::FAK,
1212 PolymarketOrderType::FOK
1213 )]
1214 order_type: PolymarketOrderType,
1215 ) {
1216 let (price, quantity, notional) = match tick_decimals {
1217 1 => (dec!(0.5), dec!(10), dec!(5_000_000)),
1218 2 => (dec!(0.56), dec!(10), dec!(5_600_000)),
1219 3 => (dec!(0.961), dec!(10), dec!(9_610_000)),
1220 4 => (dec!(0.9612), dec!(25), dec!(24_030_000)),
1221 _ => unreachable!(),
1222 };
1223 let quantity_amount = to_fixed_decimal(quantity);
1224 let expiration = if order_type == PolymarketOrderType::GTD {
1225 "1735689600"
1226 } else {
1227 "0"
1228 };
1229 let builder = make_test_builder();
1230
1231 let order = builder
1232 .build_limit_order(
1233 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1234 side,
1235 price,
1236 quantity,
1237 order_type,
1238 expiration,
1239 false,
1240 tick_decimals,
1241 )
1242 .unwrap();
1243
1244 let expected = match side {
1245 PolymarketOrderSide::Buy => (notional, quantity_amount),
1246 PolymarketOrderSide::Sell => (quantity_amount, notional),
1247 };
1248 assert_eq!((order.maker_amount, order.taker_amount), expected);
1249 }
1250
1251 #[rstest]
1252 #[case::cent_tick(dec!(0.56), dec!(10.089), 2, dec!(10_080_000), dec!(18_000_000))]
1253 #[case::half_cent_tick(
1254 dec!(0.505),
1255 dec!(10.109),
1256 3,
1257 dec!(10_100_000),
1258 dec!(20_000_000)
1259 )]
1260 #[case::quarter_cent_tick(
1261 dec!(0.5025),
1262 dec!(10.059),
1263 4,
1264 dec!(10_050_000),
1265 dec!(20_000_000)
1266 )]
1267 #[case::minimum_order_size(dec!(0.99), dec!(4.959), 2, dec!(4_950_000), dec!(5_000_000))]
1268 fn test_build_collateral_limit_buy_amounts(
1269 #[case] price: Decimal,
1270 #[case] amount: Decimal,
1271 #[case] tick_decimals: u32,
1272 #[case] expected_maker: Decimal,
1273 #[case] expected_taker: Decimal,
1274 ) {
1275 let builder = make_test_builder();
1276
1277 let order = builder
1278 .build_limit_order_from_collateral(
1279 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1280 price,
1281 amount,
1282 "0",
1283 false,
1284 tick_decimals,
1285 )
1286 .unwrap();
1287
1288 assert_eq!(order.side, PolymarketOrderSide::Buy);
1289 assert_eq!(order.maker_amount, expected_maker);
1290 assert_eq!(order.taker_amount, expected_taker);
1291 }
1292
1293 #[rstest]
1294 #[case::zero_after_quantization(dec!(0.50), dec!(0.009), 2, "amount 0.009 pUSD truncates to zero")]
1295 #[case::zero_price(dec!(0), dec!(1), 2, "price must be positive")]
1296 #[case::inexact_signed_price(
1297 dec!(0.989),
1298 dec!(5),
1299 3,
1300 "amount 5 pUSD cannot preserve limit price 0.989 after venue quantization"
1301 )]
1302 fn test_build_collateral_limit_buy_denies_invalid_amounts(
1303 #[case] price: Decimal,
1304 #[case] amount: Decimal,
1305 #[case] tick_decimals: u32,
1306 #[case] expected_error: &str,
1307 ) {
1308 let builder = make_test_builder();
1309
1310 let error = builder
1311 .build_limit_order_from_collateral(
1312 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1313 price,
1314 amount,
1315 "0",
1316 false,
1317 tick_decimals,
1318 )
1319 .unwrap_err();
1320
1321 assert!(
1322 error.to_string().contains(expected_error),
1323 "unexpected error: {error}"
1324 );
1325 }
1326
1327 #[rstest]
1328 #[case::tick_tenth(1, dec!(0.5), dec!(1.01), dec!(500_000), dec!(1_000_000))]
1329 #[case::tick_hundredth(2, dec!(0.56), dec!(1.01), dec!(560_000), dec!(1_000_000))]
1330 #[case::tick_thousandth(3, dec!(0.961), dec!(5), dec!(4_800_000), dec!(4_994_800))]
1331 #[case::tick_ten_thousandth(4, dec!(0.9612), dec!(5), dec!(4_800_000), dec!(4_993_758))]
1332 #[case::live_aggressive(3, dec!(0.227), dec!(5), dec!(1_130_000), dec!(4_977_980))]
1333 fn test_build_limit_order_quantizes_immediate_buy_to_cent_budget(
1334 #[case] tick_decimals: u32,
1335 #[case] price: Decimal,
1336 #[case] quantity: Decimal,
1337 #[case] expected_maker: Decimal,
1338 #[case] expected_taker: Decimal,
1339 #[values(PolymarketOrderType::FAK, PolymarketOrderType::FOK)]
1340 order_type: PolymarketOrderType,
1341 ) {
1342 let builder = make_test_builder();
1343
1344 let order = builder
1345 .build_limit_order(
1346 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1347 PolymarketOrderSide::Buy,
1348 price,
1349 quantity,
1350 order_type,
1351 "0",
1352 false,
1353 tick_decimals,
1354 )
1355 .unwrap();
1356
1357 assert_eq!(order.maker_amount, expected_maker);
1358 assert_eq!(order.taker_amount, expected_taker);
1359 }
1360
1361 #[rstest]
1362 fn test_validate_immediate_buy_denies_zero_cent_budget() {
1363 let order = make_limit_with_quantity(
1364 OrderSide::Buy,
1365 Quantity::from("0.01"),
1366 false,
1367 false,
1368 false,
1369 TimeInForce::Fok,
1370 Price::from("0.01"),
1371 );
1372
1373 let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
1374
1375 assert_eq!(
1376 err,
1377 validation_failed(
1378 "Polymarket FOK BUY maker amount 0.0001 pUSD truncates to zero at 2 decimal places"
1379 )
1380 );
1381 }
1382
1383 #[rstest]
1384 #[case::gtc_buy(PolymarketOrderSide::Buy, PolymarketOrderType::GTC, dec!(0.40), dec!(10), 2, dec!(10))]
1385 #[case::gtc_sell(PolymarketOrderSide::Sell, PolymarketOrderType::GTC, dec!(0.40), dec!(10), 2, dec!(10))]
1386 #[case::truncated(PolymarketOrderSide::Buy, PolymarketOrderType::GTC, dec!(0.50), dec!(5.009), 2, dec!(5))]
1387 #[case::immediate_thousandth(
1388 PolymarketOrderSide::Buy,
1389 PolymarketOrderType::FAK,
1390 dec!(0.961),
1391 dec!(5),
1392 3,
1393 dec!(4.9948)
1394 )]
1395 #[case::immediate_live(
1396 PolymarketOrderSide::Buy,
1397 PolymarketOrderType::FOK,
1398 dec!(0.227),
1399 dec!(5),
1400 3,
1401 dec!(4.977980)
1402 )]
1403 fn test_posted_limit_share_quantity_matches_signed_amounts(
1404 #[case] side: PolymarketOrderSide,
1405 #[case] order_type: PolymarketOrderType,
1406 #[case] price: Decimal,
1407 #[case] quantity: Decimal,
1408 #[case] tick_decimals: u32,
1409 #[case] expected: Decimal,
1410 ) {
1411 let shares = posted_limit_share_quantity(side, price, quantity, order_type, tick_decimals)
1412 .expect("share quantity is representable");
1413
1414 assert_eq!(shares, expected);
1415 }
1416
1417 #[rstest]
1418 fn test_posted_limit_share_quantity_rejects_zero_immediate_buy_budget() {
1419 let error = posted_limit_share_quantity(
1420 PolymarketOrderSide::Buy,
1421 dec!(0.01),
1422 dec!(0.01),
1423 PolymarketOrderType::FAK,
1424 2,
1425 )
1426 .expect_err("cent budget truncates to zero");
1427
1428 assert_eq!(
1429 error,
1430 "Polymarket FAK BUY maker amount 0.0001 pUSD truncates to zero at 2 decimal places"
1431 );
1432 }
1433
1434 #[rstest]
1435 #[case::gtc_buy(
1436 PolymarketOrderType::GTC,
1437 PolymarketOrderSide::Buy,
1438 dec!(4_805_000),
1439 dec!(5_000_000),
1440 )]
1441 #[case::gtd_buy(
1442 PolymarketOrderType::GTD,
1443 PolymarketOrderSide::Buy,
1444 dec!(4_805_000),
1445 dec!(5_000_000),
1446 )]
1447 #[case::fak_sell(
1448 PolymarketOrderType::FAK,
1449 PolymarketOrderSide::Sell,
1450 dec!(5_000_000),
1451 dec!(4_805_000),
1452 )]
1453 #[case::fok_sell(
1454 PolymarketOrderType::FOK,
1455 PolymarketOrderSide::Sell,
1456 dec!(5_000_000),
1457 dec!(4_805_000),
1458 )]
1459 fn test_build_limit_order_preserves_unaffected_fractional_cent_amounts(
1460 #[case] order_type: PolymarketOrderType,
1461 #[case] side: PolymarketOrderSide,
1462 #[case] expected_maker: Decimal,
1463 #[case] expected_taker: Decimal,
1464 ) {
1465 let builder = make_test_builder();
1466
1467 let order = builder
1468 .build_limit_order(
1469 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1470 side,
1471 dec!(0.961),
1472 dec!(5),
1473 order_type,
1474 "0",
1475 false,
1476 3,
1477 )
1478 .unwrap();
1479
1480 assert_eq!(order.maker_amount, expected_maker);
1481 assert_eq!(order.taker_amount, expected_taker);
1482 }
1483
1484 #[rstest]
1485 fn test_validate_limit_order_gtd_with_expire_accepted() {
1486 let order = make_limit(false, false, false, TimeInForce::Gtd);
1492 assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
1493 }
1494
1495 #[rstest]
1496 fn test_build_market_buy_order_wire_shape() {
1497 let builder = make_test_builder();
1500 let order = builder
1501 .build_market_order(
1502 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1503 PolymarketOrderSide::Buy,
1504 dec!(0.50),
1505 dec!(10),
1506 false,
1507 2,
1508 )
1509 .unwrap();
1510
1511 assert_eq!(order.expiration, "0");
1512 assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
1513 assert_eq!(order.metadata, ZERO_BYTES32);
1514 assert_eq!(order.side, PolymarketOrderSide::Buy);
1515 assert!(!order.timestamp.is_empty());
1516 assert!(!order.signature.expose_secret().is_empty());
1517 assert_eq!(order.maker_amount, dec!(10_000_000));
1520 assert_eq!(order.taker_amount, dec!(20_000_000));
1521 }
1522
1523 #[rstest]
1524 fn test_build_market_buy_order_rejects_fee_adjusted_amount_below_lot_size() {
1525 let builder = make_test_builder();
1526 let adjusted = adjust_market_buy_amount(
1527 dec!(10),
1528 dec!(0.009),
1529 dec!(0.5),
1530 dec!(0.04),
1531 dec!(1),
1532 dec!(0),
1533 )
1534 .unwrap();
1535 assert_eq!(adjusted, dec!(0.008823));
1536
1537 let err = builder
1538 .build_market_order(
1539 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1540 PolymarketOrderSide::Buy,
1541 dec!(0.5),
1542 adjusted,
1543 false,
1544 2,
1545 )
1546 .unwrap_err();
1547
1548 assert_eq!(
1549 err.to_string(),
1550 "Polymarket market BUY amount 0.008823 pUSD truncates to zero at 2 decimal places",
1551 );
1552 assert_eq!(builder.last_timestamp_ms.load(Ordering::Relaxed), 0);
1553 }
1554
1555 #[rstest]
1556 fn test_build_market_sell_order_wire_shape() {
1557 let builder = make_test_builder();
1560 let order = builder
1561 .build_market_order(
1562 "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1563 PolymarketOrderSide::Sell,
1564 dec!(0.50),
1565 dec!(20),
1566 false,
1567 2,
1568 )
1569 .unwrap();
1570
1571 assert_eq!(order.expiration, "0");
1572 assert_eq!(order.side, PolymarketOrderSide::Sell);
1573 assert_eq!(order.maker_amount, dec!(20_000_000));
1574 assert_eq!(order.taker_amount, dec!(10_000_000));
1575 assert!(!order.signature.expose_secret().is_empty());
1576 }
1577
1578 #[rstest]
1579 fn test_generate_salt_uniqueness() {
1580 let s1 = generate_salt();
1581 let s2 = generate_salt();
1582 assert_ne!(s1, s2);
1583 }
1584
1585 #[rstest]
1586 fn test_generate_salt_within_53_bits() {
1587 for _ in 0..100 {
1588 let s = generate_salt();
1589 assert!(s < (1u64 << 53));
1590 }
1591 }
1592
1593 #[rstest]
1596 #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1597 #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1598 #[case(dec!(0.75), dec!(200), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1599 #[case(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3, dec!(13_296_150), dec!(23_450_000))]
1601 #[case(dec!(0.55), dec!(10), PolymarketOrderSide::Buy, 1, dec!(5_500_000), dec!(10_000_000))]
1602 fn test_compute_maker_taker_amounts(
1603 #[case] price: Decimal,
1604 #[case] quantity: Decimal,
1605 #[case] side: PolymarketOrderSide,
1606 #[case] tick_decimals: u32,
1607 #[case] expected_maker: Decimal,
1608 #[case] expected_taker: Decimal,
1609 ) {
1610 let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1611 assert_eq!(maker, expected_maker);
1612 assert_eq!(taker, expected_taker);
1613 }
1614
1615 #[rstest]
1622 #[case::buy_tick_tenth(
1624 dec!(0.5), dec!(21.04), PolymarketOrderSide::Buy, 1,
1625 dec!(10_520_000), dec!(21_040_000),
1626 )]
1627 #[case::sell_tick_tenth(
1628 dec!(0.5), dec!(21.04), PolymarketOrderSide::Sell, 1,
1629 dec!(21_040_000), dec!(10_520_000),
1630 )]
1631 #[case::buy_tick_hundredth(
1633 dec!(0.56), dec!(21.04), PolymarketOrderSide::Buy, 2,
1634 dec!(11_782_400), dec!(21_040_000),
1635 )]
1636 #[case::sell_tick_hundredth(
1637 dec!(0.56), dec!(21.04), PolymarketOrderSide::Sell, 2,
1638 dec!(21_040_000), dec!(11_782_400),
1639 )]
1640 #[case::buy_decimal_accuracy_24(
1641 dec!(0.24), dec!(15), PolymarketOrderSide::Buy, 2,
1642 dec!(3_600_000), dec!(15_000_000),
1643 )]
1644 #[case::buy_decimal_accuracy_82(
1645 dec!(0.82), dec!(101), PolymarketOrderSide::Buy, 2,
1646 dec!(82_820_000), dec!(101_000_000),
1647 )]
1648 #[case::buy_decimal_accuracy_18233(
1649 dec!(0.58), dec!(18233.33), PolymarketOrderSide::Buy, 2,
1650 dec!(10_575_331_400), dec!(18_233_330_000),
1651 )]
1652 #[case::buy_tick_thousandth(
1654 dec!(0.056), dec!(21.04), PolymarketOrderSide::Buy, 3,
1655 dec!(1_178_240), dec!(21_040_000),
1656 )]
1657 #[case::sell_tick_thousandth(
1658 dec!(0.056), dec!(21.04), PolymarketOrderSide::Sell, 3,
1659 dec!(21_040_000), dec!(1_178_240),
1660 )]
1661 #[case::buy_tick_ten_thousandth(
1663 dec!(0.0056), dec!(21.04), PolymarketOrderSide::Buy, 4,
1664 dec!(117_824), dec!(21_040_000),
1665 )]
1666 #[case::sell_tick_ten_thousandth(
1667 dec!(0.0056), dec!(21.04), PolymarketOrderSide::Sell, 4,
1668 dec!(21_040_000), dec!(117_824),
1669 )]
1670 fn test_compute_maker_taker_amounts_sdk_parity(
1671 #[case] price: Decimal,
1672 #[case] quantity: Decimal,
1673 #[case] side: PolymarketOrderSide,
1674 #[case] tick_decimals: u32,
1675 #[case] expected_maker: Decimal,
1676 #[case] expected_taker: Decimal,
1677 ) {
1678 let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1679 assert_eq!(maker, expected_maker);
1680 assert_eq!(taker, expected_taker);
1681 }
1682
1683 #[rstest]
1686 #[case(dec!(0.50), dec!(50), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1687 #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1688 #[case(dec!(0.75), dec!(150), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1689 #[case(dec!(0.96), dec!(5.208), PolymarketOrderSide::Sell, 2, dec!(5_200_000), dec!(4_992_000))]
1690 #[case(dec!(0.211), dec!(23.696681), PolymarketOrderSide::Sell, 3, dec!(23_690_000), dec!(4_998_590))]
1692 #[case(dec!(0.66), dec!(5), PolymarketOrderSide::Buy, 2, dec!(5_000_000), dec!(7_575_700))]
1694 fn test_compute_market_maker_taker_amounts(
1695 #[case] price: Decimal,
1696 #[case] amount: Decimal,
1697 #[case] side: PolymarketOrderSide,
1698 #[case] tick_decimals: u32,
1699 #[case] expected_maker: Decimal,
1700 #[case] expected_taker: Decimal,
1701 ) {
1702 let (maker, taker) = compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
1703 assert_eq!(maker, expected_maker);
1704 assert_eq!(taker, expected_taker);
1705 }
1706
1707 #[rstest]
1708 fn test_compute_maker_taker_qty_truncated_to_lot_size() {
1709 let (maker, taker) =
1712 compute_maker_taker_amounts(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3);
1713 assert_eq!(maker, dec!(13_296_150));
1714 assert_eq!(taker, dec!(23_450_000));
1715 }
1716
1717 #[rstest]
1718 fn test_compute_maker_taker_zero_amounts() {
1719 let (maker, taker) =
1720 compute_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1721 assert_eq!(maker, dec!(0));
1722 assert_eq!(taker, dec!(0));
1723 }
1724
1725 #[rstest]
1726 fn test_compute_maker_taker_tick_decimals_1() {
1727 let (maker, taker) =
1728 compute_maker_taker_amounts(dec!(0.3), dec!(50), PolymarketOrderSide::Buy, 1);
1729 assert_eq!(maker, dec!(15_000_000));
1730 assert_eq!(taker, dec!(50_000_000));
1731 }
1732
1733 #[rstest]
1734 fn test_compute_maker_taker_tick_decimals_3_sell() {
1735 let (maker, taker) =
1738 compute_maker_taker_amounts(dec!(0.789), dec!(15.123), PolymarketOrderSide::Sell, 3);
1739 assert_eq!(maker, dec!(15_120_000));
1740 assert_eq!(taker, dec!(11_929_680));
1741 }
1742
1743 #[rstest]
1744 fn test_compute_market_maker_taker_zero_amount() {
1745 let (maker, taker) =
1746 compute_market_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1747 assert_eq!(maker, dec!(0));
1748 assert_eq!(taker, dec!(0));
1749 }
1750
1751 #[rstest]
1752 fn test_compute_market_sell_position_close() {
1753 let (maker, taker) = compute_market_maker_taker_amounts(
1757 dec!(0.211),
1758 dec!(23.696681),
1759 PolymarketOrderSide::Sell,
1760 3,
1761 );
1762 assert_eq!(maker, dec!(23_690_000));
1763 assert_eq!(taker, dec!(4_998_590));
1765 assert_eq!(maker % dec!(10_000), dec!(0));
1767 assert_eq!(taker % dec!(10), dec!(0));
1769 }
1770
1771 #[rstest]
1772 fn test_to_fixed_decimal_basic() {
1773 assert_eq!(to_fixed_decimal(dec!(13.29955)), dec!(13_299_550));
1774 assert_eq!(to_fixed_decimal(dec!(100)), dec!(100_000_000));
1775 assert_eq!(to_fixed_decimal(dec!(0)), dec!(0));
1776 }
1777}