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