Skip to main content

nautilus_polymarket/execution/
parse.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//! Parsing functions for Polymarket execution reports.
17
18use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::{
21    UUID4, UnixNanos,
22    datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
23};
24use nautilus_model::{
25    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
26    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
27    instruments::InstrumentAny,
28    reports::{FillReport, OrderStatusReport},
29    types::{AccountBalance, Currency, Money, Price, Quantity},
30};
31use rust_decimal::Decimal;
32
33use crate::{
34    common::{
35        consts::{DUST_SNAP_THRESHOLD_DEC, USDC_DECIMALS},
36        enums::{
37            PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
38            PolymarketOrderStatus,
39        },
40        models::PolymarketMakerOrder,
41    },
42    http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
43};
44
45/// Converts a [`PolymarketLiquiditySide`] to a Nautilus [`LiquiditySide`].
46pub const fn parse_liquidity_side(side: PolymarketLiquiditySide) -> LiquiditySide {
47    match side {
48        PolymarketLiquiditySide::Maker => LiquiditySide::Maker,
49        PolymarketLiquiditySide::Taker => LiquiditySide::Taker,
50    }
51}
52
53/// Resolves the Nautilus order status from Polymarket status and event type.
54///
55/// Venue-initiated cancellations arrive as `status=Invalid, event_type=Cancellation`
56/// (e.g. sport market resolution). These map to `Canceled`, not `Rejected`.
57pub fn resolve_order_status(
58    status: PolymarketOrderStatus,
59    event_type: PolymarketEventType,
60) -> OrderStatus {
61    if status == PolymarketOrderStatus::Invalid && event_type == PolymarketEventType::Cancellation {
62        OrderStatus::Canceled
63    } else {
64        OrderStatus::from(status)
65    }
66}
67
68/// Determines the order side for a fill based on trader role and asset matching.
69///
70/// Polymarket uses a unified order book where complementary tokens (YES/NO) can match
71/// across assets. A BUY YES can match with a BUY NO (cross-asset), not just SELL YES
72/// (same-asset). For takers, the trade side is used directly. For makers, the side
73/// depends on whether the match is cross-asset or same-asset.
74pub fn determine_order_side(
75    trader_side: PolymarketLiquiditySide,
76    trade_side: PolymarketOrderSide,
77    taker_asset_id: &str,
78    maker_asset_id: &str,
79) -> OrderSide {
80    let order_side = OrderSide::from(trade_side);
81
82    if trader_side == PolymarketLiquiditySide::Taker {
83        return order_side;
84    }
85
86    let is_cross_asset = maker_asset_id != taker_asset_id;
87
88    if is_cross_asset {
89        order_side
90    } else {
91        match order_side {
92            OrderSide::Buy => OrderSide::Sell,
93            OrderSide::Sell => OrderSide::Buy,
94        }
95    }
96}
97
98/// Creates a composite trade ID bounded to 36 characters.
99///
100/// When multiple orders are filled by a single market order, Polymarket sends one
101/// trade message with a single ID for all fills. This creates a unique trade ID
102/// per fill by combining the trade ID with part of the venue order ID.
103///
104/// Format: `{trade_id[..27]}-{venue_order_id[last 8]}` = 36 chars.
105pub fn make_composite_trade_id(trade_id: &str, venue_order_id: &str) -> TradeId {
106    TradeId::from(composite_trade_id_value(trade_id, venue_order_id).as_str())
107}
108
109pub(super) fn composite_trade_id_value(trade_id: &str, venue_order_id: &str) -> String {
110    let prefix_len = trade_id.len().min(27);
111    let suffix_len = venue_order_id.len().min(8);
112    let suffix_start = venue_order_id.len().saturating_sub(suffix_len);
113    format!(
114        "{}-{}",
115        &trade_id[..prefix_len],
116        &venue_order_id[suffix_start..]
117    )
118}
119
120/// Parses a [`PolymarketOpenOrder`] into an [`OrderStatusReport`].
121pub fn parse_order_status_report(
122    order: &PolymarketOpenOrder,
123    instrument_id: InstrumentId,
124    account_id: AccountId,
125    client_order_id: Option<ClientOrderId>,
126    price_precision: u8,
127    size_precision: u8,
128    ts_init: UnixNanos,
129) -> OrderStatusReport {
130    let expire_time = order
131        .expiration
132        .as_deref()
133        .and_then(parse_expiration_nanos)
134        .map(UnixNanos::from);
135    parse_validated_order_status_report(
136        order,
137        OrderReportParseContext {
138            instrument_id,
139            account_id,
140            client_order_id,
141            venue_order_id: VenueOrderId::from(order.id.as_str()),
142            price_precision,
143            size_precision,
144            ts_accepted: UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND),
145            expire_time,
146            ts_init,
147        },
148    )
149}
150
151#[derive(Clone, Copy, Debug)]
152pub(super) struct OrderReportParseContext {
153    pub instrument_id: InstrumentId,
154    pub account_id: AccountId,
155    pub client_order_id: Option<ClientOrderId>,
156    pub venue_order_id: VenueOrderId,
157    pub price_precision: u8,
158    pub size_precision: u8,
159    pub ts_accepted: UnixNanos,
160    pub expire_time: Option<UnixNanos>,
161    pub ts_init: UnixNanos,
162}
163
164pub(super) fn parse_validated_order_status_report(
165    order: &PolymarketOpenOrder,
166    ctx: OrderReportParseContext,
167) -> OrderStatusReport {
168    let order_side = OrderSide::from(order.side);
169    let time_in_force = TimeInForce::from(order.order_type);
170    let quantity = Quantity::from_decimal_dp(order.original_size, ctx.size_precision)
171        .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
172    let raw_filled_qty = Quantity::from_decimal_dp(order.size_matched, ctx.size_precision)
173        .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
174    // `Matched` does not mean fully filled, so resolve the status from the filled quantity.
175    let order_status = if order.status == PolymarketOrderStatus::Matched {
176        recovered_terminal_order_status(time_in_force, quantity, raw_filled_qty)
177    } else {
178        OrderStatus::from(order.status)
179    };
180    let filled_qty = snap_filled_qty_to_quantity(quantity, raw_filled_qty, order_status);
181    let price = Price::from_decimal_dp(order.price, ctx.price_precision)
182        .unwrap_or_else(|_| Price::zero(ctx.price_precision));
183
184    let mut report = OrderStatusReport::new(
185        ctx.account_id,
186        ctx.instrument_id,
187        ctx.client_order_id,
188        ctx.venue_order_id,
189        order_side.into(),
190        OrderType::Limit,
191        time_in_force,
192        order_status,
193        quantity,
194        filled_qty,
195        ctx.ts_accepted,
196        ctx.ts_accepted, // ts_last
197        ctx.ts_init,
198        None, // report_id
199    );
200    report.price = Some(price);
201    report.expire_time = ctx.expire_time;
202    report
203}
204
205/// Parses a CLOB V2 `expiration` string into a Unix-nanos value. Returns
206/// `None` for `"0"`, missing values, unparsable input, or values that
207/// overflow `u64` when scaled to nanoseconds (e.g. accidentally-passed
208/// millisecond timestamps that exceed Unix-seconds bounds).
209pub(super) fn parse_expiration_nanos(value: &str) -> Option<u64> {
210    let secs: u64 = value.parse().ok()?;
211    if secs == 0 {
212        return None;
213    }
214    secs.checked_mul(NANOSECONDS_IN_SECOND)
215}
216
217// panics-doc-ok (transitive via validating identifier constructors)
218/// Parses a [`PolymarketTradeReport`] into a [`FillReport`].
219///
220/// Produces one fill report for the overall trade. The `trade_id` is
221/// derived from the Polymarket trade ID. Commission is computed from the
222/// instrument's effective taker fee rate, fee exponent, and fill notional.
223///
224/// # Errors
225///
226/// Returns an error if the computed commission cannot be represented as [`Money`].
227///
228/// # Panics
229///
230/// Panics if the trade identifiers are invalid.
231#[expect(clippy::too_many_arguments)]
232pub fn parse_fill_report(
233    trade: &PolymarketTradeReport,
234    instrument_id: InstrumentId,
235    account_id: AccountId,
236    client_order_id: Option<ClientOrderId>,
237    price_precision: u8,
238    size_precision: u8,
239    currency: Currency,
240    taker_fee_rate: Decimal,
241    fee_exponent: f64,
242    ts_init: UnixNanos,
243) -> anyhow::Result<FillReport> {
244    parse_validated_fill_report(
245        trade,
246        TakerFillParseContext {
247            instrument_id,
248            account_id,
249            client_order_id,
250            venue_order_id: VenueOrderId::from(trade.taker_order_id.as_str()),
251            trade_id: TradeId::from(trade.id.as_str()),
252            price_precision,
253            size_precision,
254            currency,
255            taker_fee_rate,
256            fee_exponent,
257            ts_event: parse_timestamp(&trade.match_time).unwrap_or(ts_init),
258            ts_init,
259        },
260    )
261}
262
263#[derive(Clone, Copy, Debug)]
264pub(super) struct TakerFillParseContext {
265    pub instrument_id: InstrumentId,
266    pub account_id: AccountId,
267    pub client_order_id: Option<ClientOrderId>,
268    pub venue_order_id: VenueOrderId,
269    pub trade_id: TradeId,
270    pub price_precision: u8,
271    pub size_precision: u8,
272    pub currency: Currency,
273    pub taker_fee_rate: Decimal,
274    pub fee_exponent: f64,
275    pub ts_event: UnixNanos,
276    pub ts_init: UnixNanos,
277}
278
279pub(super) fn parse_validated_fill_report(
280    trade: &PolymarketTradeReport,
281    ctx: TakerFillParseContext,
282) -> anyhow::Result<FillReport> {
283    let order_side = OrderSide::from(trade.side);
284    let last_qty = Quantity::from_decimal_dp(trade.size, ctx.size_precision)
285        .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
286    let last_px = Price::from_decimal_dp(trade.price, ctx.price_precision)
287        .unwrap_or_else(|_| Price::zero(ctx.price_precision));
288    let liquidity_side = parse_liquidity_side(trade.trader_side);
289
290    let commission_value = compute_commission(
291        ctx.taker_fee_rate,
292        ctx.fee_exponent,
293        trade.size,
294        trade.price,
295        liquidity_side,
296    );
297    let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
298        format!(
299            "failed to represent commission {commission_value} for {} as Money",
300            ctx.instrument_id
301        )
302    })?;
303
304    Ok(FillReport {
305        account_id: ctx.account_id,
306        instrument_id: ctx.instrument_id,
307        venue_order_id: ctx.venue_order_id,
308        trade_id: ctx.trade_id,
309        order_side,
310        last_qty,
311        last_px,
312        commission,
313        liquidity_side,
314        avg_px: None,
315        report_id: UUID4::new(),
316        ts_event: ctx.ts_event,
317        ts_init: ctx.ts_init,
318        client_order_id: ctx.client_order_id,
319        venue_position_id: None,
320    })
321}
322
323// panics-doc-ok (transitive via validating identifier constructors)
324/// Builds a [`FillReport`] from a [`PolymarketMakerOrder`] and trade-level context.
325///
326/// Used by both the WS stream handler and REST fill report generation since both
327/// share the same [`PolymarketMakerOrder`] type for maker fills. Maker fills never
328/// pay commission per Polymarket's fee rules.
329///
330/// # Errors
331///
332/// Returns an error if the computed commission cannot be represented as [`Money`].
333///
334/// # Panics
335///
336/// Panics if the maker order or generated trade identifier is invalid.
337#[expect(clippy::too_many_arguments)]
338pub fn build_maker_fill_report(
339    mo: &PolymarketMakerOrder,
340    trade_id: &str,
341    trader_side: PolymarketLiquiditySide,
342    trade_side: PolymarketOrderSide,
343    taker_asset_id: &str,
344    account_id: AccountId,
345    instrument_id: InstrumentId,
346    price_precision: u8,
347    size_precision: u8,
348    currency: Currency,
349    liquidity_side: LiquiditySide,
350    ts_event: UnixNanos,
351    ts_init: UnixNanos,
352) -> anyhow::Result<FillReport> {
353    parse_validated_maker_fill_report(
354        mo,
355        trader_side,
356        trade_side,
357        taker_asset_id,
358        MakerFillParseContext {
359            account_id,
360            instrument_id,
361            venue_order_id: VenueOrderId::from(mo.order_id.as_str()),
362            trade_id: make_composite_trade_id(trade_id, &mo.order_id),
363            price_precision,
364            size_precision,
365            currency,
366            liquidity_side,
367            ts_event,
368            ts_init,
369        },
370    )
371}
372
373#[derive(Clone, Copy, Debug)]
374pub(super) struct MakerFillParseContext {
375    pub account_id: AccountId,
376    pub instrument_id: InstrumentId,
377    pub venue_order_id: VenueOrderId,
378    pub trade_id: TradeId,
379    pub price_precision: u8,
380    pub size_precision: u8,
381    pub currency: Currency,
382    pub liquidity_side: LiquiditySide,
383    pub ts_event: UnixNanos,
384    pub ts_init: UnixNanos,
385}
386
387pub(super) fn parse_validated_maker_fill_report(
388    mo: &PolymarketMakerOrder,
389    trader_side: PolymarketLiquiditySide,
390    trade_side: PolymarketOrderSide,
391    taker_asset_id: &str,
392    ctx: MakerFillParseContext,
393) -> anyhow::Result<FillReport> {
394    let order_side = determine_order_side(
395        trader_side,
396        trade_side,
397        taker_asset_id,
398        mo.asset_id.as_str(),
399    );
400    let last_qty = Quantity::from_decimal_dp(mo.matched_amount, ctx.size_precision)
401        .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
402    let last_px = Price::from_decimal_dp(mo.price, ctx.price_precision)
403        .unwrap_or_else(|_| Price::zero(ctx.price_precision));
404    let commission_value = compute_commission(
405        Decimal::ZERO,
406        1.0,
407        mo.matched_amount,
408        mo.price,
409        ctx.liquidity_side,
410    );
411    let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
412        format!(
413            "failed to represent commission {commission_value} for {} as Money",
414            ctx.instrument_id
415        )
416    })?;
417
418    Ok(FillReport {
419        account_id: ctx.account_id,
420        instrument_id: ctx.instrument_id,
421        venue_order_id: ctx.venue_order_id,
422        trade_id: ctx.trade_id,
423        order_side,
424        last_qty,
425        last_px,
426        commission,
427        liquidity_side: ctx.liquidity_side,
428        avg_px: None,
429        report_id: UUID4::new(),
430        ts_event: ctx.ts_event,
431        ts_init: ctx.ts_init,
432        client_order_id: None,
433        venue_position_id: None,
434    })
435}
436
437/// Returns the effective taker fee rate for a Polymarket instrument.
438///
439/// Polymarket sets this from the Gamma market's `feeSchedule.rate`. When the
440/// feeSchedule is unavailable (e.g. CLOB-only flow) the instrument's taker fee
441/// defaults to zero and no commission is charged.
442#[must_use]
443pub fn instrument_taker_fee(instrument: &InstrumentAny) -> Decimal {
444    match instrument {
445        InstrumentAny::BinaryOption(bo) => bo.taker_fee,
446        _ => Decimal::ZERO,
447    }
448}
449
450/// Returns the fee-schedule exponent for a Polymarket instrument. Polymarket
451/// stores `feeSchedule.exponent` in the instrument's `info` map at parse
452/// time. Defaults to `1.0` when missing so the fee curve degenerates to the
453/// simple `fee = C * rate * p * (1 - p)` form used by [`compute_commission`].
454#[must_use]
455pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> f64 {
456    match instrument {
457        InstrumentAny::BinaryOption(bo) => bo
458            .info
459            .as_ref()
460            .and_then(|info| info.get("fee_schedule"))
461            .and_then(|fs| fs.get("exponent"))
462            .and_then(serde_json::Value::as_f64)
463            .unwrap_or(1.0),
464        _ => 1.0,
465    }
466}
467
468/// Adjusts a market-BUY pUSD amount to fit within the user's pUSD balance once
469/// platform and builder taker fees are deducted. Mirrors `adjust_market_buy_amount`
470/// in `polymarket-rs-clob-client-v2`'s `clob/utilities.rs`.
471///
472/// Returns `amount` unchanged when the balance already covers `amount + fees`.
473/// Otherwise solves for the principal that, with fees, exactly consumes the
474/// balance, then truncates to `USDC_DECIMALS` (the on-chain pUSD scale).
475///
476/// The fee-curve step `(p * (1 - p))^exponent` is the only computation that
477/// crosses into `f64`, matching the reference SDK so we agree with the
478/// venue's authoritative match-time fee calculation regardless of whether
479/// Polymarket ships a fractional exponent in the future.
480///
481/// `price` must be strictly inside `(0, 1)`. The SDK relies on its
482/// order-builder pipeline to enforce this; this helper is public so we
483/// repeat the precondition here.
484///
485/// # Errors
486///
487/// Returns an error if `price` is outside the open `(0, 1)` interval, or if
488/// the balance is too small to cover even one pUSD-unit of fees and the
489/// adjusted amount truncates to zero.
490pub fn adjust_market_buy_amount(
491    amount: Decimal,
492    user_pusd_balance: Decimal,
493    price: Decimal,
494    fee_rate: Decimal,
495    fee_exponent: f64,
496    builder_taker_fee_rate: Decimal,
497) -> anyhow::Result<Decimal> {
498    if price <= Decimal::ZERO || price >= Decimal::ONE {
499        anyhow::bail!(
500            "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
501        );
502    }
503
504    let platform_fee_rate = fee_curve_rate(fee_rate, price, fee_exponent);
505
506    let platform_fee = amount / price * platform_fee_rate;
507    let total_cost = amount + platform_fee + amount * builder_taker_fee_rate;
508
509    let raw = if user_pusd_balance <= total_cost {
510        let divisor = Decimal::ONE + platform_fee_rate / price + builder_taker_fee_rate;
511        user_pusd_balance / divisor
512    } else {
513        amount
514    };
515
516    let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
517    if adjusted.is_zero() {
518        anyhow::bail!(
519            "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
520             fee-adjusted amount truncated to zero"
521        );
522    }
523    Ok(adjusted)
524}
525
526/// Computes a pUSD commission using Polymarket's platform fee formula.
527///
528/// `fee = C * feeRate * (p * (1 - p))^exponent`, paid only by takers.
529/// The fee is rounded to 5 decimal places.
530///
531/// The `fee_rate` here is the effective rate from `feeSchedule.rate` (e.g. 0.03 for
532/// 3%), not the `fee_rate_bps` field on a V2 trade response. The response field is
533/// the post-trade rate that actually applied; under V2 the fee is no longer carried
534/// in the signed order, so we compute commissions from the instrument's fee schedule
535/// rather than reading any cap off the order body.
536///
537/// # References
538/// <https://docs.polymarket.com/trading/fees>
539pub fn compute_commission(
540    fee_rate: Decimal,
541    fee_exponent: f64,
542    size: Decimal,
543    price: Decimal,
544    liquidity_side: LiquiditySide,
545) -> Decimal {
546    if liquidity_side != LiquiditySide::Taker || fee_rate.is_zero() {
547        return Decimal::ZERO;
548    }
549
550    let commission = size * fee_curve_rate(fee_rate, price, fee_exponent);
551    commission.round_dp(5)
552}
553
554fn fee_curve_rate(fee_rate: Decimal, price: Decimal, fee_exponent: f64) -> Decimal {
555    let base = price * (Decimal::ONE - price);
556    let base_f64: f64 = base.try_into().unwrap_or(0.0);
557    let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
558    fee_rate * curve
559}
560
561/// Sums `last_qty` across fills as a decimal.
562pub(crate) fn sum_filled_quantity(fills: &[FillReport]) -> Decimal {
563    fills.iter().map(|f| f.last_qty.as_decimal()).sum()
564}
565
566/// Quantity-weighted average price across fills, or `None` when total filled
567/// is zero (avoids divide-by-zero on empty/all-zero fill lists).
568pub(crate) fn weighted_average_price(
569    fills: &[FillReport],
570    total_filled: Decimal,
571) -> Option<Decimal> {
572    if total_filled.is_zero() {
573        return None;
574    }
575    let weighted: Decimal = fills
576        .iter()
577        .map(|f| f.last_qty.as_decimal() * f.last_px.as_decimal())
578        .sum();
579    Some(weighted / total_filled)
580}
581
582/// Resolves the terminal status of a venue-terminal order from its filled quantity.
583///
584/// Polymarket reports a terminated order as `MATCHED` whether or not it filled completely: an IOC
585/// underfill was killed, any other non-dust remainder was canceled. Dust remainders stay `Filled`.
586pub(crate) fn recovered_terminal_order_status(
587    time_in_force: TimeInForce,
588    quantity: Quantity,
589    filled_qty: Quantity,
590) -> OrderStatus {
591    if time_in_force == TimeInForce::Ioc && filled_qty < quantity {
592        return OrderStatus::Canceled;
593    }
594
595    let dust_diff = (quantity.as_decimal() - filled_qty.as_decimal()).abs();
596    if filled_qty >= quantity || dust_diff < DUST_SNAP_THRESHOLD_DEC {
597        OrderStatus::Filled
598    } else {
599        OrderStatus::Canceled
600    }
601}
602
603/// At terminal `Filled` status, snap `filled_qty` to `quantity` when the
604/// difference is within `DUST_SNAP_THRESHOLD_DEC`. Polymarket reports `size_matched`
605/// directly from venue truncation: CLOB cent-tick rounding (underfill) or V2
606/// market-BUY USDC-scale truncation (overfill). Without this snap an order at
607/// `MATCHED` can show non-zero leaves to the engine.
608///
609/// See `docs/integrations/polymarket.md` (Fill quantity normalization).
610pub(crate) fn snap_filled_qty_to_quantity(
611    quantity: Quantity,
612    filled_qty: Quantity,
613    order_status: OrderStatus,
614) -> Quantity {
615    if order_status != OrderStatus::Filled {
616        return filled_qty;
617    }
618    let diff = quantity.as_decimal() - filled_qty.as_decimal();
619    if !diff.is_zero() && diff.abs() < DUST_SNAP_THRESHOLD_DEC {
620        quantity
621    } else {
622        filled_qty
623    }
624}
625
626/// pUSD scale factor: the Polymarket API returns balances in micro-pUSD (10^6 units).
627const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
628
629/// Converts a raw micro-pUSD balance from the Polymarket API into an [`AccountBalance`].
630///
631/// The API returns balances as integer micro-pUSD (e.g. `20000000` = 20 pUSD).
632/// This divides by 10^6 and constructs Money via `Money::from_decimal`, matching
633/// the pattern used by dYdX, Deribit, OKX, and other adapters.
634pub fn parse_balance_allowance(
635    balance_raw: Decimal,
636    currency: Currency,
637) -> anyhow::Result<AccountBalance> {
638    let balance_pusd = balance_raw / USDC_SCALE;
639    AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
640        .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
641}
642
643/// Result of walking the order book to compute market order parameters.
644#[derive(Debug)]
645pub struct MarketPriceResult {
646    /// The crossing price (worst level reached) for the signed CLOB order.
647    pub crossing_price: Decimal,
648    /// Expected base quantity (shares) computed by walking levels at actual prices.
649    pub expected_base_qty: Decimal,
650}
651
652/// Calculates the market-crossing price and expected base quantity by walking the order book.
653///
654/// Sorts levels deterministically before walking:
655/// - BUY (asks): ascending by price, best (lowest) ask first
656/// - SELL (bids): descending by price, best (highest) bid first
657///
658/// This ensures correct results regardless of the CLOB API's response ordering.
659///
660/// For BUY: walks asks best-first, accumulates `size * price` (pUSD) until >= amount.
661///          Also accumulates the exact shares at each level for precise base qty.
662/// For SELL: walks bids best-first, accumulates `size` (shares) until >= amount.
663///
664/// Returns the crossing price and expected base quantity. If insufficient liquidity,
665/// uses all available levels. If the book side is empty, returns an error.
666pub fn calculate_market_price(
667    book_levels: &[ClobBookLevel],
668    amount: Decimal,
669    side: PolymarketOrderSide,
670) -> anyhow::Result<MarketPriceResult> {
671    if book_levels.is_empty() {
672        anyhow::bail!("Empty order book: no liquidity available for market order");
673    }
674
675    // Parse and sort levels deterministically so we never depend on API ordering.
676    // BUY: asks ascending (best/lowest first). SELL: bids descending (best/highest first).
677    let mut parsed_levels: Vec<(Decimal, Decimal)> = book_levels
678        .iter()
679        .map(|l| {
680            let price = Decimal::from_str_exact(&l.price).unwrap_or(Decimal::ZERO);
681            let size = Decimal::from_str_exact(&l.size).unwrap_or(Decimal::ZERO);
682            (price, size)
683        })
684        .filter(|(p, s)| !p.is_zero() && !s.is_zero())
685        .collect();
686
687    if parsed_levels.is_empty() {
688        anyhow::bail!("Empty order book: no valid price levels for market order");
689    }
690
691    match side {
692        PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
693        PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
694    }
695
696    let mut remaining = amount;
697    let mut last_price = Decimal::ZERO;
698    let mut total_base_qty = Decimal::ZERO;
699
700    for &(price, size) in &parsed_levels {
701        last_price = price;
702
703        match side {
704            PolymarketOrderSide::Buy => {
705                let level_usdc = size * price;
706                let consumed_usdc = level_usdc.min(remaining);
707                let shares_at_level = consumed_usdc / price;
708                total_base_qty += shares_at_level;
709                remaining -= consumed_usdc;
710            }
711            PolymarketOrderSide::Sell => {
712                let consumed_shares = size.min(remaining);
713                total_base_qty += consumed_shares;
714                remaining -= consumed_shares;
715            }
716        }
717
718        if remaining <= Decimal::ZERO {
719            return Ok(MarketPriceResult {
720                crossing_price: last_price,
721                expected_base_qty: total_base_qty,
722            });
723        }
724    }
725
726    // Insufficient liquidity: return what we have. FOK may reject at the venue;
727    // FAK can fill the immediately available size and cancel the remainder.
728    Ok(MarketPriceResult {
729        crossing_price: last_price,
730        expected_base_qty: total_base_qty,
731    })
732}
733
734/// Parses a timestamp string into [`UnixNanos`].
735///
736/// Accepts millisecond integers ("1703875200000"), second integers ("1703875200"),
737/// and RFC3339 strings ("2024-01-01T00:00:00Z").
738pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
739    if let Ok(n) = ts_str.parse::<u64>() {
740        return if n > 1_000_000_000_000 {
741            n.checked_mul(NANOSECONDS_IN_MILLISECOND)
742                .map(UnixNanos::from)
743        } else {
744            n.checked_mul(NANOSECONDS_IN_SECOND).map(UnixNanos::from)
745        };
746    }
747    let dt = ts_str.parse::<Timestamp>().ok()?;
748    Some(UnixNanos::from(u64::try_from(dt.as_nanosecond()).ok()?))
749}
750
751#[cfg(test)]
752mod tests {
753    use nautilus_execution::models::fee::{FeeModel, ProbabilityPriceFeeModel};
754    use nautilus_model::{
755        enums::{OrderSide, OrderType},
756        instruments::{Instrument, InstrumentAny, stubs::binary_option},
757        orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
758    };
759    use rstest::rstest;
760    use rust_decimal_macros::dec;
761    use ustr::Ustr;
762
763    use super::*;
764    use crate::common::enums::{
765        PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
766    };
767
768    // Symmetric dust band: at terminal Filled, snap filled_qty to quantity
769    // when within 0.01 shares. Other statuses (Accepted, Canceled, etc.)
770    // pass through unchanged so partial fills remain visible.
771    #[rstest]
772    // CLOB cent-tick underfill at MATCHED: snap UP to quantity.
773    #[case::filled_underfill_dust(100.000000, 99.995000, OrderStatus::Filled, 100.000000)]
774    // V2 BUY USDC-scale overfill at MATCHED: snap DOWN to quantity.
775    #[case::filled_overfill_dust(714.285710, 714.285714, OrderStatus::Filled, 714.285710)]
776    // Underfill at exactly the band: NOT dust, leave alone.
777    #[case::filled_underfill_at_band(100.000000, 99.990000, OrderStatus::Filled, 99.990000)]
778    // Underfill above the band: real partial leaves, leave alone.
779    #[case::filled_underfill_above_band(100.000000, 99.000000, OrderStatus::Filled, 99.000000)]
780    // Exact match at MATCHED: identity.
781    #[case::filled_exact(100.000000, 100.000000, OrderStatus::Filled, 100.000000)]
782    // Same dust gap at non-Filled status: leave alone (live partial fill).
783    #[case::accepted_underfill_dust(100.000000, 99.995000, OrderStatus::Accepted, 99.995000)]
784    // Same dust gap at Canceled: leave alone (legitimate partial fill before cancel).
785    #[case::canceled_underfill_dust(100.000000, 99.995000, OrderStatus::Canceled, 99.995000)]
786    fn test_snap_filled_qty_to_quantity(
787        #[case] quantity: f64,
788        #[case] filled: f64,
789        #[case] status: OrderStatus,
790        #[case] expected: f64,
791    ) {
792        let snapped = snap_filled_qty_to_quantity(
793            Quantity::new(quantity, 6),
794            Quantity::new(filled, 6),
795            status,
796        );
797        assert_eq!(snapped, Quantity::new(expected, 6));
798    }
799
800    fn make_test_fill(qty: f64, px: f64) -> FillReport {
801        FillReport::new(
802            AccountId::from("POLY-001"),
803            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
804            VenueOrderId::from("0xabc"),
805            TradeId::from("trade-1"),
806            OrderSide::Buy,
807            Quantity::new(qty, 4),
808            Price::new(px, 4),
809            Money::zero(Currency::pUSD()),
810            LiquiditySide::Taker,
811            None,
812            None,
813            UnixNanos::default(),
814            UnixNanos::default(),
815            None,
816        )
817    }
818
819    fn binary_option_fill_order(
820        instrument: &InstrumentAny,
821        liquidity_side: LiquiditySide,
822        price: &str,
823    ) -> OrderAny {
824        let limit_order = OrderTestBuilder::new(OrderType::Limit)
825            .instrument_id(instrument.id())
826            .side(OrderSide::Buy)
827            .price(Price::from(price))
828            .quantity(Quantity::from("100.00"))
829            .build();
830
831        TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
832    }
833
834    #[rstest]
835    fn test_sum_filled_quantity_empty() {
836        assert_eq!(sum_filled_quantity(&[]), Decimal::ZERO);
837    }
838
839    #[rstest]
840    fn test_sum_filled_quantity_multiple() {
841        let fills = vec![
842            make_test_fill(2.5, 0.50),
843            make_test_fill(1.0, 0.60),
844            make_test_fill(3.0, 0.55),
845        ];
846        assert_eq!(sum_filled_quantity(&fills), dec!(6.5));
847    }
848
849    #[rstest]
850    fn test_weighted_average_price_zero_total_returns_none() {
851        assert!(weighted_average_price(&[], Decimal::ZERO).is_none());
852    }
853
854    #[rstest]
855    fn test_weighted_average_price_single_fill() {
856        let fills = vec![make_test_fill(10.0, 0.5)];
857        let total = sum_filled_quantity(&fills);
858        assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.5)));
859    }
860
861    #[rstest]
862    fn test_weighted_average_price_weighted_by_quantity() {
863        // 2 @ 0.40 + 8 @ 0.60 -> (0.8 + 4.8) / 10 = 0.56
864        let fills = vec![make_test_fill(2.0, 0.40), make_test_fill(8.0, 0.60)];
865        let total = sum_filled_quantity(&fills);
866        assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.56)));
867    }
868
869    #[rstest]
870    #[case(dec!(20_000_000), 20.0)] // 20 pUSD
871    #[case(dec!(1_000_000), 1.0)] // 1 pUSD
872    #[case(dec!(500_000), 0.5)] // 0.5 pUSD
873    #[case(dec!(0), 0.0)] // zero
874    #[case(dec!(123_456_789), 123.456789)] // fractional
875    fn test_parse_balance_allowance(#[case] raw: Decimal, #[case] expected: f64) {
876        let currency = Currency::pUSD();
877        let balance = parse_balance_allowance(raw, currency).unwrap();
878        let total_f64: f64 = balance.total.as_decimal().to_string().parse().unwrap();
879        assert!(
880            (total_f64 - expected).abs() < 1e-8,
881            "expected {expected}, was {total_f64}"
882        );
883        assert_eq!(balance.free, balance.total);
884    }
885
886    #[rstest]
887    #[case::crypto_p50("0.07", "0.50", dec!(1.75))]
888    #[case::crypto_p01("0.07", "0.01", dec!(0.0693))]
889    #[case::crypto_p05("0.07", "0.05", dec!(0.3325))]
890    #[case::crypto_p10("0.07", "0.10", dec!(0.63))]
891    #[case::crypto_p30("0.07", "0.30", dec!(1.47))]
892    #[case::crypto_p70("0.07", "0.70", dec!(1.47))]
893    #[case::crypto_p90("0.07", "0.90", dec!(0.63))]
894    #[case::crypto_p99("0.07", "0.99", dec!(0.0693))]
895    #[case::sports_p50("0.05", "0.50", dec!(1.25))]
896    #[case::sports_p30("0.05", "0.30", dec!(1.05))]
897    #[case::sports_p70("0.05", "0.70", dec!(1.05))]
898    #[case::politics_p50("0.04", "0.50", dec!(1.0))]
899    #[case::politics_p30("0.04", "0.30", dec!(0.84))]
900    #[case::economics_p50("0.05", "0.50", dec!(1.25))]
901    #[case::economics_p30("0.05", "0.30", dec!(1.05))]
902    #[case::geopolitics_p50("0", "0.50", dec!(0.0))]
903    fn test_compute_commission_docs_table(
904        #[case] fee_rate: &str,
905        #[case] price: &str,
906        #[case] expected: Decimal,
907    ) {
908        let commission = compute_commission(
909            Decimal::from_str_exact(fee_rate).unwrap(),
910            1.0,
911            dec!(100),
912            Decimal::from_str_exact(price).unwrap(),
913            LiquiditySide::Taker,
914        );
915        assert_eq!(commission, expected);
916    }
917
918    #[rstest]
919    fn test_compute_commission_issue_3860_strategy_buy() {
920        // Issue #3860: strategy BUY fill
921        // qty=15.463900, price=0.97, fee_rate=0.072
922        // Expected: 15.4639 * 0.97 * 0.072 * (1 - 0.97) = 0.03240
923        let commission = compute_commission(
924            dec!(0.072),
925            1.0,
926            Decimal::from_str_exact("15.463900").unwrap(),
927            dec!(0.97),
928            LiquiditySide::Taker,
929        );
930        assert_eq!(commission, dec!(0.03240));
931    }
932
933    #[rstest]
934    fn test_compute_commission_issue_3860_reconciliation_sell() {
935        // Issue #3860: reconciliation EXTERNAL SELL fill
936        // qty=0.033400, price=0.98, fee_rate=0.072
937        // Was 0.002357 with old generic formula (qty * price * fee_rate)
938        // Correct: 0.0334 * 0.98 * 0.072 * (1 - 0.98) = 0.00005
939        let commission = compute_commission(
940            dec!(0.072),
941            1.0,
942            Decimal::from_str_exact("0.033400").unwrap(),
943            dec!(0.98),
944            LiquiditySide::Taker,
945        );
946        assert_eq!(commission, dec!(0.00005));
947    }
948
949    #[rstest]
950    fn test_compute_commission_maker_is_zero() {
951        let commission = compute_commission(
952            Decimal::from_str_exact("0.072").unwrap(),
953            1.0,
954            dec!(100),
955            Decimal::from_str_exact("0.50").unwrap(),
956            LiquiditySide::Maker,
957        );
958        assert_eq!(commission, dec!(0));
959    }
960
961    #[rstest]
962    fn test_compute_commission_uses_fee_exponent() {
963        let commission =
964            compute_commission(dec!(0.04), 2.0, dec!(10), dec!(0.5), LiquiditySide::Taker);
965        assert_eq!(commission, dec!(0.025));
966    }
967
968    #[rstest]
969    #[case::crypto_taker("0.07", "0.970", LiquiditySide::Taker)]
970    #[case::sports_taker("0.05", "0.500", LiquiditySide::Taker)]
971    #[case::politics_taker("0.04", "0.300", LiquiditySide::Taker)]
972    #[case::maker_zero("0.03", "0.500", LiquiditySide::Maker)]
973    fn test_probability_price_fee_model_matches_polymarket_commission(
974        #[case] taker_fee: &str,
975        #[case] price: &str,
976        #[case] liquidity_side: LiquiditySide,
977    ) {
978        let mut binary = binary_option();
979        binary.maker_fee = Decimal::ZERO;
980        binary.taker_fee = Decimal::from_str_exact(taker_fee).unwrap();
981        let instrument = InstrumentAny::BinaryOption(binary);
982        let order = binary_option_fill_order(&instrument, liquidity_side, price);
983        let fee_model = ProbabilityPriceFeeModel;
984
985        let commission = fee_model
986            .get_commission(
987                &order,
988                Quantity::from("100.00"),
989                Price::from(price),
990                &instrument,
991            )
992            .unwrap();
993
994        let expected = compute_commission(
995            Decimal::from_str_exact(taker_fee).unwrap(),
996            1.0,
997            dec!(100),
998            Decimal::from_str_exact(price).unwrap(),
999            liquidity_side,
1000        );
1001
1002        assert_eq!(commission.as_decimal(), expected);
1003    }
1004
1005    /// Reference computations for `adjust_market_buy_amount` follow the SDK
1006    /// formula:
1007    ///   platform_fee_rate = fee_rate * (p * (1 - p))^exp
1008    ///   platform_fee     = (amount / p) * platform_fee_rate
1009    ///   total_cost       = amount + platform_fee + amount * builder_taker_fee_rate
1010    ///   if balance <= total_cost:
1011    ///     adjusted = balance / (1 + platform_fee_rate / p + builder_taker_fee_rate)
1012    ///   else:
1013    ///     adjusted = amount
1014    ///   adjusted = trunc_with_scale(adjusted, USDC_DECIMALS)
1015    #[rstest]
1016    fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
1017        // amount=10, balance=20, price=0.5, fee_rate=0.04, exp=1, builder=0
1018        // platform_fee = 10/0.5 * 0.04 * 0.25 = 0.2; total_cost = 10.2
1019        // balance(20) > 10.2 -> unchanged
1020        let adjusted =
1021            adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1022                .unwrap();
1023        assert_eq!(adjusted, dec!(10.000000));
1024    }
1025
1026    #[rstest]
1027    fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
1028        // SDK uses `<=` on the balance vs total_cost test, so an exact
1029        // balance == total_cost should still go through the divisor branch.
1030        // amount=10, total_cost=10.2 with the params below.
1031        let adjusted =
1032            adjust_market_buy_amount(dec!(10), dec!(10.2), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1033                .unwrap();
1034        // raw = 10.2 / 1.02 = 10.0; truncated to 6dp = 10.000000.
1035        assert_eq!(adjusted, dec!(10.000000));
1036    }
1037
1038    #[rstest]
1039    fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
1040        // amount=10, balance=5.1, price=0.5, fee_rate=0.04, exp=1, builder=0
1041        // total_cost = 10.2; balance < total_cost
1042        // divisor = 1 + 0.04*0.25/0.5 = 1.02; raw = 5.1/1.02 = 5.0
1043        let adjusted =
1044            adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1045                .unwrap();
1046        assert_eq!(adjusted, dec!(5.000000));
1047    }
1048
1049    #[rstest]
1050    fn test_adjust_market_buy_amount_with_builder_fee() {
1051        // amount=10, balance=10, price=0.5, fee_rate=0.04, exp=1, builder=0.001
1052        // platform_fee_rate = 0.01; platform_fee = 0.2
1053        // total_cost = 10 + 0.2 + 10*0.001 = 10.21; balance < total_cost
1054        // divisor = 1 + 0.01/0.5 + 0.001 = 1.021
1055        // raw = 10/1.021 = 9.79431928..., trunc(6) = 9.794319
1056        let adjusted =
1057            adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 1.0, dec!(0.001))
1058                .unwrap();
1059        assert_eq!(adjusted, dec!(9.794319));
1060    }
1061
1062    #[rstest]
1063    fn test_adjust_market_buy_amount_crypto_fee_rate() {
1064        // Polymarket "Crypto" tier uses fee_rate = 0.07.
1065        // amount=100, balance=100, price=0.5, fee_rate=0.07, exp=1, builder=0
1066        // platform_fee_rate = 0.07 * 0.25 = 0.0175
1067        // platform_fee = 100/0.5 * 0.0175 = 3.5; total_cost = 103.5
1068        // divisor = 1 + 0.0175/0.5 = 1.035; raw = 100/1.035
1069        let adjusted =
1070            adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.07), 1.0, dec!(0))
1071                .unwrap();
1072        // 100 / 1.035 == 96.6183574...; truncate to 6dp.
1073        assert_eq!(adjusted, dec!(96.618357));
1074    }
1075
1076    #[rstest]
1077    fn test_adjust_market_buy_amount_extreme_low_price() {
1078        // Boundary of the price domain. Fees become tiny relative to spend.
1079        // amount=10, balance=10, price=0.001, fee_rate=0.04, exp=1
1080        // base = 0.001 * 0.999 = 0.000999
1081        // platform_fee_rate = 0.04 * 0.000999 = 0.00003996
1082        // divisor = 1 + 0.00003996/0.001 = 1.03996
1083        // raw = 10 / 1.03996 = 9.61575...
1084        let adjusted =
1085            adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.001), dec!(0.04), 1.0, dec!(0))
1086                .unwrap();
1087        // The exact divisor in 28-dp Decimal differs slightly from the
1088        // human-rounded 9.615755 above, so allow a 1e-5 tolerance.
1089        let expected = dec!(9.615755);
1090        assert!(
1091            (adjusted - expected).abs() < dec!(0.00001),
1092            "expected ~{expected}, was {adjusted}",
1093        );
1094    }
1095
1096    #[rstest]
1097    fn test_adjust_market_buy_amount_integer_exponent_two() {
1098        // Hypothetical exp=2 -- the curve gets steeper.
1099        // amount=10, balance=10, price=0.5, fee_rate=0.04, exp=2, builder=0
1100        // base^2 = 0.25^2 = 0.0625
1101        // platform_fee_rate = 0.04 * 0.0625 = 0.0025
1102        // divisor = 1 + 0.0025/0.5 = 1.005
1103        // raw = 10 / 1.005 = 9.95024876...
1104        let adjusted =
1105            adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 2.0, dec!(0))
1106                .unwrap();
1107        assert!(
1108            (adjusted - dec!(9.950248)).abs() < dec!(0.00001),
1109            "expected ~9.950248, was {adjusted}",
1110        );
1111    }
1112
1113    #[rstest]
1114    fn test_adjust_market_buy_amount_fractional_exponent() {
1115        // Confirms the f64 boundary on the curve copes with fractional
1116        // exponents the way the SDK does. exp=0.5 -> sqrt(p*(1-p)).
1117        // For price=0.5: sqrt(0.25) = 0.5
1118        // platform_fee_rate = 0.04 * 0.5 = 0.02
1119        // divisor = 1 + 0.02/0.5 = 1.04
1120        // raw = 10 / 1.04 = 9.61538...
1121        let adjusted =
1122            adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 0.5, dec!(0))
1123                .unwrap();
1124        assert!(
1125            (adjusted - dec!(9.615384)).abs() < dec!(0.00001),
1126            "expected ~9.615384, was {adjusted}",
1127        );
1128    }
1129
1130    #[rstest]
1131    fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
1132        // No platform fee + no builder fee + balance >= amount -> unchanged.
1133        let adjusted =
1134            adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), 1.0, dec!(0)).unwrap();
1135        assert_eq!(adjusted, dec!(10.000000));
1136    }
1137
1138    #[rstest]
1139    fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
1140        // Even with no fees, if balance < amount we shrink to the balance.
1141        let adjusted =
1142            adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), 1.0, dec!(0))
1143                .unwrap();
1144        assert_eq!(adjusted, dec!(7.500000));
1145    }
1146
1147    #[rstest]
1148    fn test_adjust_market_buy_amount_balance_too_small_errors() {
1149        // Balance below the 6dp truncation threshold for the fee-adjusted
1150        // amount surfaces as a domain error instead of silently submitting a
1151        // zero-value order.
1152        let err = adjust_market_buy_amount(
1153            dec!(10),
1154            dec!(0.0000001),
1155            dec!(0.5),
1156            dec!(0.04),
1157            1.0,
1158            dec!(0),
1159        )
1160        .unwrap_err();
1161        assert!(err.to_string().contains("too small"));
1162    }
1163
1164    #[rstest]
1165    #[case::zero_price(dec!(0))]
1166    #[case::one_price(dec!(1))]
1167    #[case::negative_price(dec!(-0.1))]
1168    #[case::above_one_price(dec!(1.5))]
1169    fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
1170        let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), 1.0, dec!(0))
1171            .unwrap_err();
1172        assert!(
1173            err.to_string().contains("invalid market-buy price"),
1174            "expected price-domain error, was {err}",
1175        );
1176    }
1177
1178    #[rstest]
1179    fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
1180        // amount=10, balance=9.123456789, price=0.5, fee_rate=0.04
1181        // raw = 9.123456789 / 1.02 = 8.944565479...; trunc(6) = 8.944565
1182        let adjusted = adjust_market_buy_amount(
1183            dec!(10),
1184            dec!(9.123456789),
1185            dec!(0.5),
1186            dec!(0.04),
1187            1.0,
1188            dec!(0),
1189        )
1190        .unwrap();
1191        // Verify the result has at most 6 decimal places.
1192        assert!(adjusted.scale() <= 6);
1193        // And the value is in the expected neighbourhood.
1194        let expected = dec!(8.944565);
1195        assert!(
1196            (adjusted - expected).abs() < dec!(0.000001),
1197            "expected ~{expected}, was {adjusted}",
1198        );
1199    }
1200
1201    // SDK-ported parity tests for `adjust_market_buy_amount`. These mirror the
1202    // tests in `polymarket-rs-clob-client-v2`'s `clob/utilities.rs` so that any
1203    // drift from the reference SDK is caught locally.
1204
1205    /// `platform_fee = (amount / price) * rate * (price * (1 - price))^exponent`
1206    /// Pure-Decimal port of the SDK's test-only fee helper, matching their
1207    /// integer-exponent path so the conservation tests below stay exact.
1208    fn calc_platform_fee_sdk(
1209        amount: Decimal,
1210        price: Decimal,
1211        rate: Decimal,
1212        exponent: u32,
1213    ) -> Decimal {
1214        let base = price * (Decimal::ONE - price);
1215        let base_f64 = f64::try_from(base).unwrap_or(0.0);
1216        let rate_factor = rate
1217            * Decimal::try_from(base_f64.powi(i32::try_from(exponent).unwrap_or(0)))
1218                .unwrap_or(Decimal::ZERO);
1219        (amount / price) * rate_factor
1220    }
1221
1222    /// `builder_fee = amount * rate` (flat percentage on notional).
1223    fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
1224        amount * rate
1225    }
1226
1227    fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
1228        let diff = (actual - expected).abs();
1229        assert!(
1230            diff <= tol,
1231            "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
1232        );
1233    }
1234
1235    #[rstest]
1236    fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
1237        // Verbatim from SDK utilities.rs::adjust_market_buy_no_adjustment_when_balance_sufficient.
1238        let result =
1239            adjust_market_buy_amount(dec!(100), dec!(1000), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1240                .unwrap();
1241        assert_eq!(result, dec!(100));
1242    }
1243
1244    #[rstest]
1245    fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
1246        // Verbatim from SDK::adjust_market_buy_adjusts_when_balance_insufficient.
1247        let result =
1248            adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1249                .unwrap();
1250        assert!(result < dec!(100));
1251        assert!(result > dec!(0));
1252    }
1253
1254    #[rstest]
1255    fn test_sdk_adjust_market_buy_with_builder_fee() {
1256        // Verbatim from SDK::adjust_market_buy_with_builder_fee.
1257        let result =
1258            adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0), 1.0, dec!(0.005))
1259                .unwrap();
1260        // effective * 1.005 = 100, truncated to 6 USDC decimals.
1261        let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
1262        assert_eq!(result, expected);
1263    }
1264
1265    #[rstest]
1266    fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
1267        // Verbatim from SDK::adjust_market_buy_errors_when_balance_truncates_to_zero.
1268        let err = adjust_market_buy_amount(
1269            dec!(100),
1270            dec!(0.0000001),
1271            dec!(0.5),
1272            dec!(0.02),
1273            1.0,
1274            dec!(0.005),
1275        )
1276        .unwrap_err();
1277        assert!(err.to_string().contains("truncated to zero"));
1278    }
1279
1280    #[rstest]
1281    fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
1282        // Ported from SDK::adjust_buy_balance_strictly_greater_returns_amount_unchanged.
1283        // Uses calc_platform_fee_sdk to build a balance comfortably above total cost.
1284        let amount = dec!(50);
1285        let price = dec!(0.5);
1286        let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1287        let balance = amount + fee + dec!(1);
1288        let result =
1289            adjust_market_buy_amount(amount, balance, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1290        assert_eq!(result, amount);
1291    }
1292
1293    #[rstest]
1294    fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
1295        // Ported from SDK::adjust_buy_balance_equal_to_total_cost_matches_divide_path.
1296        // At `balance == total_cost` the `<=` check fires and the divisor branch
1297        // reconstitutes the original amount.
1298        let amount = dec!(50);
1299        let price = dec!(0.5);
1300        let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1301        let total_cost = amount + fee;
1302        let result =
1303            adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1304        close_to(result, amount, dec!(0.000001));
1305    }
1306
1307    #[rstest]
1308    fn test_sdk_adjust_buy_conserves_notional_platform_only() {
1309        // Ported from SDK::adjust_buy_conserves_notional_platform_only.
1310        // balance = amount: adjusted + fee must reconstitute `amount`.
1311        let amount = dec!(50);
1312        let price = dec!(0.5);
1313        let adjusted =
1314            adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1315        let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1316        close_to(adjusted + fee, amount, dec!(0.000001));
1317        assert!(adjusted < amount);
1318    }
1319
1320    #[rstest]
1321    fn test_sdk_adjust_buy_conserves_notional_builder_only() {
1322        // Ported from SDK::adjust_buy_conserves_notional_builder_only.
1323        let amount = dec!(50);
1324        let price = dec!(0.5);
1325        let builder_rate = dec!(0.01);
1326        let adjusted =
1327            adjust_market_buy_amount(amount, amount, price, dec!(0), 0.0, builder_rate).unwrap();
1328        let fee = calc_builder_fee_sdk(adjusted, builder_rate);
1329        close_to(adjusted + fee, amount, dec!(0.000001));
1330    }
1331
1332    #[rstest]
1333    fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
1334        // Ported from SDK::adjust_buy_conserves_notional_platform_and_builder.
1335        let amount = dec!(50);
1336        let price = dec!(0.5);
1337        let builder_rate = dec!(0.01);
1338        let adjusted =
1339            adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1340        let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1341        let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1342        close_to(adjusted + platform + builder, amount, dec!(0.000001));
1343    }
1344
1345    #[rstest]
1346    fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
1347        // Ported from SDK::adjust_buy_conserves_notional_at_price_0_3.
1348        let amount = dec!(30);
1349        let price = dec!(0.3);
1350        let builder_rate = dec!(0.02);
1351        let adjusted =
1352            adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1353        let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1354        let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1355        close_to(adjusted + platform + builder, amount, dec!(0.000001));
1356    }
1357
1358    #[rstest]
1359    fn test_parse_timestamp_ms() {
1360        let ts = parse_timestamp("1703875200000").unwrap();
1361        assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1362    }
1363
1364    #[rstest]
1365    fn test_parse_timestamp_secs() {
1366        let ts = parse_timestamp("1703875200").unwrap();
1367        assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1368    }
1369
1370    #[rstest]
1371    fn test_parse_timestamp_rfc3339() {
1372        let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1373        assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1374    }
1375
1376    #[rstest]
1377    fn test_parse_liquidity_side_maker() {
1378        assert_eq!(
1379            parse_liquidity_side(PolymarketLiquiditySide::Maker),
1380            LiquiditySide::Maker
1381        );
1382    }
1383
1384    #[rstest]
1385    fn test_parse_liquidity_side_taker() {
1386        assert_eq!(
1387            parse_liquidity_side(PolymarketLiquiditySide::Taker),
1388            LiquiditySide::Taker
1389        );
1390    }
1391
1392    #[rstest]
1393    fn test_parse_order_status_report_from_fixture() {
1394        let path = "test_data/http_open_order.json";
1395        let content = std::fs::read_to_string(path).expect("Failed to read test data");
1396        let order: PolymarketOpenOrder =
1397            serde_json::from_str(&content).expect("Failed to parse test data");
1398
1399        let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1400        let account_id = AccountId::from("POLYMARKET-001");
1401
1402        let report = parse_order_status_report(
1403            &order,
1404            instrument_id,
1405            account_id,
1406            None,
1407            4,
1408            6,
1409            UnixNanos::from(1_000_000_000u64),
1410        );
1411
1412        assert_eq!(report.account_id, account_id);
1413        assert_eq!(report.instrument_id, instrument_id);
1414        assert_eq!(report.order_side, Some(OrderSide::Buy));
1415        assert_eq!(report.order_type, OrderType::Limit);
1416        assert_eq!(report.time_in_force, TimeInForce::Gtc);
1417        assert_eq!(report.order_status, OrderStatus::Accepted);
1418        assert!(report.price.is_some());
1419        assert_eq!(
1420            report.ts_accepted,
1421            UnixNanos::from(1_703_875_200_000_000_000u64)
1422        );
1423        assert_eq!(
1424            report.ts_last,
1425            UnixNanos::from(1_703_875_200_000_000_000u64)
1426        );
1427        assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1428        // Fixture has expiration=null which must surface as no expire_time.
1429        assert_eq!(report.expire_time, None);
1430    }
1431
1432    // Verifies parse_order_status_report wires `snap_filled_qty_to_quantity`
1433    // correctly. Helper-level cases live above; this guards the integration.
1434    #[rstest]
1435    // CLOB cent-tick underfill at MATCHED: snap UP to original_size.
1436    #[case::matched_underfill_dust(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.995000), 100.000000)]
1437    // V2 BUY USDC-scale overfill at MATCHED: snap DOWN to original_size.
1438    #[case::matched_overfill_dust(PolymarketOrderStatus::Matched, dec!(714.285710), dec!(714.285714), 714.285710)]
1439    // Same dust gap at LIVE: not snapped (legitimate partial fill in flight).
1440    #[case::live_underfill_dust(PolymarketOrderStatus::Live, dec!(100.000000), dec!(99.995000), 99.995000)]
1441    // Real partial leaves (above band) at MATCHED stay visible to the engine.
1442    #[case::matched_real_partial(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.000000), 99.000000)]
1443    fn test_parse_order_status_report_snaps_dust_filled_qty(
1444        #[case] status: PolymarketOrderStatus,
1445        #[case] original_size: Decimal,
1446        #[case] size_matched: Decimal,
1447        #[case] expected_filled: f64,
1448    ) {
1449        let order = PolymarketOpenOrder {
1450            associate_trades: None,
1451            id: "0xid".to_string(),
1452            status,
1453            market: Ustr::from("0xm"),
1454            original_size,
1455            outcome: PolymarketOutcome::yes(),
1456            maker_address: "0xmaker".to_string(),
1457            owner: "owner".to_string(),
1458            price: dec!(0.5),
1459            side: PolymarketOrderSide::Buy,
1460            size_matched,
1461            asset_id: Ustr::from("token"),
1462            expiration: None,
1463            order_type: PolymarketOrderType::GTC,
1464            created_at: 1_703_875_200,
1465        };
1466
1467        let report = parse_order_status_report(
1468            &order,
1469            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1470            AccountId::from("POLYMARKET-001"),
1471            None,
1472            3,
1473            6,
1474            UnixNanos::from(1_000_000_000u64),
1475        );
1476
1477        assert_eq!(report.filled_qty, Quantity::new(expected_filled, 6));
1478        assert_eq!(
1479            report.quantity,
1480            Quantity::new(original_size.try_into().unwrap_or(0.0), 6)
1481        );
1482    }
1483
1484    /// A `MATCHED` underfill must reach a terminal status, and `Filled` must never carry
1485    /// `filled_qty < quantity`. Dust remainders still resolve as `Filled` (see #3728).
1486    #[rstest]
1487    // Partially filled then canceled: terminal, not `Filled`.
1488    #[case::gtc_real_partial(PolymarketOrderType::GTC, dec!(10), dec!(7), OrderStatus::Canceled)]
1489    // Dust underfill: stays `Filled`, `filled_qty` snaps up to `quantity`.
1490    #[case::gtc_dust_underfill(PolymarketOrderType::GTC, dec!(100), dec!(99.997714), OrderStatus::Filled)]
1491    // GTC exact fill.
1492    #[case::gtc_exact(PolymarketOrderType::GTC, dec!(10), dec!(10), OrderStatus::Filled)]
1493    // FAK underfill is killed by the venue: unchanged.
1494    #[case::fak_partial(PolymarketOrderType::FAK, dec!(30), dec!(20), OrderStatus::Canceled)]
1495    fn test_parse_order_status_report_matched_resolves_terminal_status(
1496        #[case] order_type: PolymarketOrderType,
1497        #[case] original_size: Decimal,
1498        #[case] size_matched: Decimal,
1499        #[case] expected_status: OrderStatus,
1500    ) {
1501        let order = PolymarketOpenOrder {
1502            associate_trades: None,
1503            id: "0xterminal".to_string(),
1504            status: PolymarketOrderStatus::Matched,
1505            market: Ustr::from("0xmarket"),
1506            original_size,
1507            outcome: PolymarketOutcome::yes(),
1508            maker_address: "0xmaker".to_string(),
1509            owner: "owner".to_string(),
1510            price: dec!(0.5),
1511            side: PolymarketOrderSide::Buy,
1512            size_matched,
1513            asset_id: Ustr::from("token"),
1514            expiration: None,
1515            order_type,
1516            created_at: 1_784_118_677,
1517        };
1518
1519        let report = parse_order_status_report(
1520            &order,
1521            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1522            AccountId::from("POLYMARKET-001"),
1523            None,
1524            3,
1525            6,
1526            UnixNanos::from(1_000_000_000u64),
1527        );
1528
1529        assert_eq!(report.order_status, expected_status);
1530        if report.order_status == OrderStatus::Filled {
1531            assert!(
1532                report.filled_qty >= report.quantity,
1533                "a Filled report must not carry filled_qty < quantity, was filled_qty={} quantity={}",
1534                report.filled_qty,
1535                report.quantity
1536            );
1537        }
1538    }
1539
1540    #[rstest]
1541    fn test_parse_order_status_report_maps_partial_fak_match_to_canceled() {
1542        let order = PolymarketOpenOrder {
1543            associate_trades: Some(vec!["trade-partial-fak".to_string()]),
1544            id: "0xpartial-fak".to_string(),
1545            status: PolymarketOrderStatus::Matched,
1546            market: Ustr::from("0xmarket"),
1547            original_size: dec!(30),
1548            outcome: PolymarketOutcome::yes(),
1549            maker_address: "0xmaker".to_string(),
1550            owner: "owner".to_string(),
1551            price: dec!(0.093),
1552            side: PolymarketOrderSide::Buy,
1553            size_matched: dec!(20),
1554            asset_id: Ustr::from("token"),
1555            expiration: Some("0".to_string()),
1556            order_type: PolymarketOrderType::FAK,
1557            created_at: 1_784_118_677,
1558        };
1559
1560        let report = parse_order_status_report(
1561            &order,
1562            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1563            AccountId::from("POLYMARKET-001"),
1564            None,
1565            3,
1566            6,
1567            UnixNanos::from(1_000_000_000u64),
1568        );
1569
1570        assert_eq!(report.order_status, OrderStatus::Canceled);
1571        assert_eq!(report.time_in_force, TimeInForce::Ioc);
1572        assert_eq!(report.quantity, Quantity::from("30.000000"));
1573        assert_eq!(report.filled_qty, Quantity::from("20.000000"));
1574    }
1575
1576    #[rstest]
1577    #[case::null(None, None)]
1578    #[case::zero_string(Some("0"), None)]
1579    #[case::empty_string(Some(""), None)]
1580    #[case::garbage(Some("not-a-number"), None)]
1581    #[case::positive_seconds(
1582        Some("1735689600"),
1583        Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1584    )]
1585    fn test_parse_order_status_report_expiration(
1586        #[case] raw: Option<&str>,
1587        #[case] expected: Option<UnixNanos>,
1588    ) {
1589        let order = PolymarketOpenOrder {
1590            associate_trades: None,
1591            id: "0xid".to_string(),
1592            status: PolymarketOrderStatus::Live,
1593            market: Ustr::from("0xm"),
1594            original_size: dec!(100),
1595            outcome: PolymarketOutcome::yes(),
1596            maker_address: "0xmaker".to_string(),
1597            owner: "owner".to_string(),
1598            price: dec!(0.5),
1599            side: PolymarketOrderSide::Buy,
1600            size_matched: dec!(0),
1601            asset_id: Ustr::from("token"),
1602            expiration: raw.map(|s| s.to_string()),
1603            order_type: PolymarketOrderType::GTD,
1604            created_at: 1_703_875_200,
1605        };
1606
1607        let report = parse_order_status_report(
1608            &order,
1609            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1610            AccountId::from("POLYMARKET-001"),
1611            None,
1612            4,
1613            6,
1614            UnixNanos::from(1_000_000_000u64),
1615        );
1616
1617        assert_eq!(report.expire_time, expected);
1618    }
1619
1620    #[rstest]
1621    fn test_parse_fill_report_errors_when_commission_is_unrepresentable() {
1622        let path = "test_data/http_trade_report.json";
1623        let content = std::fs::read_to_string(path).expect("Failed to read test data");
1624        let trade: PolymarketTradeReport =
1625            serde_json::from_str(&content).expect("Failed to parse test data");
1626
1627        let result = parse_fill_report(
1628            &trade,
1629            InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1630            AccountId::from("POLYMARKET-001"),
1631            None,
1632            4,
1633            6,
1634            Currency::pUSD(),
1635            // Large enough that the commission exceeds Money's fixed-point range, while the
1636            // Decimal arithmetic itself stays well inside its own limits
1637            Decimal::from_i128_with_scale(100_000_000_000_000_000_000_000_000i128, 0),
1638            1.0,
1639            UnixNanos::from(1_000_000_000u64),
1640        );
1641
1642        assert!(
1643            result.is_err(),
1644            "an unrepresentable commission must surface as an error rather than panicking"
1645        );
1646    }
1647
1648    #[rstest]
1649    fn test_parse_fill_report_from_fixture() {
1650        let path = "test_data/http_trade_report.json";
1651        let content = std::fs::read_to_string(path).expect("Failed to read test data");
1652        let trade: PolymarketTradeReport =
1653            serde_json::from_str(&content).expect("Failed to parse test data");
1654
1655        let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1656        let account_id = AccountId::from("POLYMARKET-001");
1657        let currency = Currency::pUSD();
1658
1659        let report = parse_fill_report(
1660            &trade,
1661            instrument_id,
1662            account_id,
1663            None,
1664            4,
1665            6,
1666            currency,
1667            Decimal::ZERO,
1668            1.0,
1669            UnixNanos::from(1_000_000_000u64),
1670        )
1671        .expect("fixture commission is representable");
1672
1673        assert_eq!(report.account_id, account_id);
1674        assert_eq!(report.instrument_id, instrument_id);
1675        assert_eq!(report.order_side, OrderSide::Buy);
1676        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1677        assert_eq!(report.commission.as_decimal(), dec!(0.0));
1678    }
1679
1680    #[rstest]
1681    fn test_parse_fill_report_forwards_fee_schedule() {
1682        let path = "test_data/http_trade_report.json";
1683        let content = std::fs::read_to_string(path).expect("Failed to read test data");
1684        let trade: PolymarketTradeReport =
1685            serde_json::from_str(&content).expect("Failed to parse test data");
1686
1687        let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1688        let account_id = AccountId::from("POLYMARKET-001");
1689        let currency = Currency::pUSD();
1690
1691        // Expected: 25 * 0.03 * (0.5 * 0.5)^2 = 0.04688 pUSD after rounding.
1692        let report = parse_fill_report(
1693            &trade,
1694            instrument_id,
1695            account_id,
1696            None,
1697            4,
1698            6,
1699            currency,
1700            dec!(0.03),
1701            2.0,
1702            UnixNanos::from(1_000_000_000u64),
1703        )
1704        .expect("fixture commission is representable");
1705
1706        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1707        assert_eq!(report.commission.as_decimal(), dec!(0.04688));
1708    }
1709
1710    #[rstest]
1711    fn test_instrument_taker_fee_reads_binary_option() {
1712        use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
1713
1714        let path = "test_data/gamma_market_sports_market_money_line.json";
1715        let content = std::fs::read_to_string(path).expect("Failed to read test data");
1716        let market = serde_json::from_str(&content).expect("Failed to parse test data");
1717        let defs = parse_gamma_market(&market).unwrap();
1718        let instrument =
1719            create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1720
1721        assert_eq!(instrument_taker_fee(&instrument), dec!(0.03));
1722        assert_eq!(instrument_fee_exponent(&instrument), 1.0);
1723    }
1724
1725    #[rstest]
1726    #[case(
1727        PolymarketLiquiditySide::Taker,
1728        PolymarketOrderSide::Buy,
1729        "token_a",
1730        "token_b",
1731        OrderSide::Buy
1732    )]
1733    #[case(
1734        PolymarketLiquiditySide::Taker,
1735        PolymarketOrderSide::Sell,
1736        "token_a",
1737        "token_b",
1738        OrderSide::Sell
1739    )]
1740    #[case(
1741        PolymarketLiquiditySide::Maker,
1742        PolymarketOrderSide::Buy,
1743        "token_a",
1744        "token_b",
1745        OrderSide::Buy
1746    )]
1747    #[case(
1748        PolymarketLiquiditySide::Maker,
1749        PolymarketOrderSide::Buy,
1750        "token_a",
1751        "token_a",
1752        OrderSide::Sell
1753    )]
1754    #[case(
1755        PolymarketLiquiditySide::Maker,
1756        PolymarketOrderSide::Sell,
1757        "token_a",
1758        "token_a",
1759        OrderSide::Buy
1760    )]
1761    fn test_determine_order_side(
1762        #[case] trader_side: PolymarketLiquiditySide,
1763        #[case] trade_side: PolymarketOrderSide,
1764        #[case] taker_asset: &str,
1765        #[case] maker_asset: &str,
1766        #[case] expected: OrderSide,
1767    ) {
1768        let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
1769        assert_eq!(result, expected);
1770    }
1771
1772    #[rstest]
1773    fn test_make_composite_trade_id_basic() {
1774        let trade_id = "trade-abc123";
1775        let venue_order_id = "order-xyz789";
1776        let result = make_composite_trade_id(trade_id, venue_order_id);
1777        assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
1778    }
1779
1780    #[rstest]
1781    fn test_make_composite_trade_id_truncates_long_ids() {
1782        let trade_id = "a]".repeat(30);
1783        let venue_order_id = "b".repeat(20);
1784        let result = make_composite_trade_id(&trade_id, &venue_order_id);
1785        assert!(result.as_str().len() <= 36);
1786    }
1787
1788    #[rstest]
1789    fn test_make_composite_trade_id_short_venue_id() {
1790        let trade_id = "t123";
1791        let venue_order_id = "ab";
1792        let result = make_composite_trade_id(trade_id, venue_order_id);
1793        assert_eq!(result.as_str(), "t123-ab");
1794    }
1795
1796    #[rstest]
1797    fn test_make_composite_trade_id_uniqueness() {
1798        let id_a = make_composite_trade_id("same-trade", "order-aaa");
1799        let id_b = make_composite_trade_id("same-trade", "order-bbb");
1800        assert_ne!(id_a, id_b);
1801    }
1802
1803    // Tests use various input orderings to prove the function sorts deterministically.
1804
1805    #[rstest]
1806    fn test_calculate_market_price_buy_single_level() {
1807        let levels = vec![ClobBookLevel {
1808            price: "0.55".to_string(),
1809            size: "200.0".to_string(),
1810        }];
1811        let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1812        assert_eq!(result.crossing_price, dec!(0.55));
1813        // 50 pUSD / 0.55 per share = ~90.909 shares
1814        assert!(result.expected_base_qty > dec!(90));
1815    }
1816
1817    #[rstest]
1818    fn test_calculate_market_price_buy_walks_multiple_levels() {
1819        // Asks in arbitrary order, function sorts ascending for BUY
1820        let levels = vec![
1821            ClobBookLevel {
1822                price: "0.55".to_string(),
1823                size: "100.0".to_string(),
1824            },
1825            ClobBookLevel {
1826                price: "0.50".to_string(),
1827                size: "10.0".to_string(),
1828            },
1829            ClobBookLevel {
1830                price: "0.60".to_string(),
1831                size: "200.0".to_string(),
1832            },
1833        ];
1834        // Sorted ascending: 0.50/10, 0.55/100, 0.60/200
1835        // Walk: 0.50/10 → 5 pUSD (10 shares), 0.55/100 → 15 pUSD (27.27 shares)
1836        let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
1837        assert_eq!(result.crossing_price, dec!(0.55));
1838        let expected = dec!(10) + dec!(15) / dec!(0.55);
1839        assert_eq!(result.expected_base_qty, expected);
1840    }
1841
1842    #[rstest]
1843    fn test_calculate_market_price_buy_small_order_uses_best_ask() {
1844        // Asks in mixed order, function sorts to find best (0.20) first
1845        let levels = vec![
1846            ClobBookLevel {
1847                price: "0.50".to_string(),
1848                size: "50.0".to_string(),
1849            },
1850            ClobBookLevel {
1851                price: "0.999".to_string(),
1852                size: "100.0".to_string(),
1853            },
1854            ClobBookLevel {
1855                price: "0.20".to_string(),
1856                size: "72.0".to_string(),
1857            },
1858        ];
1859        // Sorted ascending: 0.20/72, 0.50/50, 0.999/100
1860        // 5 pUSD at best ask 0.20: 72 * 0.20 = 14.4 pUSD available, fills entirely
1861        let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
1862        assert_eq!(result.crossing_price, dec!(0.20));
1863        assert_eq!(result.expected_base_qty, dec!(25)); // 5 / 0.20 = 25 shares
1864    }
1865
1866    #[rstest]
1867    fn test_calculate_market_price_sell_walks_levels() {
1868        // Bids in ascending order, function sorts descending for SELL (best bid first)
1869        let levels = vec![
1870            ClobBookLevel {
1871                price: "0.48".to_string(),
1872                size: "100.0".to_string(),
1873            },
1874            ClobBookLevel {
1875                price: "0.50".to_string(),
1876                size: "50.0".to_string(),
1877            },
1878        ];
1879        // Sorted descending: 0.50/50, 0.48/100
1880        // Walk: 0.50 gives 50, need 30 more from 0.48 → fills
1881        let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
1882        assert_eq!(result.crossing_price, dec!(0.48));
1883        assert_eq!(result.expected_base_qty, dec!(80));
1884    }
1885
1886    #[rstest]
1887    fn test_calculate_market_price_empty_book() {
1888        let levels: Vec<ClobBookLevel> = vec![];
1889        let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1890        assert!(result.is_err());
1891    }
1892
1893    #[rstest]
1894    fn test_calculate_market_price_all_zero_levels_returns_error() {
1895        let levels = vec![
1896            ClobBookLevel {
1897                price: "0".to_string(),
1898                size: "100.0".to_string(),
1899            },
1900            ClobBookLevel {
1901                price: "0.50".to_string(),
1902                size: "0".to_string(),
1903            },
1904        ];
1905        let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1906        assert!(result.is_err());
1907    }
1908
1909    #[rstest]
1910    fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
1911        let levels = vec![ClobBookLevel {
1912            price: "0.55".to_string(),
1913            size: "10.0".to_string(),
1914        }];
1915        // 10 * 0.55 = 5.5 pUSD < 50 pUSD needed, returns what's available
1916        let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1917        assert_eq!(result.crossing_price, dec!(0.55));
1918        assert_eq!(result.expected_base_qty, dec!(10)); // only 10 shares available
1919    }
1920
1921    #[rstest]
1922    fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
1923        let levels_ascending = vec![
1924            ClobBookLevel {
1925                price: "0.20".to_string(),
1926                size: "72.0".to_string(),
1927            },
1928            ClobBookLevel {
1929                price: "0.50".to_string(),
1930                size: "50.0".to_string(),
1931            },
1932            ClobBookLevel {
1933                price: "0.999".to_string(),
1934                size: "100.0".to_string(),
1935            },
1936        ];
1937        let levels_descending = vec![
1938            ClobBookLevel {
1939                price: "0.999".to_string(),
1940                size: "100.0".to_string(),
1941            },
1942            ClobBookLevel {
1943                price: "0.50".to_string(),
1944                size: "50.0".to_string(),
1945            },
1946            ClobBookLevel {
1947                price: "0.20".to_string(),
1948                size: "72.0".to_string(),
1949            },
1950        ];
1951        let levels_shuffled = vec![
1952            ClobBookLevel {
1953                price: "0.50".to_string(),
1954                size: "50.0".to_string(),
1955            },
1956            ClobBookLevel {
1957                price: "0.20".to_string(),
1958                size: "72.0".to_string(),
1959            },
1960            ClobBookLevel {
1961                price: "0.999".to_string(),
1962                size: "100.0".to_string(),
1963            },
1964        ];
1965
1966        let r1 =
1967            calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1968        let r2 =
1969            calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1970        let r3 =
1971            calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
1972
1973        assert_eq!(r1.crossing_price, r2.crossing_price);
1974        assert_eq!(r2.crossing_price, r3.crossing_price);
1975        assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1976        assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
1977    }
1978
1979    #[rstest]
1980    fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
1981        let levels_a = vec![
1982            ClobBookLevel {
1983                price: "0.48".to_string(),
1984                size: "100.0".to_string(),
1985            },
1986            ClobBookLevel {
1987                price: "0.50".to_string(),
1988                size: "50.0".to_string(),
1989            },
1990        ];
1991        let levels_b = vec![
1992            ClobBookLevel {
1993                price: "0.50".to_string(),
1994                size: "50.0".to_string(),
1995            },
1996            ClobBookLevel {
1997                price: "0.48".to_string(),
1998                size: "100.0".to_string(),
1999            },
2000        ];
2001
2002        let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
2003        let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
2004
2005        assert_eq!(r1.crossing_price, r2.crossing_price);
2006        assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2007    }
2008
2009    mod adjust_market_buy_amount_property_tests {
2010        use proptest::prelude::*;
2011        use rstest::rstest;
2012
2013        use super::*;
2014
2015        // Generate a Decimal in [1e-6, 1_000_000] at USDC scale by sampling
2016        // micro-units. Avoids zero so we never hit the truncate-to-zero error
2017        // path on the input itself.
2018        fn decimal_at_usdc_scale(micros: u64) -> Decimal {
2019            Decimal::new(micros as i64, USDC_DECIMALS)
2020        }
2021
2022        // Generate a Decimal rate from basis points: bps / 10_000.
2023        fn decimal_from_bps(bps: u32) -> Decimal {
2024            Decimal::new(i64::from(bps), 4)
2025        }
2026
2027        // Recomputes total_cost the same way `adjust_market_buy_amount` does so
2028        // tests use the same formula they're verifying (no weak re-derivation).
2029        fn compute_total_cost(
2030            amount: Decimal,
2031            price: Decimal,
2032            fee_rate: Decimal,
2033            fee_exponent: f64,
2034            builder: Decimal,
2035        ) -> Decimal {
2036            let base = price * (Decimal::ONE - price);
2037            let base_f64: f64 = base.try_into().unwrap_or(0.0);
2038            let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
2039            let platform_fee_rate = fee_rate * curve;
2040            let platform_fee = amount / price * platform_fee_rate;
2041            amount + platform_fee + amount * builder
2042        }
2043
2044        proptest! {
2045            // Deterministic over arbitrary valid inputs: same args produce
2046            // the same Result (Ok or Err) and equal Ok values.
2047            #[rstest]
2048            fn prop_adjust_market_buy_amount_is_deterministic(
2049                amount_micros in 1u64..=1_000_000_000_000u64,
2050                balance_micros in 1u64..=1_000_000_000_000u64,
2051                price_milli in 1u32..=999u32,
2052                fee_rate_bps in 0u32..=1_000u32,
2053                fee_exponent in 1.0f64..=3.0f64,
2054                builder_bps in 0u32..=500u32,
2055            ) {
2056                let amount = decimal_at_usdc_scale(amount_micros);
2057                let balance = decimal_at_usdc_scale(balance_micros);
2058                let price = Decimal::new(i64::from(price_milli), 3);
2059                let fee_rate = decimal_from_bps(fee_rate_bps);
2060                let builder = decimal_from_bps(builder_bps);
2061
2062                let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2063                let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2064                prop_assert_eq!(r1.is_ok(), r2.is_ok());
2065                if let (Ok(a), Ok(b)) = (r1, r2) {
2066                    prop_assert_eq!(a, b);
2067                }
2068            }
2069
2070            // Non-binding branch: balance is always large enough to cover
2071            // total_cost. Function MUST return Ok and the result MUST equal
2072            // the input amount (already at USDC scale). A regression that
2073            // bails on valid inputs would fail this property.
2074            #[rstest]
2075            fn prop_adjust_market_buy_amount_non_binding_returns_amount(
2076                amount_micros in 1u64..=1_000_000_000u64,
2077                price_milli in 1u32..=999u32,
2078                fee_rate_bps in 0u32..=1_000u32,
2079                fee_exponent in 1.0f64..=3.0f64,
2080                builder_bps in 0u32..=500u32,
2081            ) {
2082                let amount = decimal_at_usdc_scale(amount_micros);
2083                let price = Decimal::new(i64::from(price_milli), 3);
2084                let fee_rate = decimal_from_bps(fee_rate_bps);
2085                let builder = decimal_from_bps(builder_bps);
2086
2087                // Balance covers total_cost with margin. Use 10x as a generous
2088                // upper bound on cost-vs-amount even at extreme p, fee, and
2089                // builder values within the generator bounds.
2090                let total_cost =
2091                    compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2092                let balance = total_cost * Decimal::from(10);
2093
2094                let adjusted = adjust_market_buy_amount(
2095                    amount, balance, price, fee_rate, fee_exponent, builder,
2096                )
2097                .expect("non-binding balance must yield Ok");
2098                prop_assert_eq!(
2099                    adjusted, amount,
2100                    "non-binding branch must return the input amount unchanged",
2101                );
2102            }
2103
2104            // Binding branch: balance < total_cost(amount). Function MUST
2105            // return Ok (assuming the divisor produces something >= 1 micro)
2106            // and the result MUST be strictly less than amount, at USDC scale,
2107            // and total_cost(adjusted) MUST fit inside balance.
2108            #[rstest]
2109            fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
2110                amount_micros in 1_000u64..=1_000_000_000u64,
2111                price_milli in 10u32..=990u32,
2112                fee_rate_bps in 0u32..=1_000u32,
2113                fee_exponent in 1.0f64..=3.0f64,
2114                builder_bps in 0u32..=500u32,
2115                fraction_thousandths in 100u32..=900u32,
2116            ) {
2117                let amount = decimal_at_usdc_scale(amount_micros);
2118                let price = Decimal::new(i64::from(price_milli), 3);
2119                let fee_rate = decimal_from_bps(fee_rate_bps);
2120                let builder = decimal_from_bps(builder_bps);
2121
2122                // Balance set to a fraction (0.1 .. 0.9) of total_cost so the
2123                // shrink branch is always exercised with non-trivial values.
2124                let total_cost =
2125                    compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2126                let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
2127                let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
2128                if balance.is_zero() {
2129                    return Ok(()); // sub-micro balance hits the bail path; skip.
2130                }
2131
2132                let adjusted = adjust_market_buy_amount(
2133                    amount, balance, price, fee_rate, fee_exponent, builder,
2134                )
2135                .expect("non-zero balance fraction must yield Ok in binding branch");
2136
2137                prop_assert!(
2138                    adjusted < amount,
2139                    "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
2140                );
2141                prop_assert!(
2142                    adjusted > Decimal::ZERO,
2143                    "adjusted must be strictly positive",
2144                );
2145                prop_assert_eq!(
2146                    adjusted,
2147                    adjusted.trunc_with_scale(USDC_DECIMALS),
2148                    "adjusted must be at USDC_DECIMALS scale",
2149                );
2150                let recomputed_cost =
2151                    compute_total_cost(adjusted, price, fee_rate, fee_exponent, builder);
2152                prop_assert!(
2153                    recomputed_cost <= balance,
2154                    "total_cost {recomputed_cost} must fit balance {balance}",
2155                );
2156            }
2157
2158            // Truncation property: when the input amount has sub-USDC
2159            // precision (e.g. amount derived from f64 math elsewhere in the
2160            // pipeline), the result is rounded down to USDC scale, never up.
2161            #[rstest]
2162            fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
2163                amount_pico in 1_000_000u64..=1_000_000_000_000u64,
2164                price_milli in 1u32..=999u32,
2165                fee_rate_bps in 0u32..=1_000u32,
2166                fee_exponent in 1.0f64..=3.0f64,
2167                builder_bps in 0u32..=500u32,
2168            ) {
2169                // Sample at 9 dp (pico-USDC) so amounts have 3 dp beyond the
2170                // USDC on-chain scale.
2171                let amount = Decimal::new(amount_pico as i64, 9);
2172                let price = Decimal::new(i64::from(price_milli), 3);
2173                let fee_rate = decimal_from_bps(fee_rate_bps);
2174                let builder = decimal_from_bps(builder_bps);
2175
2176                // Non-binding so we exercise the trunc-on-amount path.
2177                let total_cost =
2178                    compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2179                let balance = total_cost * Decimal::from(10);
2180
2181                if let Ok(adjusted) = adjust_market_buy_amount(
2182                    amount, balance, price, fee_rate, fee_exponent, builder,
2183                ) {
2184                    prop_assert_eq!(
2185                        adjusted,
2186                        adjusted.trunc_with_scale(USDC_DECIMALS),
2187                        "result must be at USDC_DECIMALS scale",
2188                    );
2189                    prop_assert!(
2190                        adjusted <= amount,
2191                        "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
2192                    );
2193                }
2194            }
2195        }
2196    }
2197}