Skip to main content

nautilus_polymarket/execution/
order_builder.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Order builder for the Polymarket CLOB exchange.
17//!
18//! Centralizes all order construction logic:
19//! - Limit and market order building
20//! - Order validation (side, TIF, quote_quantity)
21//! - EIP-712 signing
22//! - Maker/taker amount computation
23//!
24//! Amounts are converted from human-readable decimals to on-chain base units
25//! (pUSD 10^6 / CTF shares 10^6) by truncating to `USDC_DECIMALS` (6) decimal
26//! places and extracting the mantissa as an integer.
27//!
28//! The builder produces signed [`PolymarketOrder`] structs ready for HTTP submission.
29
30use 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;
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
52/// Zero `bytes32` used for the `metadata` field (reserved for future use).
53pub const ZERO_BYTES32: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";
54
55// Matches the official Polymarket SDK's minimum accepted GTD horizon.
56const GTD_EXPIRATION_BUFFER_SECS: u64 = 180;
57
58/// Builds signed Polymarket orders for submission to the CLOB V2 exchange.
59///
60/// `last_timestamp_ms` backs a strictly-monotonic millisecond clock so that
61/// bursts of submissions landing in the same wall-clock millisecond still
62/// produce distinct `timestamp` values (the V2 per-address uniqueness field).
63#[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    /// Creates a new [`PolymarketOrderBuilder`].
74    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    // Returns a strictly-monotonic millisecond timestamp: the current wall
90    // time in ms, or `last_seen + 1` if that would be larger. Thread-safe.
91    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    /// Builds and signs a limit order for submission.
109    ///
110    /// Immediate BUYs must have an exact cent-denominated maker amount. The builder rejects
111    /// quantities that cannot satisfy that constraint without changing the signed price/amount
112    /// ratio.
113    ///
114    /// `expiration` is a unix-seconds timestamp (`"0"` for non-GTD orders).
115    /// It is carried in the wire body but excluded from the EIP-712 signed hash.
116    #[expect(clippy::too_many_arguments)]
117    pub fn build_limit_order(
118        &self,
119        token_id: &str,
120        side: PolymarketOrderSide,
121        price: Decimal,
122        quantity: Decimal,
123        order_type: PolymarketOrderType,
124        expiration: &str,
125        neg_risk: bool,
126        tick_decimals: u32,
127    ) -> anyhow::Result<PolymarketOrder> {
128        validate_immediate_buy_maker_amount(price, quantity, side, order_type)
129            .map_err(anyhow::Error::msg)?;
130        let (maker_amount, taker_amount) =
131            compute_maker_taker_amounts(price, quantity, side, tick_decimals);
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    /// Builds and signs a collateral-sized limit BUY for submission.
143    ///
144    /// `amount` is the pUSD collateral to spend. The signed maker amount is the collateral after
145    /// venue-required cent quantization, and the taker amount is the share quantity derived from
146    /// that exact signed amount.
147    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    /// Builds and signs a market order for submission.
190    ///
191    /// `amount` semantics differ by side:
192    /// - BUY: `amount` is pUSD to spend
193    /// - SELL: `amount` is shares to sell
194    ///
195    /// Market orders never set an expiration.
196    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    /// Computes the Polymarket order ID for a signed CLOB V2 order.
218    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    /// Validates a limit order before building, returning a denial reason if invalid.
230    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            validate_immediate_buy_maker_amount(
281                price.as_decimal(),
282                order.quantity().as_decimal(),
283                side,
284                order_type,
285            )
286            .map_err(validation_failed)?;
287        }
288
289        Ok(())
290    }
291
292    pub(crate) fn validate_limit_price(
293        order: &OrderAny,
294        tick_size: Price,
295    ) -> Result<(), OrderDeniedReason> {
296        let price = order
297            .price()
298            .ok_or_else(|| validation_failed("Limit orders require a price"))?;
299        Self::validate_limit_price_value(price, tick_size)
300    }
301
302    pub(crate) fn validate_limit_price_value(
303        price: Price,
304        tick_size: Price,
305    ) -> Result<(), OrderDeniedReason> {
306        let tick = tick_size.as_decimal();
307        let price_decimal = price.as_decimal();
308
309        validate_price(price_decimal, tick, "Limit order price", &price.to_string())
310            .map_err(validation_failed)
311    }
312
313    pub(crate) fn validate_limit_expiration(
314        order: &OrderAny,
315        ts_now: UnixNanos,
316    ) -> Result<(), OrderDeniedReason> {
317        if order.time_in_force() != TimeInForce::Gtd {
318            return Ok(());
319        }
320
321        let expire_time = order
322            .expire_time()
323            .filter(|expire_time| !expire_time.is_zero())
324            .ok_or(OrderDeniedReason::MissingExpireTime)?;
325        let now_secs = ts_now.as_seconds();
326        let expire_secs = expire_time.as_seconds();
327        let minimum_expire_secs = now_secs.saturating_add(GTD_EXPIRATION_BUFFER_SECS);
328
329        if expire_secs < minimum_expire_secs {
330            return Err(validation_failed(format!(
331                "Polymarket GTD expiry must be at least {GTD_EXPIRATION_BUFFER_SECS} seconds in the future"
332            )));
333        }
334
335        Ok(())
336    }
337
338    /// Validates a market order before building, returning a denial reason if invalid.
339    pub fn validate_market_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
340        if order.is_reduce_only() {
341            return Err(validation_failed(
342                "Reduce-only orders not supported on Polymarket",
343            ));
344        }
345
346        if order.order_type() != OrderType::Market {
347            return Err(OrderDeniedReason::UnsupportedOrderType {
348                order_type: order.order_type(),
349            });
350        }
351
352        // BUY market orders must use quote_quantity (amount in pUSD)
353        // SELL market orders must NOT use quote_quantity (amount in shares)
354        match order.order_side() {
355            OrderSide::Buy => {
356                if !order.is_quote_quantity() {
357                    return Err(validation_failed(
358                        "Market BUY orders require quote_quantity=true (amount in pUSD)",
359                    ));
360                }
361            }
362            OrderSide::Sell => {
363                if order.is_quote_quantity() {
364                    return Err(validation_failed(
365                        "Market SELL orders require quote_quantity=false (amount in shares)",
366                    ));
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
478/// Returns the share quantity encoded into a signed limit order.
479pub(crate) fn signed_limit_order_quantity(quantity: Decimal) -> Decimal {
480    quantity.trunc_with_scale(LOT_SIZE_SCALE)
481}
482
483fn validate_immediate_buy_maker_amount(
484    price: Decimal,
485    quantity: Decimal,
486    side: PolymarketOrderSide,
487    order_type: PolymarketOrderType,
488) -> Result<(), String> {
489    if side != PolymarketOrderSide::Buy
490        || !matches!(
491            order_type,
492            PolymarketOrderType::FAK | PolymarketOrderType::FOK
493        )
494    {
495        return Ok(());
496    }
497
498    let quantity = signed_limit_order_quantity(quantity);
499    let maker_amount = (quantity * price).normalize();
500    if maker_amount.scale() > LOT_SIZE_SCALE {
501        return Err(format!(
502            "Polymarket {order_type} BUY maker amount {} pUSD exceeds 2 decimal places for price {} and quantity {}",
503            maker_amount,
504            price.normalize(),
505            quantity.normalize(),
506        ));
507    }
508
509    Ok(())
510}
511
512/// Builds the maker/taker amounts for a Polymarket CLOB limit order.
513///
514/// The CLOB enforces precision constraints on both amounts:
515/// - Direct amounts (quantity passed through): max `LOT_SIZE_SCALE` (2) decimal places
516/// - Computed amounts (quantity * price): max `tick_decimals + LOT_SIZE_SCALE` decimal places
517///
518/// For BUY: paying pUSD (maker, computed) to receive CTF shares (taker, direct)
519/// For SELL: paying CTF shares (maker, direct) to receive pUSD (taker, computed)
520pub fn compute_maker_taker_amounts(
521    price: Decimal,
522    quantity: Decimal,
523    side: PolymarketOrderSide,
524    tick_decimals: u32,
525) -> (Decimal, Decimal) {
526    let precision = tick_decimals + LOT_SIZE_SCALE;
527    let qty = signed_limit_order_quantity(quantity);
528
529    match side {
530        PolymarketOrderSide::Buy => {
531            let maker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
532            let taker_amount = to_fixed_decimal(qty);
533            (maker_amount, taker_amount)
534        }
535        PolymarketOrderSide::Sell => {
536            let maker_amount = to_fixed_decimal(qty);
537            let taker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
538            (maker_amount, taker_amount)
539        }
540    }
541}
542
543/// Builds maker/taker amounts for a Polymarket market order.
544///
545/// Same precision constraints as limit orders. The direct amount is truncated to
546/// `LOT_SIZE_SCALE` decimal places before conversion (market order amounts like
547/// position sizes from fills may have more decimal places than the CLOB allows).
548///
549/// Unlike limit orders where quantity always means shares, market order semantics differ by side:
550/// - BUY: `amount` is pUSD to spend, compute shares received
551/// - SELL: `amount` is shares to sell, compute pUSD received
552pub fn compute_market_maker_taker_amounts(
553    price: Decimal,
554    amount: Decimal,
555    side: PolymarketOrderSide,
556    tick_decimals: u32,
557) -> (Decimal, Decimal) {
558    let precision = tick_decimals + LOT_SIZE_SCALE;
559    let amt = amount.trunc_with_scale(LOT_SIZE_SCALE);
560
561    match side {
562        PolymarketOrderSide::Buy => {
563            compute_quote_buy_maker_taker_amounts(price, amount, tick_decimals)
564        }
565        PolymarketOrderSide::Sell => {
566            let maker_amount = to_fixed_decimal(amt);
567            let taker_amount = to_fixed_decimal((amt * price).trunc_with_scale(precision));
568            (maker_amount, taker_amount)
569        }
570    }
571}
572
573fn compute_quote_buy_maker_taker_amounts(
574    price: Decimal,
575    amount: Decimal,
576    tick_decimals: u32,
577) -> (Decimal, Decimal) {
578    let precision = tick_decimals + LOT_SIZE_SCALE;
579    let maker_amount = amount.trunc_with_scale(LOT_SIZE_SCALE);
580    let taker_amount = (maker_amount / price).trunc_with_scale(precision);
581    (
582        to_fixed_decimal(maker_amount),
583        to_fixed_decimal(taker_amount),
584    )
585}
586
587/// Generates a random salt for order construction.
588///
589/// # Panics
590///
591/// Cannot panic: UUID v4 always produces 16 bytes, so the 8-byte slice conversion is infallible.
592pub fn generate_salt() -> u64 {
593    let bytes = uuid::Uuid::new_v4().into_bytes();
594    u64::from_le_bytes(bytes[..8].try_into().unwrap()) & ((1u64 << 53) - 1)
595}
596
597#[cfg(test)]
598mod tests {
599    use nautilus_core::UUID4;
600    use nautilus_model::{
601        enums::{OrderSide, OrderType, TimeInForce},
602        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
603        orders::{LimitOrder, MarketOrder, OrderAny},
604        types::{Price, Quantity},
605    };
606    use rstest::rstest;
607    use rust_decimal::Decimal;
608    use rust_decimal_macros::dec;
609
610    use super::*;
611    use crate::{
612        common::{credential::EvmPrivateKey, enums::PolymarketOrderSide},
613        execution::parse::adjust_market_buy_amount,
614        signing::eip712::OrderSigner,
615    };
616
617    fn make_limit(
618        reduce_only: bool,
619        quote_quantity: bool,
620        post_only: bool,
621        tif: TimeInForce,
622    ) -> OrderAny {
623        make_limit_at_price(
624            reduce_only,
625            quote_quantity,
626            post_only,
627            tif,
628            Price::from("0.50"),
629        )
630    }
631
632    fn make_limit_at_price(
633        reduce_only: bool,
634        quote_quantity: bool,
635        post_only: bool,
636        tif: TimeInForce,
637        price: Price,
638    ) -> OrderAny {
639        make_limit_with_quantity(
640            OrderSide::Buy,
641            Quantity::from("10"),
642            reduce_only,
643            quote_quantity,
644            post_only,
645            tif,
646            price,
647        )
648    }
649
650    fn make_limit_with_quantity(
651        side: OrderSide,
652        quantity: Quantity,
653        reduce_only: bool,
654        quote_quantity: bool,
655        post_only: bool,
656        tif: TimeInForce,
657        price: Price,
658    ) -> OrderAny {
659        let expire_time = if tif == TimeInForce::Gtd {
660            Some(UnixNanos::from(2_000_000_000_000_000_000u64))
661        } else {
662            None
663        };
664        OrderAny::Limit(LimitOrder::new(
665            TraderId::from("TESTER-001"),
666            StrategyId::from("S-001"),
667            InstrumentId::from("TEST.POLYMARKET"),
668            ClientOrderId::from("O-001"),
669            side,
670            quantity,
671            price,
672            tif,
673            expire_time,
674            post_only,
675            reduce_only,
676            quote_quantity,
677            None,
678            None,
679            None,
680            None,
681            None,
682            None,
683            None,
684            None,
685            None,
686            None,
687            None,
688            UUID4::new(),
689            UnixNanos::default(),
690        ))
691    }
692
693    fn with_expire_time(mut order: OrderAny, expire_time: Option<UnixNanos>) -> OrderAny {
694        let OrderAny::Limit(limit) = &mut order else {
695            unreachable!("test order is limit")
696        };
697        limit.expire_time = expire_time;
698        order
699    }
700
701    fn make_market(side: OrderSide, quote_quantity: bool) -> OrderAny {
702        OrderAny::Market(MarketOrder::new(
703            TraderId::from("TESTER-001"),
704            StrategyId::from("S-001"),
705            InstrumentId::from("TEST.POLYMARKET"),
706            ClientOrderId::from("O-001"),
707            side,
708            Quantity::from("10"),
709            TimeInForce::Ioc,
710            UUID4::new(),
711            UnixNanos::default(),
712            false,
713            quote_quantity,
714            None,
715            None,
716            None,
717            None,
718            None,
719            None,
720            None,
721            None,
722        ))
723    }
724
725    #[rstest]
726    fn test_validate_limit_order_valid() {
727        let order = make_limit(false, false, false, TimeInForce::Gtc);
728        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
729    }
730
731    #[rstest]
732    #[case("0.000", "0.001", "0.001", "0.999")]
733    #[case("1.000", "0.001", "0.001", "0.999")]
734    #[case("0.0000", "0.0001", "0.0001", "0.9999")]
735    #[case("1.0000", "0.0001", "0.0001", "0.9999")]
736    fn test_validate_limit_order_price_out_of_range_denied(
737        #[case] price: &str,
738        #[case] tick_size: &str,
739        #[case] min_price: &str,
740        #[case] max_price: &str,
741    ) {
742        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
743        let err = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
744            .unwrap_err();
745        assert_eq!(
746            err,
747            OrderDeniedReason::ValidationFailed {
748                detail: format!(
749                    "Limit order price {price} outside Polymarket range [{min_price}, {max_price}]"
750                ),
751            }
752        );
753    }
754
755    #[rstest]
756    #[case("0.001", "0.001")]
757    #[case("0.999", "0.001")]
758    #[case("0.0001", "0.0001")]
759    #[case("0.9999", "0.0001")]
760    #[case("0.005", "0.005")]
761    #[case("0.995", "0.005")]
762    #[case("0.0025", "0.0025")]
763    #[case("0.9975", "0.0025")]
764    fn test_validate_limit_order_price_boundary_allowed(
765        #[case] price: &str,
766        #[case] tick_size: &str,
767    ) {
768        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
769        assert!(
770            PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
771        );
772    }
773
774    #[rstest]
775    #[case("0.505", "0.005")]
776    #[case("0.5025", "0.0025")]
777    fn test_validate_limit_order_price_aligned_allowed(
778        #[case] price: &str,
779        #[case] tick_size: &str,
780    ) {
781        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
782        assert!(
783            PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
784        );
785    }
786
787    #[rstest]
788    #[case("0.501", "0.005")]
789    #[case("0.501", "0.0025")]
790    fn test_validate_limit_order_price_misaligned_denied(
791        #[case] price: &str,
792        #[case] tick_size: &str,
793    ) {
794        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
795        let error = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
796            .unwrap_err();
797        assert_eq!(
798            error,
799            OrderDeniedReason::ValidationFailed {
800                detail: format!(
801                    "Limit order price {price} does not conform to Polymarket tick size {tick_size}"
802                ),
803            }
804        );
805    }
806
807    #[rstest]
808    #[case::half_cent("0.5059", "0.005", 3, dec!(0.505))]
809    #[case::half_cent_p4("0.5059", "0.0050", 3, dec!(0.505))]
810    #[case::quarter_cent("0.50259", "0.0025", 4, dec!(0.5025))]
811    fn test_normalize_market_price(
812        #[case] price: &str,
813        #[case] tick_size: &str,
814        #[case] tick_decimals: u32,
815        #[case] expected: Decimal,
816    ) {
817        let normalized = PolymarketOrderBuilder::normalize_market_price(
818            price.parse::<Decimal>().unwrap(),
819            Price::from(tick_size),
820            tick_decimals,
821        )
822        .unwrap();
823
824        assert_eq!(normalized, expected);
825    }
826
827    #[rstest]
828    #[case::below_range(
829        "0.0049",
830        "0.005",
831        "Derived market price 0.004 outside Polymarket range [0.005, 0.995]"
832    )]
833    #[case::above_range(
834        "1.0000",
835        "0.0025",
836        "Derived market price 1 outside Polymarket range [0.0025, 0.9975]"
837    )]
838    #[case::off_half_cent(
839        "0.5019",
840        "0.005",
841        "Derived market price 0.501 does not conform to Polymarket tick size 0.005"
842    )]
843    #[case::off_quarter_cent(
844        "0.50129",
845        "0.0025",
846        "Derived market price 0.5012 does not conform to Polymarket tick size 0.0025"
847    )]
848    fn test_normalize_market_price_denied(
849        #[case] price: &str,
850        #[case] tick_size: &str,
851        #[case] expected: &str,
852    ) {
853        let tick_size = Price::from(tick_size);
854        let error = PolymarketOrderBuilder::normalize_market_price(
855            price.parse().unwrap(),
856            tick_size,
857            u32::from(tick_size.precision),
858        )
859        .unwrap_err();
860
861        assert_eq!(error, expected);
862    }
863
864    #[rstest]
865    #[case::below(
866        1_700_000_179_999_999_999,
867        Some("Polymarket GTD expiry must be at least 180 seconds in the future")
868    )]
869    #[case::equal(1_700_000_180_000_000_000, None)]
870    #[case::above(1_700_000_181_000_000_000, None)]
871    fn test_validate_limit_expiration_boundary(
872        #[case] expire_ns: u64,
873        #[case] expected_error: Option<&str>,
874    ) {
875        let order = with_expire_time(
876            make_limit(false, false, false, TimeInForce::Gtd),
877            Some(UnixNanos::from(expire_ns)),
878        );
879        let ts_now = UnixNanos::from(1_700_000_000_900_000_000u64);
880
881        let result = PolymarketOrderBuilder::validate_limit_expiration(&order, ts_now);
882
883        assert_eq!(
884            result.err().map(|reason| reason.to_string()),
885            expected_error.map(|detail| OrderDeniedReason::ValidationFailed {
886                detail: detail.to_string(),
887            }
888            .to_string())
889        );
890    }
891
892    #[rstest]
893    #[case::missing(None)]
894    #[case::zero(Some(UnixNanos::default()))]
895    fn test_validate_limit_expiration_requires_non_zero_expiry(
896        #[case] expire_time: Option<UnixNanos>,
897    ) {
898        let order = with_expire_time(
899            make_limit(false, false, false, TimeInForce::Gtd),
900            expire_time,
901        );
902
903        let error = PolymarketOrderBuilder::validate_limit_expiration(
904            &order,
905            UnixNanos::from(1_700_000_000_900_000_000u64),
906        )
907        .unwrap_err();
908
909        assert_eq!(error, OrderDeniedReason::MissingExpireTime);
910    }
911
912    #[rstest]
913    fn test_validate_limit_order_reduce_only_denied() {
914        let order = make_limit(true, false, false, TimeInForce::Gtc);
915        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
916        assert!(err.to_string().contains("Reduce-only"));
917    }
918
919    #[rstest]
920    fn test_validate_limit_order_quote_quantity_buy_allowed() {
921        let order = make_limit(false, true, false, TimeInForce::Gtc);
922        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
923    }
924
925    #[rstest]
926    fn test_validate_limit_order_quote_quantity_sell_denied() {
927        let order = make_limit_with_quantity(
928            OrderSide::Sell,
929            Quantity::from("10"),
930            false,
931            true,
932            false,
933            TimeInForce::Gtc,
934            Price::from("0.50"),
935        );
936        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
937        assert_eq!(
938            err,
939            validation_failed("Limit SELL orders require quote_quantity=false (amount in shares)")
940        );
941    }
942
943    #[rstest]
944    #[case::base(false, "shares")]
945    #[case::collateral(true, "pUSD")]
946    fn test_validate_limit_order_denies_quantity_below_signing_minimum(
947        #[case] quote_quantity: bool,
948        #[case] denomination: &str,
949    ) {
950        let order = make_limit_with_quantity(
951            OrderSide::Buy,
952            Quantity::from("0.009"),
953            false,
954            quote_quantity,
955            false,
956            TimeInForce::Gtc,
957            Price::from("0.50"),
958        );
959
960        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
961
962        assert_eq!(
963            err,
964            validation_failed(format!(
965                "Polymarket limit order amount 0.009 {denomination} truncates to zero at 2 decimal places"
966            ))
967        );
968    }
969
970    #[rstest]
971    fn test_validate_limit_order_post_only_ioc_denied() {
972        let order = make_limit(false, false, true, TimeInForce::Ioc);
973        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
974        assert!(err.to_string().contains("Post-only"));
975    }
976
977    #[rstest]
978    fn test_validate_limit_order_post_only_gtc_allowed() {
979        let order = make_limit(false, false, true, TimeInForce::Gtc);
980        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
981    }
982
983    #[rstest]
984    fn test_validate_market_order_buy_with_quote_qty() {
985        let order = make_market(OrderSide::Buy, true);
986        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
987    }
988
989    #[rstest]
990    fn test_validate_market_order_buy_without_quote_qty_denied() {
991        let order = make_market(OrderSide::Buy, false);
992        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
993        assert!(err.to_string().contains("quote_quantity=true"));
994    }
995
996    #[rstest]
997    fn test_validate_market_order_sell_without_quote_qty() {
998        let order = make_market(OrderSide::Sell, false);
999        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
1000    }
1001
1002    #[rstest]
1003    fn test_validate_market_order_sell_with_quote_qty_denied() {
1004        let order = make_market(OrderSide::Sell, true);
1005        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
1006        assert!(err.to_string().contains("quote_quantity=false"));
1007    }
1008
1009    #[rstest]
1010    fn test_validate_market_order_wrong_type_denied() {
1011        // Passing a limit order to validate_market_order should fail with type mismatch
1012        let limit = make_limit(false, false, false, TimeInForce::Gtc);
1013        let err = PolymarketOrderBuilder::validate_market_order(&limit).unwrap_err();
1014        assert_eq!(
1015            err,
1016            OrderDeniedReason::UnsupportedOrderType {
1017                order_type: OrderType::Limit,
1018            }
1019        );
1020    }
1021
1022    fn make_test_builder() -> PolymarketOrderBuilder {
1023        let pk = EvmPrivateKey::new(
1024            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1025        )
1026        .unwrap();
1027        let signer = OrderSigner::new(&pk).unwrap();
1028        let addr = format!("{:#x}", signer.address());
1029        PolymarketOrderBuilder::new(signer, addr.clone(), addr, PolymarketSignatureType::Eoa)
1030    }
1031
1032    #[rstest]
1033    fn test_next_timestamp_ms_is_strictly_monotonic() {
1034        let builder = make_test_builder();
1035        let mut prev = builder.next_timestamp_ms();
1036        for _ in 0..1_000 {
1037            let next = builder.next_timestamp_ms();
1038            assert!(
1039                next > prev,
1040                "timestamp not strictly monotonic: {prev} >= {next}"
1041            );
1042            prev = next;
1043        }
1044    }
1045
1046    #[rstest]
1047    fn test_build_orders_produce_unique_timestamps() {
1048        let builder = make_test_builder();
1049        let mut timestamps = ahash::AHashSet::new();
1050
1051        for _ in 0..50 {
1052            let order = builder
1053                .build_limit_order(
1054                    "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1055                    PolymarketOrderSide::Buy,
1056                    dec!(0.50),
1057                    dec!(10),
1058                    PolymarketOrderType::GTC,
1059                    "0",
1060                    false,
1061                    2,
1062                )
1063                .unwrap();
1064            assert!(
1065                timestamps.insert(order.timestamp.clone()),
1066                "duplicate timestamp {} in burst",
1067                order.timestamp,
1068            );
1069        }
1070    }
1071
1072    #[rstest]
1073    fn test_built_order_carries_nautilus_builder_code() {
1074        let builder = make_test_builder();
1075        let order = builder
1076            .build_limit_order(
1077                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1078                PolymarketOrderSide::Buy,
1079                dec!(0.50),
1080                dec!(10),
1081                PolymarketOrderType::GTC,
1082                "0",
1083                false,
1084                2,
1085            )
1086            .unwrap();
1087        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
1088    }
1089
1090    #[rstest]
1091    fn test_poly_1271_order_uses_deposit_wallet_as_signer() {
1092        let pk = EvmPrivateKey::new(
1093            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1094        )
1095        .unwrap();
1096        let signer = OrderSigner::new(&pk).unwrap();
1097        let signer_address = format!("{:#x}", signer.address());
1098        let deposit_wallet = "0x1111111111111111111111111111111111111111".to_string();
1099        let builder = PolymarketOrderBuilder::new(
1100            signer,
1101            signer_address,
1102            deposit_wallet.clone(),
1103            PolymarketSignatureType::Poly1271,
1104        );
1105
1106        let order = builder
1107            .build_limit_order(
1108                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1109                PolymarketOrderSide::Buy,
1110                dec!(0.50),
1111                dec!(10),
1112                PolymarketOrderType::GTC,
1113                "0",
1114                false,
1115                2,
1116            )
1117            .unwrap();
1118
1119        assert_eq!(order.maker, deposit_wallet);
1120        assert_eq!(order.signer, deposit_wallet);
1121        assert_eq!(order.signature_type, PolymarketSignatureType::Poly1271);
1122        assert_eq!(order.signature.expose_secret().len(), 636);
1123    }
1124
1125    #[rstest]
1126    fn test_build_limit_order_expiration_passthrough() {
1127        let builder = make_test_builder();
1128        let order = builder
1129            .build_limit_order(
1130                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1131                PolymarketOrderSide::Buy,
1132                dec!(0.50),
1133                dec!(10),
1134                PolymarketOrderType::GTD,
1135                "1735689600",
1136                false,
1137                2,
1138            )
1139            .unwrap();
1140        assert_eq!(order.expiration, "1735689600");
1141    }
1142
1143    #[rstest]
1144    fn test_build_limit_order_amount_matrix(
1145        #[values(1, 2, 3, 4)] tick_decimals: u32,
1146        #[values(PolymarketOrderSide::Buy, PolymarketOrderSide::Sell)] side: PolymarketOrderSide,
1147        #[values(
1148            PolymarketOrderType::GTC,
1149            PolymarketOrderType::GTD,
1150            PolymarketOrderType::FAK,
1151            PolymarketOrderType::FOK
1152        )]
1153        order_type: PolymarketOrderType,
1154    ) {
1155        let (price, quantity, notional) = match tick_decimals {
1156            1 => (dec!(0.5), dec!(10), dec!(5_000_000)),
1157            2 => (dec!(0.56), dec!(10), dec!(5_600_000)),
1158            3 => (dec!(0.961), dec!(10), dec!(9_610_000)),
1159            4 => (dec!(0.9612), dec!(25), dec!(24_030_000)),
1160            _ => unreachable!(),
1161        };
1162        let quantity_amount = to_fixed_decimal(quantity);
1163        let expiration = if order_type == PolymarketOrderType::GTD {
1164            "1735689600"
1165        } else {
1166            "0"
1167        };
1168        let builder = make_test_builder();
1169
1170        let order = builder
1171            .build_limit_order(
1172                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1173                side,
1174                price,
1175                quantity,
1176                order_type,
1177                expiration,
1178                false,
1179                tick_decimals,
1180            )
1181            .unwrap();
1182
1183        let expected = match side {
1184            PolymarketOrderSide::Buy => (notional, quantity_amount),
1185            PolymarketOrderSide::Sell => (quantity_amount, notional),
1186        };
1187        assert_eq!((order.maker_amount, order.taker_amount), expected);
1188    }
1189
1190    #[rstest]
1191    #[case::cent_tick(dec!(0.56), dec!(10.089), 2, dec!(10_080_000), dec!(18_000_000))]
1192    #[case::half_cent_tick(
1193        dec!(0.505),
1194        dec!(10.109),
1195        3,
1196        dec!(10_100_000),
1197        dec!(20_000_000)
1198    )]
1199    #[case::quarter_cent_tick(
1200        dec!(0.5025),
1201        dec!(10.059),
1202        4,
1203        dec!(10_050_000),
1204        dec!(20_000_000)
1205    )]
1206    #[case::minimum_order_size(dec!(0.99), dec!(4.959), 2, dec!(4_950_000), dec!(5_000_000))]
1207    fn test_build_collateral_limit_buy_amounts(
1208        #[case] price: Decimal,
1209        #[case] amount: Decimal,
1210        #[case] tick_decimals: u32,
1211        #[case] expected_maker: Decimal,
1212        #[case] expected_taker: Decimal,
1213    ) {
1214        let builder = make_test_builder();
1215
1216        let order = builder
1217            .build_limit_order_from_collateral(
1218                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1219                price,
1220                amount,
1221                "0",
1222                false,
1223                tick_decimals,
1224            )
1225            .unwrap();
1226
1227        assert_eq!(order.side, PolymarketOrderSide::Buy);
1228        assert_eq!(order.maker_amount, expected_maker);
1229        assert_eq!(order.taker_amount, expected_taker);
1230    }
1231
1232    #[rstest]
1233    #[case::zero_after_quantization(dec!(0.50), dec!(0.009), 2, "amount 0.009 pUSD truncates to zero")]
1234    #[case::zero_price(dec!(0), dec!(1), 2, "price must be positive")]
1235    #[case::inexact_signed_price(
1236        dec!(0.989),
1237        dec!(5),
1238        3,
1239        "amount 5 pUSD cannot preserve limit price 0.989 after venue quantization"
1240    )]
1241    fn test_build_collateral_limit_buy_denies_invalid_amounts(
1242        #[case] price: Decimal,
1243        #[case] amount: Decimal,
1244        #[case] tick_decimals: u32,
1245        #[case] expected_error: &str,
1246    ) {
1247        let builder = make_test_builder();
1248
1249        let error = builder
1250            .build_limit_order_from_collateral(
1251                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1252                price,
1253                amount,
1254                "0",
1255                false,
1256                tick_decimals,
1257            )
1258            .unwrap_err();
1259
1260        assert!(
1261            error.to_string().contains(expected_error),
1262            "unexpected error: {error}"
1263        );
1264    }
1265
1266    #[rstest]
1267    fn test_build_limit_order_denies_unrepresentable_immediate_buy(
1268        #[values(1, 2, 3, 4)] tick_decimals: u32,
1269        #[values(PolymarketOrderType::FAK, PolymarketOrderType::FOK)]
1270        order_type: PolymarketOrderType,
1271    ) {
1272        let (price, quantity, maker_amount) = match tick_decimals {
1273            1 => (dec!(0.5), dec!(1.01), dec!(0.505)),
1274            2 => (dec!(0.56), dec!(1.01), dec!(0.5656)),
1275            3 => (dec!(0.961), dec!(5), dec!(4.805)),
1276            4 => (dec!(0.9612), dec!(5), dec!(4.806)),
1277            _ => unreachable!(),
1278        };
1279        let builder = make_test_builder();
1280
1281        let error = builder
1282            .build_limit_order(
1283                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1284                PolymarketOrderSide::Buy,
1285                price,
1286                quantity,
1287                order_type,
1288                "0",
1289                false,
1290                tick_decimals,
1291            )
1292            .unwrap_err();
1293
1294        assert_eq!(
1295            error.to_string(),
1296            format!(
1297                "Polymarket {order_type} BUY maker amount {maker_amount} pUSD exceeds 2 decimal places for price {price} and quantity {quantity}"
1298            )
1299        );
1300    }
1301
1302    #[rstest]
1303    #[case::gtc_buy(
1304        PolymarketOrderType::GTC,
1305        PolymarketOrderSide::Buy,
1306        dec!(4_805_000),
1307        dec!(5_000_000),
1308    )]
1309    #[case::gtd_buy(
1310        PolymarketOrderType::GTD,
1311        PolymarketOrderSide::Buy,
1312        dec!(4_805_000),
1313        dec!(5_000_000),
1314    )]
1315    #[case::fak_sell(
1316        PolymarketOrderType::FAK,
1317        PolymarketOrderSide::Sell,
1318        dec!(5_000_000),
1319        dec!(4_805_000),
1320    )]
1321    #[case::fok_sell(
1322        PolymarketOrderType::FOK,
1323        PolymarketOrderSide::Sell,
1324        dec!(5_000_000),
1325        dec!(4_805_000),
1326    )]
1327    fn test_build_limit_order_preserves_unaffected_fractional_cent_amounts(
1328        #[case] order_type: PolymarketOrderType,
1329        #[case] side: PolymarketOrderSide,
1330        #[case] expected_maker: Decimal,
1331        #[case] expected_taker: Decimal,
1332    ) {
1333        let builder = make_test_builder();
1334
1335        let order = builder
1336            .build_limit_order(
1337                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1338                side,
1339                dec!(0.961),
1340                dec!(5),
1341                order_type,
1342                "0",
1343                false,
1344                3,
1345            )
1346            .unwrap();
1347
1348        assert_eq!(order.maker_amount, expected_maker);
1349        assert_eq!(order.taker_amount, expected_taker);
1350    }
1351
1352    #[rstest]
1353    fn test_validate_limit_order_gtd_with_expire_accepted() {
1354        // LimitOrder::new enforces GTD + expire_time upstream, so validate_limit_order
1355        // only needs to accept the valid case and let the lower layer reject mismatched
1356        // orders. Locking this behavior prevents a future regression where our
1357        // validator rejects GTD outright (which would break V2 GTD flows, as the V2
1358        // wire body carries expiration unsigned).
1359        let order = make_limit(false, false, false, TimeInForce::Gtd);
1360        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
1361    }
1362
1363    #[rstest]
1364    fn test_build_market_buy_order_wire_shape() {
1365        // Market BUY: `amount` is quote pUSD, so maker_amount = quote spend
1366        // and taker_amount = computed shares.
1367        let builder = make_test_builder();
1368        let order = builder
1369            .build_market_order(
1370                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1371                PolymarketOrderSide::Buy,
1372                dec!(0.50),
1373                dec!(10),
1374                false,
1375                2,
1376            )
1377            .unwrap();
1378
1379        assert_eq!(order.expiration, "0");
1380        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
1381        assert_eq!(order.metadata, ZERO_BYTES32);
1382        assert_eq!(order.side, PolymarketOrderSide::Buy);
1383        assert!(!order.timestamp.is_empty());
1384        assert!(!order.signature.expose_secret().is_empty());
1385        // Wire amounts are micro-pUSD / micro-share mantissas (10^6 scale).
1386        // Quote-denominated BUY: 10 pUSD spend -> 20 shares at price 0.50.
1387        assert_eq!(order.maker_amount, dec!(10_000_000));
1388        assert_eq!(order.taker_amount, dec!(20_000_000));
1389    }
1390
1391    #[rstest]
1392    fn test_build_market_buy_order_rejects_fee_adjusted_amount_below_lot_size() {
1393        let builder = make_test_builder();
1394        let adjusted = adjust_market_buy_amount(
1395            dec!(10),
1396            dec!(0.009),
1397            dec!(0.5),
1398            dec!(0.04),
1399            dec!(1),
1400            dec!(0),
1401        )
1402        .unwrap();
1403        assert_eq!(adjusted, dec!(0.008823));
1404
1405        let err = builder
1406            .build_market_order(
1407                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1408                PolymarketOrderSide::Buy,
1409                dec!(0.5),
1410                adjusted,
1411                false,
1412                2,
1413            )
1414            .unwrap_err();
1415
1416        assert_eq!(
1417            err.to_string(),
1418            "Polymarket market BUY amount 0.008823 pUSD truncates to zero at 2 decimal places",
1419        );
1420        assert_eq!(builder.last_timestamp_ms.load(Ordering::Relaxed), 0);
1421    }
1422
1423    #[rstest]
1424    fn test_build_market_sell_order_wire_shape() {
1425        // Market SELL: `amount` is shares, so maker_amount = shares
1426        // and taker_amount = computed pUSD proceeds.
1427        let builder = make_test_builder();
1428        let order = builder
1429            .build_market_order(
1430                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1431                PolymarketOrderSide::Sell,
1432                dec!(0.50),
1433                dec!(20),
1434                false,
1435                2,
1436            )
1437            .unwrap();
1438
1439        assert_eq!(order.expiration, "0");
1440        assert_eq!(order.side, PolymarketOrderSide::Sell);
1441        assert_eq!(order.maker_amount, dec!(20_000_000));
1442        assert_eq!(order.taker_amount, dec!(10_000_000));
1443        assert!(!order.signature.expose_secret().is_empty());
1444    }
1445
1446    #[rstest]
1447    fn test_generate_salt_uniqueness() {
1448        let s1 = generate_salt();
1449        let s2 = generate_salt();
1450        assert_ne!(s1, s2);
1451    }
1452
1453    #[rstest]
1454    fn test_generate_salt_within_53_bits() {
1455        for _ in 0..100 {
1456            let s = generate_salt();
1457            assert!(s < (1u64 << 53));
1458        }
1459    }
1460
1461    // Limit order amount tests
1462    // qty truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
1463    #[rstest]
1464    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1465    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1466    #[case(dec!(0.75), dec!(200), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1467    // qty=23.456 → trunc(2)=23.45, maker=(23.45*0.567).trunc(5)=13.29615→13_296_150
1468    #[case(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3, dec!(13_296_150), dec!(23_450_000))]
1469    #[case(dec!(0.55), dec!(10), PolymarketOrderSide::Buy, 1, dec!(5_500_000), dec!(10_000_000))]
1470    fn test_compute_maker_taker_amounts(
1471        #[case] price: Decimal,
1472        #[case] quantity: Decimal,
1473        #[case] side: PolymarketOrderSide,
1474        #[case] tick_decimals: u32,
1475        #[case] expected_maker: Decimal,
1476        #[case] expected_taker: Decimal,
1477    ) {
1478        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1479        assert_eq!(maker, expected_maker);
1480        assert_eq!(taker, expected_taker);
1481    }
1482
1483    // SDK parity vectors lifted from `polymarket-rs-clob-client-v2`'s
1484    // `tests/order.rs::lifecycle::limit::{buy,sell}::should_succeed_*`. They
1485    // pin (price, size, tick_size) to specific signed maker/taker amounts;
1486    // any drift in our truncation or scale logic is caught against the
1487    // reference SDK directly. Covers all four documented tick sizes on both
1488    // sides plus a handful of decimal-accuracy edge cases.
1489    #[rstest]
1490    // tick=0.1 (decimals=1)
1491    #[case::buy_tick_tenth(
1492        dec!(0.5), dec!(21.04), PolymarketOrderSide::Buy, 1,
1493        dec!(10_520_000), dec!(21_040_000),
1494    )]
1495    #[case::sell_tick_tenth(
1496        dec!(0.5), dec!(21.04), PolymarketOrderSide::Sell, 1,
1497        dec!(21_040_000), dec!(10_520_000),
1498    )]
1499    // tick=0.01 (decimals=2)
1500    #[case::buy_tick_hundredth(
1501        dec!(0.56), dec!(21.04), PolymarketOrderSide::Buy, 2,
1502        dec!(11_782_400), dec!(21_040_000),
1503    )]
1504    #[case::sell_tick_hundredth(
1505        dec!(0.56), dec!(21.04), PolymarketOrderSide::Sell, 2,
1506        dec!(21_040_000), dec!(11_782_400),
1507    )]
1508    #[case::buy_decimal_accuracy_24(
1509        dec!(0.24), dec!(15), PolymarketOrderSide::Buy, 2,
1510        dec!(3_600_000), dec!(15_000_000),
1511    )]
1512    #[case::buy_decimal_accuracy_82(
1513        dec!(0.82), dec!(101), PolymarketOrderSide::Buy, 2,
1514        dec!(82_820_000), dec!(101_000_000),
1515    )]
1516    #[case::buy_decimal_accuracy_18233(
1517        dec!(0.58), dec!(18233.33), PolymarketOrderSide::Buy, 2,
1518        dec!(10_575_331_400), dec!(18_233_330_000),
1519    )]
1520    // tick=0.001 (decimals=3)
1521    #[case::buy_tick_thousandth(
1522        dec!(0.056), dec!(21.04), PolymarketOrderSide::Buy, 3,
1523        dec!(1_178_240), dec!(21_040_000),
1524    )]
1525    #[case::sell_tick_thousandth(
1526        dec!(0.056), dec!(21.04), PolymarketOrderSide::Sell, 3,
1527        dec!(21_040_000), dec!(1_178_240),
1528    )]
1529    // tick=0.0001 (decimals=4)
1530    #[case::buy_tick_ten_thousandth(
1531        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Buy, 4,
1532        dec!(117_824), dec!(21_040_000),
1533    )]
1534    #[case::sell_tick_ten_thousandth(
1535        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Sell, 4,
1536        dec!(21_040_000), dec!(117_824),
1537    )]
1538    fn test_compute_maker_taker_amounts_sdk_parity(
1539        #[case] price: Decimal,
1540        #[case] quantity: Decimal,
1541        #[case] side: PolymarketOrderSide,
1542        #[case] tick_decimals: u32,
1543        #[case] expected_maker: Decimal,
1544        #[case] expected_taker: Decimal,
1545    ) {
1546        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1547        assert_eq!(maker, expected_maker);
1548        assert_eq!(taker, expected_taker);
1549    }
1550
1551    // Market order amount tests
1552    // amount truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
1553    #[rstest]
1554    #[case(dec!(0.50), dec!(50), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1555    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1556    #[case(dec!(0.75), dec!(150), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1557    #[case(dec!(0.96), dec!(5.208), PolymarketOrderSide::Sell, 2, dec!(5_200_000), dec!(4_992_000))]
1558    // amt=23.696681 → trunc(2)=23.69, taker=(23.69*0.211).trunc(5)=4.99859→4_998_590
1559    #[case(dec!(0.211), dec!(23.696681), PolymarketOrderSide::Sell, 3, dec!(23_690_000), dec!(4_998_590))]
1560    fn test_compute_market_maker_taker_amounts(
1561        #[case] price: Decimal,
1562        #[case] amount: Decimal,
1563        #[case] side: PolymarketOrderSide,
1564        #[case] tick_decimals: u32,
1565        #[case] expected_maker: Decimal,
1566        #[case] expected_taker: Decimal,
1567    ) {
1568        let (maker, taker) = compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
1569        assert_eq!(maker, expected_maker);
1570        assert_eq!(taker, expected_taker);
1571    }
1572
1573    #[rstest]
1574    fn test_compute_maker_taker_qty_truncated_to_lot_size() {
1575        // qty=23.456 → trunc(2)=23.45, tick_decimals=3 → precision=5
1576        // BUY: maker=(23.45*0.567).trunc(5)=13.29615→13_296_150, taker=23.45→23_450_000
1577        let (maker, taker) =
1578            compute_maker_taker_amounts(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3);
1579        assert_eq!(maker, dec!(13_296_150));
1580        assert_eq!(taker, dec!(23_450_000));
1581    }
1582
1583    #[rstest]
1584    fn test_compute_maker_taker_zero_amounts() {
1585        let (maker, taker) =
1586            compute_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1587        assert_eq!(maker, dec!(0));
1588        assert_eq!(taker, dec!(0));
1589    }
1590
1591    #[rstest]
1592    fn test_compute_maker_taker_tick_decimals_1() {
1593        let (maker, taker) =
1594            compute_maker_taker_amounts(dec!(0.3), dec!(50), PolymarketOrderSide::Buy, 1);
1595        assert_eq!(maker, dec!(15_000_000));
1596        assert_eq!(taker, dec!(50_000_000));
1597    }
1598
1599    #[rstest]
1600    fn test_compute_maker_taker_tick_decimals_3_sell() {
1601        // qty=15.123 → trunc(2)=15.12
1602        // SELL: maker=15.12→15_120_000, taker=(15.12*0.789).trunc(5)=11.92968→11_929_680
1603        let (maker, taker) =
1604            compute_maker_taker_amounts(dec!(0.789), dec!(15.123), PolymarketOrderSide::Sell, 3);
1605        assert_eq!(maker, dec!(15_120_000));
1606        assert_eq!(taker, dec!(11_929_680));
1607    }
1608
1609    #[rstest]
1610    fn test_compute_market_maker_taker_zero_amount() {
1611        let (maker, taker) =
1612            compute_market_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1613        assert_eq!(maker, dec!(0));
1614        assert_eq!(taker, dec!(0));
1615    }
1616
1617    #[rstest]
1618    fn test_compute_market_sell_position_close() {
1619        // Reproduces the live bug: position=23.696681 shares, price=0.211, tick_decimals=3
1620        // Without truncation: maker=23_696_681 (6 decimals) → rejected by CLOB
1621        // With truncation: amt=23.69 → maker=23_690_000 (2 decimals) → accepted
1622        let (maker, taker) = compute_market_maker_taker_amounts(
1623            dec!(0.211),
1624            dec!(23.696681),
1625            PolymarketOrderSide::Sell,
1626            3,
1627        );
1628        assert_eq!(maker, dec!(23_690_000));
1629        // 23.69 * 0.211 = 4.99859, trunc(5) = 4.99859
1630        assert_eq!(taker, dec!(4_998_590));
1631        // Verify maker has max 2 decimal precision: 23_690_000 / 1_000_000 = 23.69
1632        assert_eq!(maker % dec!(10_000), dec!(0));
1633        // Verify taker has max 5 decimal precision: 4_998_590 / 1_000_000 = 4.99859
1634        assert_eq!(taker % dec!(10), dec!(0));
1635    }
1636
1637    #[rstest]
1638    fn test_to_fixed_decimal_basic() {
1639        assert_eq!(to_fixed_decimal(dec!(13.29955)), dec!(13_299_550));
1640        assert_eq!(to_fixed_decimal(dec!(100)), dec!(100_000_000));
1641        assert_eq!(to_fixed_decimal(dec!(0)), dec!(0));
1642    }
1643}