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