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::time::get_atomic_clock_realtime;
33use nautilus_model::{
34    enums::{OrderSide, OrderType, TimeInForce},
35    identifiers::VenueOrderId,
36    orders::{Order, OrderAny},
37};
38use rust_decimal::Decimal;
39use ustr::Ustr;
40
41use crate::{
42    common::{
43        consts::{LOT_SIZE_SCALE, POLYMARKET_NAUTILUS_BUILDER_CODE, USDC_DECIMALS},
44        enums::{PolymarketOrderSide, PolymarketOrderType, SignatureType},
45    },
46    http::models::PolymarketOrder,
47    signing::eip712::{OrderSigner, order_hash},
48};
49
50/// Zero `bytes32` used for the `metadata` field (reserved for future use).
51pub const ZERO_BYTES32: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";
52
53/// Builds signed Polymarket orders for submission to the CLOB V2 exchange.
54///
55/// `last_timestamp_ms` backs a strictly-monotonic millisecond clock so that
56/// bursts of submissions landing in the same wall-clock millisecond still
57/// produce distinct `timestamp` values (the V2 per-address uniqueness field).
58#[derive(Debug)]
59pub struct PolymarketOrderBuilder {
60    order_signer: OrderSigner,
61    signer_address: String,
62    maker_address: String,
63    signature_type: SignatureType,
64    last_timestamp_ms: AtomicU64,
65}
66
67impl PolymarketOrderBuilder {
68    /// Creates a new [`PolymarketOrderBuilder`].
69    pub fn new(
70        order_signer: OrderSigner,
71        signer_address: String,
72        maker_address: String,
73        signature_type: SignatureType,
74    ) -> Self {
75        Self {
76            order_signer,
77            signer_address,
78            maker_address,
79            signature_type,
80            last_timestamp_ms: AtomicU64::new(0),
81        }
82    }
83
84    // Returns a strictly-monotonic millisecond timestamp: the current wall
85    // time in ms, or `last_seen + 1` if that would be larger. Thread-safe.
86    fn next_timestamp_ms(&self) -> u64 {
87        let now_ms = get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000;
88
89        loop {
90            let prev = self.last_timestamp_ms.load(Ordering::Relaxed);
91            let candidate = prev.saturating_add(1).max(now_ms);
92
93            if self
94                .last_timestamp_ms
95                .compare_exchange_weak(prev, candidate, Ordering::Relaxed, Ordering::Relaxed)
96                .is_ok()
97            {
98                return candidate;
99            }
100        }
101    }
102
103    /// Builds and signs a limit order for submission.
104    ///
105    /// `expiration` is a unix-seconds timestamp (`"0"` for non-GTD orders).
106    /// It is carried in the wire body but excluded from the EIP-712 signed hash.
107    #[expect(clippy::too_many_arguments)]
108    pub fn build_limit_order(
109        &self,
110        token_id: &str,
111        side: PolymarketOrderSide,
112        price: Decimal,
113        quantity: Decimal,
114        expiration: &str,
115        neg_risk: bool,
116        tick_decimals: u32,
117    ) -> anyhow::Result<PolymarketOrder> {
118        let (maker_amount, taker_amount) =
119            compute_maker_taker_amounts(price, quantity, side, tick_decimals);
120        self.build_and_sign(
121            token_id,
122            side,
123            maker_amount,
124            taker_amount,
125            expiration,
126            neg_risk,
127        )
128    }
129
130    /// Builds and signs a market order for submission.
131    ///
132    /// `amount` semantics differ by side:
133    /// - BUY: `amount` is pUSD to spend
134    /// - SELL: `amount` is shares to sell
135    ///
136    /// Market orders never set an expiration.
137    pub fn build_market_order(
138        &self,
139        token_id: &str,
140        side: PolymarketOrderSide,
141        price: Decimal,
142        amount: Decimal,
143        neg_risk: bool,
144        tick_decimals: u32,
145    ) -> anyhow::Result<PolymarketOrder> {
146        let (maker_amount, taker_amount) =
147            compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
148        self.build_and_sign(token_id, side, maker_amount, taker_amount, "0", neg_risk)
149    }
150
151    /// Computes the Polymarket order ID for a signed CLOB V2 order.
152    pub fn expected_order_id(
153        &self,
154        order: &PolymarketOrder,
155        neg_risk: bool,
156    ) -> anyhow::Result<VenueOrderId> {
157        let hash = order_hash(order, neg_risk)
158            .map_err(|e| anyhow::anyhow!("Failed to derive order hash: {e}"))?;
159        let order_id = format!("{hash:#x}");
160        Ok(VenueOrderId::from(order_id.as_str()))
161    }
162
163    /// Validates a limit order before building, returning a denial reason if invalid.
164    pub fn validate_limit_order(order: &OrderAny) -> Result<(), String> {
165        if order.is_reduce_only() {
166            return Err("Reduce-only orders not supported on Polymarket".to_string());
167        }
168
169        if order.order_type() != OrderType::Limit {
170            return Err(format!(
171                "Unsupported order type for Polymarket: {:?}",
172                order.order_type()
173            ));
174        }
175
176        if order.is_quote_quantity() {
177            return Err("Quote quantity not supported for limit orders".to_string());
178        }
179
180        if order.price().is_none() {
181            return Err("Limit orders require a price".to_string());
182        }
183
184        if PolymarketOrderType::try_from(order.time_in_force()).is_err() {
185            return Err(format!(
186                "Unsupported time in force: {:?}",
187                order.time_in_force()
188            ));
189        }
190
191        if PolymarketOrderSide::try_from(order.order_side()).is_err() {
192            return Err(format!("Invalid order side: {:?}", order.order_side()));
193        }
194
195        if order.is_post_only()
196            && !matches!(order.time_in_force(), TimeInForce::Gtc | TimeInForce::Gtd)
197        {
198            return Err("Post-only orders require GTC or GTD time in force".to_string());
199        }
200
201        Ok(())
202    }
203
204    /// Validates a market order before building, returning a denial reason if invalid.
205    pub fn validate_market_order(order: &OrderAny) -> Result<(), String> {
206        if order.is_reduce_only() {
207            return Err("Reduce-only orders not supported on Polymarket".to_string());
208        }
209
210        if order.order_type() != OrderType::Market {
211            return Err(format!(
212                "Expected Market order, was {:?}",
213                order.order_type()
214            ));
215        }
216
217        // BUY market orders must use quote_quantity (amount in pUSD)
218        // SELL market orders must NOT use quote_quantity (amount in shares)
219        match order.order_side() {
220            OrderSide::Buy => {
221                if !order.is_quote_quantity() {
222                    return Err(
223                        "Market BUY orders require quote_quantity=true (amount in pUSD)"
224                            .to_string(),
225                    );
226                }
227            }
228            OrderSide::Sell => {
229                if order.is_quote_quantity() {
230                    return Err(
231                        "Market SELL orders require quote_quantity=false (amount in shares)"
232                            .to_string(),
233                    );
234                }
235            }
236            _ => {
237                return Err(format!("Invalid order side: {:?}", order.order_side()));
238            }
239        }
240
241        if PolymarketOrderType::from_market_time_in_force(order.time_in_force()).is_err() {
242            return Err(format!(
243                "Unsupported time in force for Polymarket market order: {:?}; use IOC or FOK",
244                order.time_in_force()
245            ));
246        }
247
248        Ok(())
249    }
250
251    fn build_and_sign(
252        &self,
253        token_id: &str,
254        side: PolymarketOrderSide,
255        maker_amount: Decimal,
256        taker_amount: Decimal,
257        expiration: &str,
258        neg_risk: bool,
259    ) -> anyhow::Result<PolymarketOrder> {
260        let salt = generate_salt();
261        let timestamp_ms = self.next_timestamp_ms();
262
263        let mut poly_order = PolymarketOrder {
264            salt,
265            maker: self.maker_address.clone(),
266            signer: self.order_signer_address(),
267            token_id: Ustr::from(token_id),
268            maker_amount,
269            taker_amount,
270            side,
271            signature_type: self.signature_type,
272            expiration: expiration.to_string(),
273            timestamp: timestamp_ms.to_string(),
274            metadata: ZERO_BYTES32.to_string(),
275            builder: POLYMARKET_NAUTILUS_BUILDER_CODE.to_string(),
276            signature: String::new(),
277        };
278
279        let signature = self
280            .order_signer
281            .sign_order(&poly_order, neg_risk)
282            .map_err(|e| anyhow::anyhow!("EIP-712 signing failed: {e}"))?;
283        poly_order.signature = signature;
284
285        Ok(poly_order)
286    }
287
288    fn order_signer_address(&self) -> String {
289        match self.signature_type {
290            SignatureType::Poly1271 => self.maker_address.clone(),
291            _ => self.signer_address.clone(),
292        }
293    }
294}
295
296fn to_fixed_decimal(d: Decimal) -> Decimal {
297    let mantissa = d.normalize().trunc_with_scale(USDC_DECIMALS).mantissa();
298    Decimal::from(mantissa)
299}
300
301/// Builds the maker/taker amounts for a Polymarket CLOB limit order.
302///
303/// The CLOB enforces precision constraints on both amounts:
304/// - Direct amounts (quantity passed through): max `LOT_SIZE_SCALE` (2) decimal places
305/// - Computed amounts (quantity * price): max `tick_decimals + LOT_SIZE_SCALE` decimal places
306///
307/// For BUY: paying pUSD (maker, computed) to receive CTF shares (taker, direct)
308/// For SELL: paying CTF shares (maker, direct) to receive pUSD (taker, computed)
309pub fn compute_maker_taker_amounts(
310    price: Decimal,
311    quantity: Decimal,
312    side: PolymarketOrderSide,
313    tick_decimals: u32,
314) -> (Decimal, Decimal) {
315    let precision = tick_decimals + LOT_SIZE_SCALE;
316    let qty = quantity.trunc_with_scale(LOT_SIZE_SCALE);
317
318    match side {
319        PolymarketOrderSide::Buy => {
320            let maker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
321            let taker_amount = to_fixed_decimal(qty);
322            (maker_amount, taker_amount)
323        }
324        PolymarketOrderSide::Sell => {
325            let maker_amount = to_fixed_decimal(qty);
326            let taker_amount = to_fixed_decimal((qty * price).trunc_with_scale(precision));
327            (maker_amount, taker_amount)
328        }
329    }
330}
331
332/// Builds maker/taker amounts for a Polymarket market order.
333///
334/// Same precision constraints as limit orders. The direct amount is truncated to
335/// `LOT_SIZE_SCALE` decimal places before conversion (market order amounts like
336/// position sizes from fills may have more decimal places than the CLOB allows).
337///
338/// Unlike limit orders where quantity always means shares, market order semantics differ by side:
339/// - BUY: `amount` is pUSD to spend, compute shares received
340/// - SELL: `amount` is shares to sell, compute pUSD received
341pub fn compute_market_maker_taker_amounts(
342    price: Decimal,
343    amount: Decimal,
344    side: PolymarketOrderSide,
345    tick_decimals: u32,
346) -> (Decimal, Decimal) {
347    let precision = tick_decimals + LOT_SIZE_SCALE;
348    let amt = amount.trunc_with_scale(LOT_SIZE_SCALE);
349
350    match side {
351        PolymarketOrderSide::Buy => {
352            let maker_amount = to_fixed_decimal(amt);
353            let taker_amount = to_fixed_decimal((amt / price).trunc_with_scale(precision));
354            (maker_amount, taker_amount)
355        }
356        PolymarketOrderSide::Sell => {
357            let maker_amount = to_fixed_decimal(amt);
358            let taker_amount = to_fixed_decimal((amt * price).trunc_with_scale(precision));
359            (maker_amount, taker_amount)
360        }
361    }
362}
363
364/// Generates a random salt for order construction.
365///
366/// # Panics
367///
368/// Cannot panic: UUID v4 always produces 16 bytes, so the 8-byte slice conversion is infallible.
369pub fn generate_salt() -> u64 {
370    let bytes = uuid::Uuid::new_v4().into_bytes();
371    u64::from_le_bytes(bytes[..8].try_into().unwrap()) & ((1u64 << 53) - 1)
372}
373
374#[cfg(test)]
375mod tests {
376    use nautilus_core::{UUID4, UnixNanos};
377    use nautilus_model::{
378        enums::{OrderSide, TimeInForce},
379        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
380        orders::{LimitOrder, MarketOrder, OrderAny},
381        types::{Price, Quantity},
382    };
383    use rstest::rstest;
384    use rust_decimal::Decimal;
385    use rust_decimal_macros::dec;
386
387    use super::*;
388    use crate::{
389        common::{credential::EvmPrivateKey, enums::PolymarketOrderSide},
390        signing::eip712::OrderSigner,
391    };
392
393    fn make_limit(
394        reduce_only: bool,
395        quote_quantity: bool,
396        post_only: bool,
397        tif: TimeInForce,
398    ) -> OrderAny {
399        let expire_time = if tif == TimeInForce::Gtd {
400            Some(UnixNanos::from(2_000_000_000_000_000_000u64))
401        } else {
402            None
403        };
404        OrderAny::Limit(LimitOrder::new(
405            TraderId::from("TESTER-001"),
406            StrategyId::from("S-001"),
407            InstrumentId::from("TEST.POLYMARKET"),
408            ClientOrderId::from("O-001"),
409            OrderSide::Buy,
410            Quantity::from("10"),
411            Price::from("0.50"),
412            tif,
413            expire_time,
414            post_only,
415            reduce_only,
416            quote_quantity,
417            None,
418            None,
419            None,
420            None,
421            None,
422            None,
423            None,
424            None,
425            None,
426            None,
427            None,
428            UUID4::new(),
429            UnixNanos::default(),
430        ))
431    }
432
433    fn make_market(side: OrderSide, quote_quantity: bool) -> OrderAny {
434        OrderAny::Market(MarketOrder::new(
435            TraderId::from("TESTER-001"),
436            StrategyId::from("S-001"),
437            InstrumentId::from("TEST.POLYMARKET"),
438            ClientOrderId::from("O-001"),
439            side,
440            Quantity::from("10"),
441            TimeInForce::Ioc,
442            UUID4::new(),
443            UnixNanos::default(),
444            false,
445            quote_quantity,
446            None,
447            None,
448            None,
449            None,
450            None,
451            None,
452            None,
453            None,
454        ))
455    }
456
457    #[rstest]
458    fn test_validate_limit_order_valid() {
459        let order = make_limit(false, false, false, TimeInForce::Gtc);
460        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
461    }
462
463    #[rstest]
464    fn test_validate_limit_order_reduce_only_denied() {
465        let order = make_limit(true, false, false, TimeInForce::Gtc);
466        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
467        assert!(err.contains("Reduce-only"));
468    }
469
470    #[rstest]
471    fn test_validate_limit_order_quote_quantity_denied() {
472        let order = make_limit(false, true, false, TimeInForce::Gtc);
473        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
474        assert!(err.contains("Quote quantity"));
475    }
476
477    #[rstest]
478    fn test_validate_limit_order_post_only_ioc_denied() {
479        let order = make_limit(false, false, true, TimeInForce::Ioc);
480        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
481        assert!(err.contains("Post-only"));
482    }
483
484    #[rstest]
485    fn test_validate_limit_order_post_only_gtc_allowed() {
486        let order = make_limit(false, false, true, TimeInForce::Gtc);
487        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
488    }
489
490    #[rstest]
491    fn test_validate_limit_order_no_order_side_denied() {
492        let order = OrderAny::Limit(LimitOrder::new(
493            TraderId::from("TESTER-001"),
494            StrategyId::from("S-001"),
495            InstrumentId::from("TEST.POLYMARKET"),
496            ClientOrderId::from("O-NO-SIDE"),
497            OrderSide::NoOrderSide,
498            Quantity::from("10"),
499            Price::from("0.50"),
500            TimeInForce::Gtc,
501            None,
502            false,
503            false,
504            false,
505            None,
506            None,
507            None,
508            None,
509            None,
510            None,
511            None,
512            None,
513            None,
514            None,
515            None,
516            UUID4::new(),
517            UnixNanos::default(),
518        ));
519        let err = PolymarketOrderBuilder::validate_limit_order(&order).unwrap_err();
520        assert!(err.contains("Invalid order side"));
521    }
522
523    #[rstest]
524    fn test_validate_market_order_buy_with_quote_qty() {
525        let order = make_market(OrderSide::Buy, true);
526        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
527    }
528
529    #[rstest]
530    fn test_validate_market_order_buy_without_quote_qty_denied() {
531        let order = make_market(OrderSide::Buy, false);
532        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
533        assert!(err.contains("quote_quantity=true"));
534    }
535
536    #[rstest]
537    fn test_validate_market_order_sell_without_quote_qty() {
538        let order = make_market(OrderSide::Sell, false);
539        assert!(PolymarketOrderBuilder::validate_market_order(&order).is_ok());
540    }
541
542    #[rstest]
543    fn test_validate_market_order_sell_with_quote_qty_denied() {
544        let order = make_market(OrderSide::Sell, true);
545        let err = PolymarketOrderBuilder::validate_market_order(&order).unwrap_err();
546        assert!(err.contains("quote_quantity=false"));
547    }
548
549    #[rstest]
550    fn test_validate_market_order_wrong_type_denied() {
551        // Passing a limit order to validate_market_order should fail with type mismatch
552        let limit = make_limit(false, false, false, TimeInForce::Gtc);
553        let err = PolymarketOrderBuilder::validate_market_order(&limit).unwrap_err();
554        assert!(err.contains("Expected Market order"));
555    }
556
557    fn make_test_builder() -> PolymarketOrderBuilder {
558        let pk = EvmPrivateKey::new(
559            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
560        )
561        .unwrap();
562        let signer = OrderSigner::new(&pk).unwrap();
563        let addr = format!("{:#x}", signer.address());
564        PolymarketOrderBuilder::new(signer, addr.clone(), addr, SignatureType::Eoa)
565    }
566
567    #[rstest]
568    fn test_next_timestamp_ms_is_strictly_monotonic() {
569        let builder = make_test_builder();
570        let mut prev = builder.next_timestamp_ms();
571        for _ in 0..1_000 {
572            let next = builder.next_timestamp_ms();
573            assert!(
574                next > prev,
575                "timestamp not strictly monotonic: {prev} >= {next}"
576            );
577            prev = next;
578        }
579    }
580
581    #[rstest]
582    fn test_build_orders_produce_unique_timestamps() {
583        let builder = make_test_builder();
584        let mut timestamps = ahash::AHashSet::new();
585
586        for _ in 0..50 {
587            let order = builder
588                .build_limit_order(
589                    "71321045679252212594626385532706912750332728571942532289631379312455583992563",
590                    PolymarketOrderSide::Buy,
591                    dec!(0.50),
592                    dec!(10),
593                    "0",
594                    false,
595                    2,
596                )
597                .unwrap();
598            assert!(
599                timestamps.insert(order.timestamp.clone()),
600                "duplicate timestamp {} in burst",
601                order.timestamp,
602            );
603        }
604    }
605
606    #[rstest]
607    fn test_built_order_carries_nautilus_builder_code() {
608        let builder = make_test_builder();
609        let order = builder
610            .build_limit_order(
611                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
612                PolymarketOrderSide::Buy,
613                dec!(0.50),
614                dec!(10),
615                "0",
616                false,
617                2,
618            )
619            .unwrap();
620        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
621    }
622
623    #[rstest]
624    fn test_poly_1271_order_uses_deposit_wallet_as_signer() {
625        let pk = EvmPrivateKey::new(
626            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
627        )
628        .unwrap();
629        let signer = OrderSigner::new(&pk).unwrap();
630        let signer_address = format!("{:#x}", signer.address());
631        let deposit_wallet = "0x1111111111111111111111111111111111111111".to_string();
632        let builder = PolymarketOrderBuilder::new(
633            signer,
634            signer_address,
635            deposit_wallet.clone(),
636            SignatureType::Poly1271,
637        );
638
639        let order = builder
640            .build_limit_order(
641                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
642                PolymarketOrderSide::Buy,
643                dec!(0.50),
644                dec!(10),
645                "0",
646                false,
647                2,
648            )
649            .unwrap();
650
651        assert_eq!(order.maker, deposit_wallet);
652        assert_eq!(order.signer, deposit_wallet);
653        assert_eq!(order.signature_type, SignatureType::Poly1271);
654        assert_eq!(order.signature.len(), 636);
655    }
656
657    #[rstest]
658    fn test_build_limit_order_expiration_passthrough() {
659        let builder = make_test_builder();
660        let order = builder
661            .build_limit_order(
662                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
663                PolymarketOrderSide::Buy,
664                dec!(0.50),
665                dec!(10),
666                "1735689600",
667                false,
668                2,
669            )
670            .unwrap();
671        assert_eq!(order.expiration, "1735689600");
672    }
673
674    #[rstest]
675    fn test_validate_limit_order_gtd_with_expire_accepted() {
676        // LimitOrder::new enforces GTD + expire_time upstream, so validate_limit_order
677        // only needs to accept the valid case and let the lower layer reject mismatched
678        // orders. Locking this behavior prevents a future regression where our
679        // validator rejects GTD outright (which would break V2 GTD flows, as the V2
680        // wire body carries expiration unsigned).
681        let order = make_limit(false, false, false, TimeInForce::Gtd);
682        assert!(PolymarketOrderBuilder::validate_limit_order(&order).is_ok());
683    }
684
685    #[rstest]
686    fn test_build_market_buy_order_wire_shape() {
687        // Market BUY: `amount` is quote pUSD, so maker_amount = quote spend
688        // and taker_amount = computed shares.
689        let builder = make_test_builder();
690        let order = builder
691            .build_market_order(
692                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
693                PolymarketOrderSide::Buy,
694                dec!(0.50),
695                dec!(10),
696                false,
697                2,
698            )
699            .unwrap();
700
701        assert_eq!(order.expiration, "0");
702        assert_eq!(order.builder, POLYMARKET_NAUTILUS_BUILDER_CODE);
703        assert_eq!(order.metadata, ZERO_BYTES32);
704        assert_eq!(order.side, PolymarketOrderSide::Buy);
705        assert!(!order.timestamp.is_empty());
706        assert!(!order.signature.is_empty());
707        // Wire amounts are micro-pUSD / micro-share mantissas (10^6 scale).
708        // Quote-denominated BUY: 10 pUSD spend -> 20 shares at price 0.50.
709        assert_eq!(order.maker_amount, dec!(10_000_000));
710        assert_eq!(order.taker_amount, dec!(20_000_000));
711    }
712
713    #[rstest]
714    fn test_build_market_sell_order_wire_shape() {
715        // Market SELL: `amount` is shares, so maker_amount = shares
716        // and taker_amount = computed pUSD proceeds.
717        let builder = make_test_builder();
718        let order = builder
719            .build_market_order(
720                "71321045679252212594626385532706912750332728571942532289631379312455583992563",
721                PolymarketOrderSide::Sell,
722                dec!(0.50),
723                dec!(20),
724                false,
725                2,
726            )
727            .unwrap();
728
729        assert_eq!(order.expiration, "0");
730        assert_eq!(order.side, PolymarketOrderSide::Sell);
731        assert_eq!(order.maker_amount, dec!(20_000_000));
732        assert_eq!(order.taker_amount, dec!(10_000_000));
733        assert!(!order.signature.is_empty());
734    }
735
736    #[rstest]
737    fn test_generate_salt_uniqueness() {
738        let s1 = generate_salt();
739        let s2 = generate_salt();
740        assert_ne!(s1, s2);
741    }
742
743    #[rstest]
744    fn test_generate_salt_within_53_bits() {
745        for _ in 0..100 {
746            let s = generate_salt();
747            assert!(s < (1u64 << 53));
748        }
749    }
750
751    // Limit order amount tests
752    // qty truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
753    #[rstest]
754    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
755    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
756    #[case(dec!(0.75), dec!(200), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
757    // qty=23.456 → trunc(2)=23.45, maker=(23.45*0.567).trunc(5)=13.29615→13_296_150
758    #[case(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3, dec!(13_296_150), dec!(23_450_000))]
759    #[case(dec!(0.55), dec!(10), PolymarketOrderSide::Buy, 1, dec!(5_500_000), dec!(10_000_000))]
760    fn test_compute_maker_taker_amounts(
761        #[case] price: Decimal,
762        #[case] quantity: Decimal,
763        #[case] side: PolymarketOrderSide,
764        #[case] tick_decimals: u32,
765        #[case] expected_maker: Decimal,
766        #[case] expected_taker: Decimal,
767    ) {
768        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
769        assert_eq!(maker, expected_maker);
770        assert_eq!(taker, expected_taker);
771    }
772
773    // SDK parity vectors lifted from `polymarket-rs-clob-client-v2`'s
774    // `tests/order.rs::lifecycle::limit::{buy,sell}::should_succeed_*`. They
775    // pin (price, size, tick_size) to specific signed maker/taker amounts;
776    // any drift in our truncation or scale logic is caught against the
777    // reference SDK directly. Covers all four documented tick sizes on both
778    // sides plus a handful of decimal-accuracy edge cases.
779    #[rstest]
780    // tick=0.1 (decimals=1)
781    #[case::buy_tick_tenth(
782        dec!(0.5), dec!(21.04), PolymarketOrderSide::Buy, 1,
783        dec!(10_520_000), dec!(21_040_000),
784    )]
785    #[case::sell_tick_tenth(
786        dec!(0.5), dec!(21.04), PolymarketOrderSide::Sell, 1,
787        dec!(21_040_000), dec!(10_520_000),
788    )]
789    // tick=0.01 (decimals=2)
790    #[case::buy_tick_hundredth(
791        dec!(0.56), dec!(21.04), PolymarketOrderSide::Buy, 2,
792        dec!(11_782_400), dec!(21_040_000),
793    )]
794    #[case::sell_tick_hundredth(
795        dec!(0.56), dec!(21.04), PolymarketOrderSide::Sell, 2,
796        dec!(21_040_000), dec!(11_782_400),
797    )]
798    #[case::buy_decimal_accuracy_24(
799        dec!(0.24), dec!(15), PolymarketOrderSide::Buy, 2,
800        dec!(3_600_000), dec!(15_000_000),
801    )]
802    #[case::buy_decimal_accuracy_82(
803        dec!(0.82), dec!(101), PolymarketOrderSide::Buy, 2,
804        dec!(82_820_000), dec!(101_000_000),
805    )]
806    #[case::buy_decimal_accuracy_18233(
807        dec!(0.58), dec!(18233.33), PolymarketOrderSide::Buy, 2,
808        dec!(10_575_331_400), dec!(18_233_330_000),
809    )]
810    // tick=0.001 (decimals=3)
811    #[case::buy_tick_thousandth(
812        dec!(0.056), dec!(21.04), PolymarketOrderSide::Buy, 3,
813        dec!(1_178_240), dec!(21_040_000),
814    )]
815    #[case::sell_tick_thousandth(
816        dec!(0.056), dec!(21.04), PolymarketOrderSide::Sell, 3,
817        dec!(21_040_000), dec!(1_178_240),
818    )]
819    // tick=0.0001 (decimals=4)
820    #[case::buy_tick_ten_thousandth(
821        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Buy, 4,
822        dec!(117_824), dec!(21_040_000),
823    )]
824    #[case::sell_tick_ten_thousandth(
825        dec!(0.0056), dec!(21.04), PolymarketOrderSide::Sell, 4,
826        dec!(21_040_000), dec!(117_824),
827    )]
828    fn test_compute_maker_taker_amounts_sdk_parity(
829        #[case] price: Decimal,
830        #[case] quantity: Decimal,
831        #[case] side: PolymarketOrderSide,
832        #[case] tick_decimals: u32,
833        #[case] expected_maker: Decimal,
834        #[case] expected_taker: Decimal,
835    ) {
836        let (maker, taker) = compute_maker_taker_amounts(price, quantity, side, tick_decimals);
837        assert_eq!(maker, expected_maker);
838        assert_eq!(taker, expected_taker);
839    }
840
841    // Market order amount tests
842    // amount truncated to LOT_SIZE_SCALE=2 first, then computed side truncated to precision
843    #[rstest]
844    #[case(dec!(0.50), dec!(50), PolymarketOrderSide::Buy, 2, dec!(50_000_000), dec!(100_000_000))]
845    #[case(dec!(0.50), dec!(100), PolymarketOrderSide::Sell, 2, dec!(100_000_000), dec!(50_000_000))]
846    #[case(dec!(0.75), dec!(150), PolymarketOrderSide::Buy, 2, dec!(150_000_000), dec!(200_000_000))]
847    // amt=23.696681 → trunc(2)=23.69, taker=(23.69*0.211).trunc(5)=4.99859→4_998_590
848    #[case(dec!(0.211), dec!(23.696681), PolymarketOrderSide::Sell, 3, dec!(23_690_000), dec!(4_998_590))]
849    fn test_compute_market_maker_taker_amounts(
850        #[case] price: Decimal,
851        #[case] amount: Decimal,
852        #[case] side: PolymarketOrderSide,
853        #[case] tick_decimals: u32,
854        #[case] expected_maker: Decimal,
855        #[case] expected_taker: Decimal,
856    ) {
857        let (maker, taker) = compute_market_maker_taker_amounts(price, amount, side, tick_decimals);
858        assert_eq!(maker, expected_maker);
859        assert_eq!(taker, expected_taker);
860    }
861
862    #[rstest]
863    fn test_compute_maker_taker_qty_truncated_to_lot_size() {
864        // qty=23.456 → trunc(2)=23.45, tick_decimals=3 → precision=5
865        // BUY: maker=(23.45*0.567).trunc(5)=13.29615→13_296_150, taker=23.45→23_450_000
866        let (maker, taker) =
867            compute_maker_taker_amounts(dec!(0.567), dec!(23.456), PolymarketOrderSide::Buy, 3);
868        assert_eq!(maker, dec!(13_296_150));
869        assert_eq!(taker, dec!(23_450_000));
870    }
871
872    #[rstest]
873    fn test_compute_maker_taker_zero_amounts() {
874        let (maker, taker) =
875            compute_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
876        assert_eq!(maker, dec!(0));
877        assert_eq!(taker, dec!(0));
878    }
879
880    #[rstest]
881    fn test_compute_maker_taker_tick_decimals_1() {
882        let (maker, taker) =
883            compute_maker_taker_amounts(dec!(0.3), dec!(50), PolymarketOrderSide::Buy, 1);
884        assert_eq!(maker, dec!(15_000_000));
885        assert_eq!(taker, dec!(50_000_000));
886    }
887
888    #[rstest]
889    fn test_compute_maker_taker_tick_decimals_3_sell() {
890        // qty=15.123 → trunc(2)=15.12
891        // SELL: maker=15.12→15_120_000, taker=(15.12*0.789).trunc(5)=11.92968→11_929_680
892        let (maker, taker) =
893            compute_maker_taker_amounts(dec!(0.789), dec!(15.123), PolymarketOrderSide::Sell, 3);
894        assert_eq!(maker, dec!(15_120_000));
895        assert_eq!(taker, dec!(11_929_680));
896    }
897
898    #[rstest]
899    fn test_compute_market_maker_taker_zero_amount() {
900        let (maker, taker) =
901            compute_market_maker_taker_amounts(dec!(0.50), dec!(0), PolymarketOrderSide::Buy, 2);
902        assert_eq!(maker, dec!(0));
903        assert_eq!(taker, dec!(0));
904    }
905
906    #[rstest]
907    fn test_compute_market_sell_position_close() {
908        // Reproduces the live bug: position=23.696681 shares, price=0.211, tick_decimals=3
909        // Without truncation: maker=23_696_681 (6 decimals) → rejected by CLOB
910        // With truncation: amt=23.69 → maker=23_690_000 (2 decimals) → accepted
911        let (maker, taker) = compute_market_maker_taker_amounts(
912            dec!(0.211),
913            dec!(23.696681),
914            PolymarketOrderSide::Sell,
915            3,
916        );
917        assert_eq!(maker, dec!(23_690_000));
918        // 23.69 * 0.211 = 4.99859, trunc(5) = 4.99859
919        assert_eq!(taker, dec!(4_998_590));
920        // Verify maker has max 2 decimal precision: 23_690_000 / 1_000_000 = 23.69
921        assert_eq!(maker % dec!(10_000), dec!(0));
922        // Verify taker has max 5 decimal precision: 4_998_590 / 1_000_000 = 4.99859
923        assert_eq!(taker % dec!(10), dec!(0));
924    }
925
926    #[rstest]
927    fn test_to_fixed_decimal_basic() {
928        assert_eq!(to_fixed_decimal(dec!(13.29955)), dec!(13_299_550));
929        assert_eq!(to_fixed_decimal(dec!(100)), dec!(100_000_000));
930        assert_eq!(to_fixed_decimal(dec!(0)), dec!(0));
931    }
932}