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