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