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