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