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, time::get_atomic_clock_realtime};
33use nautilus_model::{
34    enums::{OrderSide, OrderType, TimeInForce},
35    events::OrderDeniedReason,
36    identifiers::VenueOrderId,
37    orders::{Order, OrderAny},
38    types::Price,
39};
40use rust_decimal::Decimal;
41use ustr::Ustr;
42
43use crate::{
44    common::{
45        consts::{LOT_SIZE_SCALE, POLYMARKET_NAUTILUS_BUILDER_CODE, USDC_DECIMALS},
46        enums::{PolymarketOrderSide, PolymarketOrderType, SignatureType},
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: SignatureType,
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: SignatureType,
79    ) -> Self {
80        Self {
81            order_signer,
82            signer_address,
83            maker_address,
84            signature_type,
85            last_timestamp_ms: AtomicU64::new(0),
86        }
87    }
88
89    // Returns a strictly-monotonic millisecond timestamp: the current wall
90    // time in ms, or `last_seen + 1` if that would be larger. Thread-safe.
91    fn next_timestamp_ms(&self) -> u64 {
92        let now_ms = get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000;
93
94        loop {
95            let prev = self.last_timestamp_ms.load(Ordering::Relaxed);
96            let candidate = prev.saturating_add(1).max(now_ms);
97
98            if self
99                .last_timestamp_ms
100                .compare_exchange_weak(prev, candidate, Ordering::Relaxed, Ordering::Relaxed)
101                .is_ok()
102            {
103                return candidate;
104            }
105        }
106    }
107
108    /// Builds and signs a limit order for submission.
109    ///
110    /// Immediate BUYs must have an exact cent-denominated maker amount. The builder rejects
111    /// quantities that cannot satisfy that constraint without changing the signed price/amount
112    /// ratio.
113    ///
114    /// `expiration` is a unix-seconds timestamp (`"0"` for non-GTD orders).
115    /// It is carried in the wire body but excluded from the EIP-712 signed hash.
116    #[expect(clippy::too_many_arguments)]
117    pub fn build_limit_order(
118        &self,
119        token_id: &str,
120        side: PolymarketOrderSide,
121        price: Decimal,
122        quantity: Decimal,
123        order_type: PolymarketOrderType,
124        expiration: &str,
125        neg_risk: bool,
126        tick_decimals: u32,
127    ) -> anyhow::Result<PolymarketOrder> {
128        validate_immediate_buy_maker_amount(price, quantity, side, order_type)
129            .map_err(anyhow::Error::msg)?;
130        let (maker_amount, taker_amount) =
131            compute_maker_taker_amounts(price, quantity, side, tick_decimals);
132        self.build_and_sign(
133            token_id,
134            side,
135            maker_amount,
136            taker_amount,
137            expiration,
138            neg_risk,
139        )
140    }
141
142    /// Builds and signs a market order for submission.
143    ///
144    /// `amount` semantics differ by side:
145    /// - BUY: `amount` is pUSD to spend
146    /// - SELL: `amount` is shares to sell
147    ///
148    /// Market orders never set an expiration.
149    pub fn build_market_order(
150        &self,
151        token_id: &str,
152        side: PolymarketOrderSide,
153        price: Decimal,
154        amount: Decimal,
155        neg_risk: bool,
156        tick_decimals: u32,
157    ) -> anyhow::Result<PolymarketOrder> {
158        if side == PolymarketOrderSide::Buy && amount.trunc_with_scale(LOT_SIZE_SCALE).is_zero() {
159            anyhow::bail!(
160                "Polymarket market BUY amount {} pUSD truncates to zero at {LOT_SIZE_SCALE} decimal places",
161                amount.normalize(),
162            );
163        }
164
165        let (maker_amount, taker_amount) =
166            compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
167        self.build_and_sign(token_id, side, maker_amount, taker_amount, "0", neg_risk)
168    }
169
170    /// Computes the Polymarket order ID for a signed CLOB V2 order.
171    pub fn expected_order_id(
172        &self,
173        order: &PolymarketOrder,
174        neg_risk: bool,
175    ) -> anyhow::Result<VenueOrderId> {
176        let hash = order_hash(order, neg_risk)
177            .map_err(|e| anyhow::anyhow!("Failed to derive order hash: {e}"))?;
178        let order_id = format!("{hash:#x}");
179        Ok(VenueOrderId::from(order_id.as_str()))
180    }
181
182    /// Validates a limit order before building, returning a denial reason if invalid.
183    pub fn validate_limit_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
184        if order.is_reduce_only() {
185            return Err(validation_failed(
186                "Reduce-only orders not supported on Polymarket",
187            ));
188        }
189
190        if order.order_type() != OrderType::Limit {
191            return Err(OrderDeniedReason::UnsupportedOrderType {
192                order_type: order.order_type(),
193            });
194        }
195
196        if order.is_quote_quantity() {
197            return Err(validation_failed(
198                "Quote quantity not supported for limit orders",
199            ));
200        }
201
202        let price = order
203            .price()
204            .ok_or_else(|| validation_failed("Limit orders require a price"))?;
205
206        let order_type = PolymarketOrderType::try_from(order.time_in_force())
207            .map_err(|_| OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()))?;
208
209        let side = PolymarketOrderSide::from(order.order_side());
210
211        if order.is_post_only()
212            && !matches!(order.time_in_force(), TimeInForce::Gtc | TimeInForce::Gtd)
213        {
214            return Err(validation_failed(
215                "Post-only orders require GTC or GTD time in force",
216            ));
217        }
218
219        validate_immediate_buy_maker_amount(
220            price.as_decimal(),
221            order.quantity().as_decimal(),
222            side,
223            order_type,
224        )
225        .map_err(validation_failed)?;
226
227        Ok(())
228    }
229
230    pub(crate) fn validate_limit_price(
231        order: &OrderAny,
232        tick_size: Price,
233    ) -> Result<(), OrderDeniedReason> {
234        let price = order
235            .price()
236            .ok_or_else(|| validation_failed("Limit orders require a price"))?;
237        let tick = tick_size.as_decimal();
238        let price_decimal = price.as_decimal();
239
240        validate_price(price_decimal, tick, "Limit order price", &price.to_string())
241            .map_err(validation_failed)
242    }
243
244    pub(crate) fn validate_limit_expiration(
245        order: &OrderAny,
246        ts_now: UnixNanos,
247    ) -> Result<(), OrderDeniedReason> {
248        if order.time_in_force() != TimeInForce::Gtd {
249            return Ok(());
250        }
251
252        let expire_time = order
253            .expire_time()
254            .filter(|expire_time| !expire_time.is_zero())
255            .ok_or(OrderDeniedReason::MissingExpireTime)?;
256        let now_secs = ts_now.as_seconds();
257        let expire_secs = expire_time.as_seconds();
258        let minimum_expire_secs = now_secs.saturating_add(GTD_EXPIRATION_BUFFER_SECS);
259
260        if expire_secs < minimum_expire_secs {
261            return Err(validation_failed(format!(
262                "Polymarket GTD expiry must be at least {GTD_EXPIRATION_BUFFER_SECS} seconds in the future"
263            )));
264        }
265
266        Ok(())
267    }
268
269    /// Validates a market order before building, returning a denial reason if invalid.
270    pub fn validate_market_order(order: &OrderAny) -> Result<(), OrderDeniedReason> {
271        if order.is_reduce_only() {
272            return Err(validation_failed(
273                "Reduce-only orders not supported on Polymarket",
274            ));
275        }
276
277        if order.order_type() != OrderType::Market {
278            return Err(OrderDeniedReason::UnsupportedOrderType {
279                order_type: order.order_type(),
280            });
281        }
282
283        // BUY market orders must use quote_quantity (amount in pUSD)
284        // SELL market orders must NOT use quote_quantity (amount in shares)
285        match order.order_side() {
286            OrderSide::Buy => {
287                if !order.is_quote_quantity() {
288                    return Err(validation_failed(
289                        "Market BUY orders require quote_quantity=true (amount in pUSD)",
290                    ));
291                }
292            }
293            OrderSide::Sell => {
294                if order.is_quote_quantity() {
295                    return Err(validation_failed(
296                        "Market SELL orders require quote_quantity=false (amount in shares)",
297                    ));
298                }
299            }
300        }
301
302        if PolymarketOrderType::from_market_time_in_force(order.time_in_force()).is_err() {
303            return Err(OrderDeniedReason::UnsupportedTimeInForce(
304                order.time_in_force(),
305            ));
306        }
307
308        Ok(())
309    }
310
311    pub(crate) fn normalize_market_price(
312        price: Decimal,
313        tick_size: Price,
314        tick_decimals: u32,
315    ) -> Result<Decimal, String> {
316        let tick = tick_size.as_decimal();
317        let price = price.trunc_with_scale(tick_decimals);
318
319        validate_price(
320            price,
321            tick,
322            "Derived market price",
323            &price.normalize().to_string(),
324        )?;
325
326        Ok(price)
327    }
328
329    fn build_and_sign(
330        &self,
331        token_id: &str,
332        side: PolymarketOrderSide,
333        maker_amount: Decimal,
334        taker_amount: Decimal,
335        expiration: &str,
336        neg_risk: bool,
337    ) -> anyhow::Result<PolymarketOrder> {
338        let salt = generate_salt();
339        let timestamp_ms = self.next_timestamp_ms();
340
341        let mut poly_order = PolymarketOrder {
342            salt,
343            maker: self.maker_address.clone(),
344            signer: self.order_signer_address(),
345            token_id: Ustr::from(token_id),
346            maker_amount,
347            taker_amount,
348            side,
349            signature_type: self.signature_type,
350            expiration: expiration.to_string(),
351            timestamp: timestamp_ms.to_string(),
352            metadata: ZERO_BYTES32.to_string(),
353            builder: POLYMARKET_NAUTILUS_BUILDER_CODE.to_string(),
354            signature: String::new(),
355        };
356
357        let signature = self
358            .order_signer
359            .sign_order(&poly_order, neg_risk)
360            .map_err(|e| anyhow::anyhow!("EIP-712 signing failed: {e}"))?;
361        poly_order.signature = signature;
362
363        Ok(poly_order)
364    }
365
366    fn order_signer_address(&self) -> String {
367        match self.signature_type {
368            SignatureType::Poly1271 => self.maker_address.clone(),
369            _ => self.signer_address.clone(),
370        }
371    }
372}
373
374fn validation_failed(detail: impl Into<String>) -> OrderDeniedReason {
375    OrderDeniedReason::ValidationFailed {
376        detail: detail.into(),
377    }
378}
379
380fn validate_price(
381    price: Decimal,
382    tick: Decimal,
383    label: &str,
384    price_display: &str,
385) -> Result<(), String> {
386    let tick = tick.normalize();
387    let max_price = Decimal::ONE - tick;
388
389    if price < tick || price > max_price {
390        return Err(format!(
391            "{label} {price_display} outside Polymarket range [{tick}, {max_price}]"
392        ));
393    }
394
395    if price % tick != Decimal::ZERO {
396        return Err(format!(
397            "{label} {price_display} does not conform to Polymarket tick size {tick}"
398        ));
399    }
400
401    Ok(())
402}
403
404fn to_fixed_decimal(d: Decimal) -> Decimal {
405    let mantissa = d.normalize().trunc_with_scale(USDC_DECIMALS).mantissa();
406    Decimal::from(mantissa)
407}
408
409/// Returns the share quantity encoded into a signed limit order.
410pub(crate) fn signed_limit_order_quantity(quantity: Decimal) -> Decimal {
411    quantity.trunc_with_scale(LOT_SIZE_SCALE)
412}
413
414fn validate_immediate_buy_maker_amount(
415    price: Decimal,
416    quantity: Decimal,
417    side: PolymarketOrderSide,
418    order_type: PolymarketOrderType,
419) -> Result<(), String> {
420    if side != PolymarketOrderSide::Buy
421        || !matches!(
422            order_type,
423            PolymarketOrderType::FAK | PolymarketOrderType::FOK
424        )
425    {
426        return Ok(());
427    }
428
429    let quantity = signed_limit_order_quantity(quantity);
430    let maker_amount = (quantity * price).normalize();
431    if maker_amount.scale() > LOT_SIZE_SCALE {
432        return Err(format!(
433            "Polymarket {order_type} BUY maker amount {} pUSD exceeds 2 decimal places for price {} and quantity {}",
434            maker_amount,
435            price.normalize(),
436            quantity.normalize(),
437        ));
438    }
439
440    Ok(())
441}
442
443/// Builds the maker/taker amounts for a Polymarket CLOB limit order.
444///
445/// The CLOB enforces precision constraints on both amounts:
446/// - Direct amounts (quantity passed through): max `LOT_SIZE_SCALE` (2) decimal places
447/// - Computed amounts (quantity * price): max `tick_decimals + LOT_SIZE_SCALE` decimal places
448///
449/// For BUY: paying pUSD (maker, computed) to receive CTF shares (taker, direct)
450/// For SELL: paying CTF shares (maker, direct) to receive pUSD (taker, computed)
451pub fn compute_maker_taker_amounts(
452    price: Decimal,
453    quantity: Decimal,
454    side: PolymarketOrderSide,
455    tick_decimals: u32,
456) -> (Decimal, Decimal) {
457    let precision = tick_decimals + LOT_SIZE_SCALE;
458    let qty = signed_limit_order_quantity(quantity);
459
460    match side {
461        PolymarketOrderSide::Buy => {
462            let maker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
463            let taker_amount = to_fixed_decimal(qty);
464            (maker_amount, taker_amount)
465        }
466        PolymarketOrderSide::Sell => {
467            let maker_amount = to_fixed_decimal(qty);
468            let taker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
469            (maker_amount, taker_amount)
470        }
471    }
472}
473
474/// Builds maker/taker amounts for a Polymarket market order.
475///
476/// Same precision constraints as limit orders. The direct amount is truncated to
477/// `LOT_SIZE_SCALE` decimal places before conversion (market order amounts like
478/// position sizes from fills may have more decimal places than the CLOB allows).
479///
480/// Unlike limit orders where quantity always means shares, market order semantics differ by side:
481/// - BUY: `amount` is pUSD to spend, compute shares received
482/// - SELL: `amount` is shares to sell, compute pUSD received
483pub fn compute_market_maker_taker_amounts(
484    price: Decimal,
485    amount: Decimal,
486    side: PolymarketOrderSide,
487    tick_decimals: u32,
488) -> (Decimal, Decimal) {
489    let precision = tick_decimals + LOT_SIZE_SCALE;
490    let amt = amount.trunc_with_scale(LOT_SIZE_SCALE);
491
492    match side {
493        PolymarketOrderSide::Buy => {
494            let maker_amount = to_fixed_decimal(amt);
495            let taker_amount = to_fixed_decimal((amt / price).trunc_with_scale(precision));
496            (maker_amount, taker_amount)
497        }
498        PolymarketOrderSide::Sell => {
499            let maker_amount = to_fixed_decimal(amt);
500            let taker_amount = to_fixed_decimal((amt * price).trunc_with_scale(precision));
501            (maker_amount, taker_amount)
502        }
503    }
504}
505
506/// Generates a random salt for order construction.
507///
508/// # Panics
509///
510/// Cannot panic: UUID v4 always produces 16 bytes, so the 8-byte slice conversion is infallible.
511pub fn generate_salt() -> u64 {
512    let bytes = uuid::Uuid::new_v4().into_bytes();
513    u64::from_le_bytes(bytes[..8].try_into().unwrap()) & ((1u64 << 53) - 1)
514}
515
516#[cfg(test)]
517mod tests {
518    use nautilus_core::UUID4;
519    use nautilus_model::{
520        enums::{OrderSide, OrderType, TimeInForce},
521        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
522        orders::{LimitOrder, MarketOrder, OrderAny},
523        types::{Price, Quantity},
524    };
525    use rstest::rstest;
526    use rust_decimal::Decimal;
527    use rust_decimal_macros::dec;
528
529    use super::*;
530    use crate::{
531        common::{credential::EvmPrivateKey, enums::PolymarketOrderSide},
532        execution::parse::adjust_market_buy_amount,
533        signing::eip712::OrderSigner,
534    };
535
536    fn make_limit(
537        reduce_only: bool,
538        quote_quantity: bool,
539        post_only: bool,
540        tif: TimeInForce,
541    ) -> OrderAny {
542        make_limit_at_price(
543            reduce_only,
544            quote_quantity,
545            post_only,
546            tif,
547            Price::from("0.50"),
548        )
549    }
550
551    fn make_limit_at_price(
552        reduce_only: bool,
553        quote_quantity: bool,
554        post_only: bool,
555        tif: TimeInForce,
556        price: Price,
557    ) -> OrderAny {
558        let expire_time = if tif == TimeInForce::Gtd {
559            Some(UnixNanos::from(2_000_000_000_000_000_000u64))
560        } else {
561            None
562        };
563        OrderAny::Limit(LimitOrder::new(
564            TraderId::from("TESTER-001"),
565            StrategyId::from("S-001"),
566            InstrumentId::from("TEST.POLYMARKET"),
567            ClientOrderId::from("O-001"),
568            OrderSide::Buy,
569            Quantity::from("10"),
570            price,
571            tif,
572            expire_time,
573            post_only,
574            reduce_only,
575            quote_quantity,
576            None,
577            None,
578            None,
579            None,
580            None,
581            None,
582            None,
583            None,
584            None,
585            None,
586            None,
587            UUID4::new(),
588            UnixNanos::default(),
589        ))
590    }
591
592    fn with_expire_time(mut order: OrderAny, expire_time: Option<UnixNanos>) -> OrderAny {
593        let OrderAny::Limit(limit) = &mut order else {
594            unreachable!("test order is limit")
595        };
596        limit.expire_time = expire_time;
597        order
598    }
599
600    fn make_market(side: OrderSide, quote_quantity: bool) -> OrderAny {
601        OrderAny::Market(MarketOrder::new(
602            TraderId::from("TESTER-001"),
603            StrategyId::from("S-001"),
604            InstrumentId::from("TEST.POLYMARKET"),
605            ClientOrderId::from("O-001"),
606            side,
607            Quantity::from("10"),
608            TimeInForce::Ioc,
609            UUID4::new(),
610            UnixNanos::default(),
611            false,
612            quote_quantity,
613            None,
614            None,
615            None,
616            None,
617            None,
618            None,
619            None,
620            None,
621        ))
622    }
623
624    #[rstest]
625    fn test_validate_limit_order_valid() {
626        let order = make_limit(false, false, false, TimeInForce::Gtc);
627        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
628    }
629
630    #[rstest]
631    #[case("0.000", "0.001", "0.001", "0.999")]
632    #[case("1.000", "0.001", "0.001", "0.999")]
633    #[case("0.0000", "0.0001", "0.0001", "0.9999")]
634    #[case("1.0000", "0.0001", "0.0001", "0.9999")]
635    fn test_validate_limit_order_price_out_of_range_denied(
636        #[case] price: &str,
637        #[case] tick_size: &str,
638        #[case] min_price: &str,
639        #[case] max_price: &str,
640    ) {
641        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
642        let err = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
643            .unwrap_err();
644        assert_eq!(
645            err,
646            OrderDeniedReason::ValidationFailed {
647                detail: format!(
648                    "Limit order price {price} outside Polymarket range [{min_price}, {max_price}]"
649                ),
650            }
651        );
652    }
653
654    #[rstest]
655    #[case("0.001", "0.001")]
656    #[case("0.999", "0.001")]
657    #[case("0.0001", "0.0001")]
658    #[case("0.9999", "0.0001")]
659    #[case("0.005", "0.005")]
660    #[case("0.995", "0.005")]
661    #[case("0.0025", "0.0025")]
662    #[case("0.9975", "0.0025")]
663    fn test_validate_limit_order_price_boundary_allowed(
664        #[case] price: &str,
665        #[case] tick_size: &str,
666    ) {
667        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
668        assert!(
669            PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
670        );
671    }
672
673    #[rstest]
674    #[case("0.505", "0.005")]
675    #[case("0.5025", "0.0025")]
676    fn test_validate_limit_order_price_aligned_allowed(
677        #[case] price: &str,
678        #[case] tick_size: &str,
679    ) {
680        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
681        assert!(
682            PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size)).is_ok()
683        );
684    }
685
686    #[rstest]
687    #[case("0.501", "0.005")]
688    #[case("0.501", "0.0025")]
689    fn test_validate_limit_order_price_misaligned_denied(
690        #[case] price: &str,
691        #[case] tick_size: &str,
692    ) {
693        let order = make_limit_at_price(false, false, false, TimeInForce::Gtc, Price::from(price));
694        let error = PolymarketOrderBuilder::validate_limit_price(&order, Price::from(tick_size))
695            .unwrap_err();
696        assert_eq!(
697            error,
698            OrderDeniedReason::ValidationFailed {
699                detail: format!(
700                    "Limit order price {price} does not conform to Polymarket tick size {tick_size}"
701                ),
702            }
703        );
704    }
705
706    #[rstest]
707    #[case::half_cent("0.5059", "0.005", 3, dec!(0.505))]
708    #[case::half_cent_p4("0.5059", "0.0050", 3, dec!(0.505))]
709    #[case::quarter_cent("0.50259", "0.0025", 4, dec!(0.5025))]
710    fn test_normalize_market_price(
711        #[case] price: &str,
712        #[case] tick_size: &str,
713        #[case] tick_decimals: u32,
714        #[case] expected: Decimal,
715    ) {
716        let normalized = PolymarketOrderBuilder::normalize_market_price(
717            price.parse::<Decimal>().unwrap(),
718            Price::from(tick_size),
719            tick_decimals,
720        )
721        .unwrap();
722
723        assert_eq!(normalized, expected);
724    }
725
726    #[rstest]
727    #[case::below_range(
728        "0.0049",
729        "0.005",
730        "Derived market price 0.004 outside Polymarket range [0.005, 0.995]"
731    )]
732    #[case::above_range(
733        "1.0000",
734        "0.0025",
735        "Derived market price 1 outside Polymarket range [0.0025, 0.9975]"
736    )]
737    #[case::off_half_cent(
738        "0.5019",
739        "0.005",
740        "Derived market price 0.501 does not conform to Polymarket tick size 0.005"
741    )]
742    #[case::off_quarter_cent(
743        "0.50129",
744        "0.0025",
745        "Derived market price 0.5012 does not conform to Polymarket tick size 0.0025"
746    )]
747    fn test_normalize_market_price_denied(
748        #[case] price: &str,
749        #[case] tick_size: &str,
750        #[case] expected: &str,
751    ) {
752        let tick_size = Price::from(tick_size);
753        let error = PolymarketOrderBuilder::normalize_market_price(
754            price.parse().unwrap(),
755            tick_size,
756            u32::from(tick_size.precision),
757        )
758        .unwrap_err();
759
760        assert_eq!(error, expected);
761    }
762
763    #[rstest]
764    #[case::below(
765        1_700_000_179_999_999_999,
766        Some("Polymarket GTD expiry must be at least 180 seconds in the future")
767    )]
768    #[case::equal(1_700_000_180_000_000_000, None)]
769    #[case::above(1_700_000_181_000_000_000, None)]
770    fn test_validate_limit_expiration_boundary(
771        #[case] expire_ns: u64,
772        #[case] expected_error: Option<&str>,
773    ) {
774        let order = with_expire_time(
775            make_limit(false, false, false, TimeInForce::Gtd),
776            Some(UnixNanos::from(expire_ns)),
777        );
778        let ts_now = UnixNanos::from(1_700_000_000_900_000_000u64);
779
780        let result = PolymarketOrderBuilder::validate_limit_expiration(&order, ts_now);
781
782        assert_eq!(
783            result.err().map(|reason| reason.to_string()),
784            expected_error.map(|detail| OrderDeniedReason::ValidationFailed {
785                detail: detail.to_string(),
786            }
787            .to_string())
788        );
789    }
790
791    #[rstest]
792    #[case::missing(None)]
793    #[case::zero(Some(UnixNanos::default()))]
794    fn test_validate_limit_expiration_requires_non_zero_expiry(
795        #[case] expire_time: Option<UnixNanos>,
796    ) {
797        let order = with_expire_time(
798            make_limit(false, false, false, TimeInForce::Gtd),
799            expire_time,
800        );
801
802        let error = PolymarketOrderBuilder::validate_limit_expiration(
803            &order,
804            UnixNanos::from(1_700_000_000_900_000_000u64),
805        )
806        .unwrap_err();
807
808        assert_eq!(error, OrderDeniedReason::MissingExpireTime);
809    }
810
811    #[rstest]
812    fn test_validate_limit_order_reduce_only_denied() {
813        let order = make_limit(true, false, false, TimeInForce::Gtc);
814        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
815        assert!(err.to_string().contains("Reduce-only"));
816    }
817
818    #[rstest]
819    fn test_validate_limit_order_quote_quantity_denied() {
820        let order = make_limit(false, true, false, TimeInForce::Gtc);
821        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
822        assert!(err.to_string().contains("Quote quantity"));
823    }
824
825    #[rstest]
826    fn test_validate_limit_order_post_only_ioc_denied() {
827        let order = make_limit(false, false, true, TimeInForce::Ioc);
828        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
829        assert!(err.to_string().contains("Post-only"));
830    }
831
832    #[rstest]
833    fn test_validate_limit_order_post_only_gtc_allowed() {
834        let order = make_limit(false, false, true, TimeInForce::Gtc);
835        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
836    }
837
838    #[rstest]
839    fn test_validate_market_order_buy_with_quote_qty() {
840        let order = make_market(OrderSide::Buy, true);
841        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
842    }
843
844    #[rstest]
845    fn test_validate_market_order_buy_without_quote_qty_denied() {
846        let order = make_market(OrderSide::Buy, false);
847        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
848        assert!(err.to_string().contains("quote_quantity=true"));
849    }
850
851    #[rstest]
852    fn test_validate_market_order_sell_without_quote_qty() {
853        let order = make_market(OrderSide::Sell, false);
854        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
855    }
856
857    #[rstest]
858    fn test_validate_market_order_sell_with_quote_qty_denied() {
859        let order = make_market(OrderSide::Sell, true);
860        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
861        assert!(err.to_string().contains("quote_quantity=false"));
862    }
863
864    #[rstest]
865    fn test_validate_market_order_wrong_type_denied() {
866        // Passing a limit order to validate_market_order should fail with type mismatch
867        let limit = make_limit(false, false, false, TimeInForce::Gtc);
868        let err = PolymarketOrderBuilder::validate_market_order(&limit).unwrap_err();
869        assert_eq!(
870            err,
871            OrderDeniedReason::UnsupportedOrderType {
872                order_type: OrderType::Limit,
873            }
874        );
875    }
876
877    fn make_test_builder() -> PolymarketOrderBuilder {
878        let pk = EvmPrivateKey::new(
879            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
880        )
881        .unwrap();
882        let signer = OrderSigner::new(&pk).unwrap();
883        let addr = format!("{:#x}", signer.address());
884        PolymarketOrderBuilder::new(signer, addr.clone(), addr, SignatureType::Eoa)
885    }
886
887    #[rstest]
888    fn test_next_timestamp_ms_is_strictly_monotonic() {
889        let builder = make_test_builder();
890        let mut prev = builder.next_timestamp_ms();
891        for _ in 0..1_000 {
892            let next = builder.next_timestamp_ms();
893            assert!(
894                next > prev,
895                "timestamp not strictly monotonic: {prev} >= {next}"
896            );
897            prev = next;
898        }
899    }
900
901    #[rstest]
902    fn test_build_orders_produce_unique_timestamps() {
903        let builder = make_test_builder();
904        let mut timestamps = ahash::AHashSet::new();
905
906        for _ in 0..50 {
907            let order = builder
908                .build_limit_order(
909                    "71321045679252212594626385532706912750332728571942532289631379312455583992563",
910                    PolymarketOrderSide::Buy,
911                    dec!(0.50),
912                    dec!(10),
913                    PolymarketOrderType::GTC,
914                    "0",
915                    false,
916                    2,
917                )
918                .unwrap();
919            assert!(
920                timestamps.insert(order.timestamp.clone()),
921                "duplicate timestamp {} in burst",
922                order.timestamp,
923            );
924        }
925    }
926
927    #[rstest]
928    fn test_built_order_carries_nautilus_builder_code() {
929        let builder = make_test_builder();
930        let order = builder
931            .build_limit_order(
932                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
933                PolymarketOrderSide::Buy,
934                dec!(0.50),
935                dec!(10),
936                PolymarketOrderType::GTC,
937                "0",
938                false,
939                2,
940            )
941            .unwrap();
942        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
943    }
944
945    #[rstest]
946    fn test_poly_1271_order_uses_deposit_wallet_as_signer() {
947        let pk = EvmPrivateKey::new(
948            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
949        )
950        .unwrap();
951        let signer = OrderSigner::new(&pk).unwrap();
952        let signer_address = format!("{:#x}", signer.address());
953        let deposit_wallet = "0x1111111111111111111111111111111111111111".to_string();
954        let builder = PolymarketOrderBuilder::new(
955            signer,
956            signer_address,
957            deposit_wallet.clone(),
958            SignatureType::Poly1271,
959        );
960
961        let order = builder
962            .build_limit_order(
963                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
964                PolymarketOrderSide::Buy,
965                dec!(0.50),
966                dec!(10),
967                PolymarketOrderType::GTC,
968                "0",
969                false,
970                2,
971            )
972            .unwrap();
973
974        assert_eq!(order.maker, deposit_wallet);
975        assert_eq!(order.signer, deposit_wallet);
976        assert_eq!(order.signature_type, SignatureType::Poly1271);
977        assert_eq!(order.signature.len(), 636);
978    }
979
980    #[rstest]
981    fn test_build_limit_order_expiration_passthrough() {
982        let builder = make_test_builder();
983        let order = builder
984            .build_limit_order(
985                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
986                PolymarketOrderSide::Buy,
987                dec!(0.50),
988                dec!(10),
989                PolymarketOrderType::GTD,
990                "1735689600",
991                false,
992                2,
993            )
994            .unwrap();
995        assert_eq!(order.expiration, "1735689600");
996    }
997
998    #[rstest]
999    fn test_build_limit_order_amount_matrix(
1000        #[values(1, 2, 3, 4)] tick_decimals: u32,
1001        #[values(PolymarketOrderSide::Buy, PolymarketOrderSide::Sell)] side: PolymarketOrderSide,
1002        #[values(
1003            PolymarketOrderType::GTC,
1004            PolymarketOrderType::GTD,
1005            PolymarketOrderType::FAK,
1006            PolymarketOrderType::FOK
1007        )]
1008        order_type: PolymarketOrderType,
1009    ) {
1010        let (price, quantity, notional) = match tick_decimals {
1011            1 => (dec!(0.5), dec!(10), dec!(5_000_000)),
1012            2 => (dec!(0.56), dec!(10), dec!(5_600_000)),
1013            3 => (dec!(0.961), dec!(10), dec!(9_610_000)),
1014            4 => (dec!(0.9612), dec!(25), dec!(24_030_000)),
1015            _ => unreachable!(),
1016        };
1017        let quantity_amount = to_fixed_decimal(quantity);
1018        let expiration = if order_type == PolymarketOrderType::GTD {
1019            "1735689600"
1020        } else {
1021            "0"
1022        };
1023        let builder = make_test_builder();
1024
1025        let order = builder
1026            .build_limit_order(
1027                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1028                side,
1029                price,
1030                quantity,
1031                order_type,
1032                expiration,
1033                false,
1034                tick_decimals,
1035            )
1036            .unwrap();
1037
1038        let expected = match side {
1039            PolymarketOrderSide::Buy => (notional, quantity_amount),
1040            PolymarketOrderSide::Sell => (quantity_amount, notional),
1041        };
1042        assert_eq!((order.maker_amount, order.taker_amount), expected);
1043    }
1044
1045    #[rstest]
1046    fn test_build_limit_order_denies_unrepresentable_immediate_buy(
1047        #[values(1, 2, 3, 4)] tick_decimals: u32,
1048        #[values(PolymarketOrderType::FAK, PolymarketOrderType::FOK)]
1049        order_type: PolymarketOrderType,
1050    ) {
1051        let (price, quantity, maker_amount) = match tick_decimals {
1052            1 => (dec!(0.5), dec!(1.01), dec!(0.505)),
1053            2 => (dec!(0.56), dec!(1.01), dec!(0.5656)),
1054            3 => (dec!(0.961), dec!(5), dec!(4.805)),
1055            4 => (dec!(0.9612), dec!(5), dec!(4.806)),
1056            _ => unreachable!(),
1057        };
1058        let builder = make_test_builder();
1059
1060        let error = builder
1061            .build_limit_order(
1062                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1063                PolymarketOrderSide::Buy,
1064                price,
1065                quantity,
1066                order_type,
1067                "0",
1068                false,
1069                tick_decimals,
1070            )
1071            .unwrap_err();
1072
1073        assert_eq!(
1074            error.to_string(),
1075            format!(
1076                "Polymarket {order_type} BUY maker amount {maker_amount} pUSD exceeds 2 decimal places for price {price} and quantity {quantity}"
1077            )
1078        );
1079    }
1080
1081    #[rstest]
1082    #[case::gtc_buy(
1083        PolymarketOrderType::GTC,
1084        PolymarketOrderSide::Buy,
1085        dec!(4_805_000),
1086        dec!(5_000_000),
1087    )]
1088    #[case::gtd_buy(
1089        PolymarketOrderType::GTD,
1090        PolymarketOrderSide::Buy,
1091        dec!(4_805_000),
1092        dec!(5_000_000),
1093    )]
1094    #[case::fak_sell(
1095        PolymarketOrderType::FAK,
1096        PolymarketOrderSide::Sell,
1097        dec!(5_000_000),
1098        dec!(4_805_000),
1099    )]
1100    #[case::fok_sell(
1101        PolymarketOrderType::FOK,
1102        PolymarketOrderSide::Sell,
1103        dec!(5_000_000),
1104        dec!(4_805_000),
1105    )]
1106    fn test_build_limit_order_preserves_unaffected_fractional_cent_amounts(
1107        #[case] order_type: PolymarketOrderType,
1108        #[case] side: PolymarketOrderSide,
1109        #[case] expected_maker: Decimal,
1110        #[case] expected_taker: Decimal,
1111    ) {
1112        let builder = make_test_builder();
1113
1114        let order = builder
1115            .build_limit_order(
1116                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1117                side,
1118                dec!(0.961),
1119                dec!(5),
1120                order_type,
1121                "0",
1122                false,
1123                3,
1124            )
1125            .unwrap();
1126
1127        assert_eq!(order.maker_amount, expected_maker);
1128        assert_eq!(order.taker_amount, expected_taker);
1129    }
1130
1131    #[rstest]
1132    fn test_validate_limit_order_gtd_with_expire_accepted() {
1133        // LimitOrder::new enforces GTD + expire_time upstream, so validate_limit_order
1134        // only needs to accept the valid case and let the lower layer reject mismatched
1135        // orders. Locking this behavior prevents a future regression where our
1136        // validator rejects GTD outright (which would break V2 GTD flows, as the V2
1137        // wire body carries expiration unsigned).
1138        let order = make_limit(false, false, false, TimeInForce::Gtd);
1139        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
1140    }
1141
1142    #[rstest]
1143    fn test_build_market_buy_order_wire_shape() {
1144        // Market BUY: `amount` is quote pUSD, so maker_amount = quote spend
1145        // and taker_amount = computed shares.
1146        let builder = make_test_builder();
1147        let order = builder
1148            .build_market_order(
1149                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1150                PolymarketOrderSide::Buy,
1151                dec!(0.50),
1152                dec!(10),
1153                false,
1154                2,
1155            )
1156            .unwrap();
1157
1158        assert_eq!(order.expiration, "0");
1159        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
1160        assert_eq!(order.metadata, ZERO_BYTES32);
1161        assert_eq!(order.side, PolymarketOrderSide::Buy);
1162        assert!(!order.timestamp.is_empty());
1163        assert!(!order.signature.is_empty());
1164        // Wire amounts are micro-pUSD / micro-share mantissas (10^6 scale).
1165        // Quote-denominated BUY: 10 pUSD spend -> 20 shares at price 0.50.
1166        assert_eq!(order.maker_amount, dec!(10_000_000));
1167        assert_eq!(order.taker_amount, dec!(20_000_000));
1168    }
1169
1170    #[rstest]
1171    fn test_build_market_buy_order_rejects_fee_adjusted_amount_below_lot_size() {
1172        let builder = make_test_builder();
1173        let adjusted =
1174            adjust_market_buy_amount(dec!(10), dec!(0.009), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1175                .unwrap();
1176        assert_eq!(adjusted, dec!(0.008823));
1177
1178        let err = builder
1179            .build_market_order(
1180                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1181                PolymarketOrderSide::Buy,
1182                dec!(0.5),
1183                adjusted,
1184                false,
1185                2,
1186            )
1187            .unwrap_err();
1188
1189        assert_eq!(
1190            err.to_string(),
1191            "Polymarket market BUY amount 0.008823 pUSD truncates to zero at 2 decimal places",
1192        );
1193        assert_eq!(builder.last_timestamp_ms.load(Ordering::Relaxed), 0);
1194    }
1195
1196    #[rstest]
1197    fn test_build_market_sell_order_wire_shape() {
1198        // Market SELL: `amount` is shares, so maker_amount = shares
1199        // and taker_amount = computed pUSD proceeds.
1200        let builder = make_test_builder();
1201        let order = builder
1202            .build_market_order(
1203                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
1204                PolymarketOrderSide::Sell,
1205                dec!(0.50),
1206                dec!(20),
1207                false,
1208                2,
1209            )
1210            .unwrap();
1211
1212        assert_eq!(order.expiration, "0");
1213        assert_eq!(order.side, PolymarketOrderSide::Sell);
1214        assert_eq!(order.maker_amount, dec!(20_000_000));
1215        assert_eq!(order.taker_amount, dec!(10_000_000));
1216        assert!(!order.signature.is_empty());
1217    }
1218
1219    #[rstest]
1220    fn test_generate_salt_uniqueness() {
1221        let s1 = generate_salt();
1222        let s2 = generate_salt();
1223        assert_ne!(s1, s2);
1224    }
1225
1226    #[rstest]
1227    fn test_generate_salt_within_53_bits() {
1228        for _ in 0..100 {
1229            let s = generate_salt();
1230            assert!(s < (1u64 << 53));
1231        }
1232    }
1233
1234    // Limit order amount tests
1235    // qty truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
1236    #[rstest]
1237    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1238    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1239    #[case(dec!(0.75), dec!(200), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1240    // qty=23.456 → trunc(2)=23.45, maker=(23.45*0.567).trunc(5)=13.29615→13_296_150
1241    #[case(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3, dec!(13_296_150), dec!(23_450_000))]
1242    #[case(dec!(0.55), dec!(10), PolymarketOrderSide::Buy, 1, dec!(5_500_000), dec!(10_000_000))]
1243    fn test_compute_maker_taker_amounts(
1244        #[case] price: Decimal,
1245        #[case] quantity: Decimal,
1246        #[case] side: PolymarketOrderSide,
1247        #[case] tick_decimals: u32,
1248        #[case] expected_maker: Decimal,
1249        #[case] expected_taker: Decimal,
1250    ) {
1251        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1252        assert_eq!(maker, expected_maker);
1253        assert_eq!(taker, expected_taker);
1254    }
1255
1256    // SDK parity vectors lifted from `polymarket-rs-clob-client-v2`'s
1257    // `tests/order.rs::lifecycle::limit::{buy,sell}::should_succeed_*`. They
1258    // pin (price, size, tick_size) to specific signed maker/taker amounts;
1259    // any drift in our truncation or scale logic is caught against the
1260    // reference SDK directly. Covers all four documented tick sizes on both
1261    // sides plus a handful of decimal-accuracy edge cases.
1262    #[rstest]
1263    // tick=0.1 (decimals=1)
1264    #[case::buy_tick_tenth(
1265        dec!(0.5), dec!(21.04), PolymarketOrderSide::Buy, 1,
1266        dec!(10_520_000), dec!(21_040_000),
1267    )]
1268    #[case::sell_tick_tenth(
1269        dec!(0.5), dec!(21.04), PolymarketOrderSide::Sell, 1,
1270        dec!(21_040_000), dec!(10_520_000),
1271    )]
1272    // tick=0.01 (decimals=2)
1273    #[case::buy_tick_hundredth(
1274        dec!(0.56), dec!(21.04), PolymarketOrderSide::Buy, 2,
1275        dec!(11_782_400), dec!(21_040_000),
1276    )]
1277    #[case::sell_tick_hundredth(
1278        dec!(0.56), dec!(21.04), PolymarketOrderSide::Sell, 2,
1279        dec!(21_040_000), dec!(11_782_400),
1280    )]
1281    #[case::buy_decimal_accuracy_24(
1282        dec!(0.24), dec!(15), PolymarketOrderSide::Buy, 2,
1283        dec!(3_600_000), dec!(15_000_000),
1284    )]
1285    #[case::buy_decimal_accuracy_82(
1286        dec!(0.82), dec!(101), PolymarketOrderSide::Buy, 2,
1287        dec!(82_820_000), dec!(101_000_000),
1288    )]
1289    #[case::buy_decimal_accuracy_18233(
1290        dec!(0.58), dec!(18233.33), PolymarketOrderSide::Buy, 2,
1291        dec!(10_575_331_400), dec!(18_233_330_000),
1292    )]
1293    // tick=0.001 (decimals=3)
1294    #[case::buy_tick_thousandth(
1295        dec!(0.056), dec!(21.04), PolymarketOrderSide::Buy, 3,
1296        dec!(1_178_240), dec!(21_040_000),
1297    )]
1298    #[case::sell_tick_thousandth(
1299        dec!(0.056), dec!(21.04), PolymarketOrderSide::Sell, 3,
1300        dec!(21_040_000), dec!(1_178_240),
1301    )]
1302    // tick=0.0001 (decimals=4)
1303    #[case::buy_tick_ten_thousandth(
1304        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Buy, 4,
1305        dec!(117_824), dec!(21_040_000),
1306    )]
1307    #[case::sell_tick_ten_thousandth(
1308        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Sell, 4,
1309        dec!(21_040_000), dec!(117_824),
1310    )]
1311    fn test_compute_maker_taker_amounts_sdk_parity(
1312        #[case] price: Decimal,
1313        #[case] quantity: Decimal,
1314        #[case] side: PolymarketOrderSide,
1315        #[case] tick_decimals: u32,
1316        #[case] expected_maker: Decimal,
1317        #[case] expected_taker: Decimal,
1318    ) {
1319        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
1320        assert_eq!(maker, expected_maker);
1321        assert_eq!(taker, expected_taker);
1322    }
1323
1324    // Market order amount tests
1325    // amount truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
1326    #[rstest]
1327    #[case(dec!(0.50), dec!(50), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
1328    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
1329    #[case(dec!(0.75), dec!(150), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
1330    #[case(dec!(0.96), dec!(5.208), PolymarketOrderSide::Sell, 2, dec!(5_200_000), dec!(4_992_000))]
1331    // amt=23.696681 → trunc(2)=23.69, taker=(23.69*0.211).trunc(5)=4.99859→4_998_590
1332    #[case(dec!(0.211), dec!(23.696681), PolymarketOrderSide::Sell, 3, dec!(23_690_000), dec!(4_998_590))]
1333    fn test_compute_market_maker_taker_amounts(
1334        #[case] price: Decimal,
1335        #[case] amount: Decimal,
1336        #[case] side: PolymarketOrderSide,
1337        #[case] tick_decimals: u32,
1338        #[case] expected_maker: Decimal,
1339        #[case] expected_taker: Decimal,
1340    ) {
1341        let (maker, taker) = compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
1342        assert_eq!(maker, expected_maker);
1343        assert_eq!(taker, expected_taker);
1344    }
1345
1346    #[rstest]
1347    fn test_compute_maker_taker_qty_truncated_to_lot_size() {
1348        // qty=23.456 → trunc(2)=23.45, tick_decimals=3 → precision=5
1349        // BUY: maker=(23.45*0.567).trunc(5)=13.29615→13_296_150, taker=23.45→23_450_000
1350        let (maker, taker) =
1351            compute_maker_taker_amounts(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3);
1352        assert_eq!(maker, dec!(13_296_150));
1353        assert_eq!(taker, dec!(23_450_000));
1354    }
1355
1356    #[rstest]
1357    fn test_compute_maker_taker_zero_amounts() {
1358        let (maker, taker) =
1359            compute_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1360        assert_eq!(maker, dec!(0));
1361        assert_eq!(taker, dec!(0));
1362    }
1363
1364    #[rstest]
1365    fn test_compute_maker_taker_tick_decimals_1() {
1366        let (maker, taker) =
1367            compute_maker_taker_amounts(dec!(0.3), dec!(50), PolymarketOrderSide::Buy, 1);
1368        assert_eq!(maker, dec!(15_000_000));
1369        assert_eq!(taker, dec!(50_000_000));
1370    }
1371
1372    #[rstest]
1373    fn test_compute_maker_taker_tick_decimals_3_sell() {
1374        // qty=15.123 → trunc(2)=15.12
1375        // SELL: maker=15.12→15_120_000, taker=(15.12*0.789).trunc(5)=11.92968→11_929_680
1376        let (maker, taker) =
1377            compute_maker_taker_amounts(dec!(0.789), dec!(15.123), PolymarketOrderSide::Sell, 3);
1378        assert_eq!(maker, dec!(15_120_000));
1379        assert_eq!(taker, dec!(11_929_680));
1380    }
1381
1382    #[rstest]
1383    fn test_compute_market_maker_taker_zero_amount() {
1384        let (maker, taker) =
1385            compute_market_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
1386        assert_eq!(maker, dec!(0));
1387        assert_eq!(taker, dec!(0));
1388    }
1389
1390    #[rstest]
1391    fn test_compute_market_sell_position_close() {
1392        // Reproduces the live bug: position=23.696681 shares, price=0.211, tick_decimals=3
1393        // Without truncation: maker=23_696_681 (6 decimals) → rejected by CLOB
1394        // With truncation: amt=23.69 → maker=23_690_000 (2 decimals) → accepted
1395        let (maker, taker) = compute_market_maker_taker_amounts(
1396            dec!(0.211),
1397            dec!(23.696681),
1398            PolymarketOrderSide::Sell,
1399            3,
1400        );
1401        assert_eq!(maker, dec!(23_690_000));
1402        // 23.69 * 0.211 = 4.99859, trunc(5) = 4.99859
1403        assert_eq!(taker, dec!(4_998_590));
1404        // Verify maker has max 2 decimal precision: 23_690_000 / 1_000_000 = 23.69
1405        assert_eq!(maker % dec!(10_000), dec!(0));
1406        // Verify taker has max 5 decimal precision: 4_998_590 / 1_000_000 = 4.99859
1407        assert_eq!(taker % dec!(10), dec!(0));
1408    }
1409
1410    #[rstest]
1411    fn test_to_fixed_decimal_basic() {
1412        assert_eq!(to_fixed_decimal(dec!(13.29955)), dec!(13_299_550));
1413        assert_eq!(to_fixed_decimal(dec!(100)), dec!(100_000_000));
1414        assert_eq!(to_fixed_decimal(dec!(0)), dec!(0));
1415    }
1416}