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