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