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