1use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::{
21 UUID4, UnixNanos,
22 datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
23};
24use nautilus_model::{
25 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
26 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
27 instruments::InstrumentAny,
28 reports::{FillReport, OrderStatusReport},
29 types::{AccountBalance, Currency, Money, Price, Quantity},
30};
31use rust_decimal::Decimal;
32use thiserror::Error;
33
34use crate::{
35 common::{
36 consts::{DUST_SNAP_THRESHOLD_DEC, USDC_DECIMALS},
37 enums::{
38 PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
39 PolymarketOrderStatus,
40 },
41 models::PolymarketMakerOrder,
42 parse::parse_decimal_exact,
43 },
44 http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
45};
46
47pub const fn parse_liquidity_side(side: PolymarketLiquiditySide) -> LiquiditySide {
49 match side {
50 PolymarketLiquiditySide::Maker => LiquiditySide::Maker,
51 PolymarketLiquiditySide::Taker => LiquiditySide::Taker,
52 }
53}
54
55pub fn resolve_order_status(
60 status: PolymarketOrderStatus,
61 event_type: PolymarketEventType,
62) -> OrderStatus {
63 if status == PolymarketOrderStatus::Invalid && event_type == PolymarketEventType::Cancellation {
64 OrderStatus::Canceled
65 } else {
66 OrderStatus::from(status)
67 }
68}
69
70pub fn determine_order_side(
77 trader_side: PolymarketLiquiditySide,
78 trade_side: PolymarketOrderSide,
79 taker_asset_id: &str,
80 maker_asset_id: &str,
81) -> OrderSide {
82 let order_side = OrderSide::from(trade_side);
83
84 if trader_side == PolymarketLiquiditySide::Taker {
85 return order_side;
86 }
87
88 let is_cross_asset = maker_asset_id != taker_asset_id;
89
90 if is_cross_asset {
91 order_side
92 } else {
93 match order_side {
94 OrderSide::Buy => OrderSide::Sell,
95 OrderSide::Sell => OrderSide::Buy,
96 }
97 }
98}
99
100pub fn make_composite_trade_id(trade_id: &str, venue_order_id: &str) -> TradeId {
108 TradeId::from(composite_trade_id_value(trade_id, venue_order_id).as_str())
109}
110
111pub(super) fn composite_trade_id_value(trade_id: &str, venue_order_id: &str) -> String {
112 let prefix_len = trade_id.len().min(27);
113 let suffix_len = venue_order_id.len().min(8);
114 let suffix_start = venue_order_id.len().saturating_sub(suffix_len);
115 format!(
116 "{}-{}",
117 &trade_id[..prefix_len],
118 &venue_order_id[suffix_start..]
119 )
120}
121
122pub fn parse_order_status_report(
128 order: &PolymarketOpenOrder,
129 instrument_id: InstrumentId,
130 account_id: AccountId,
131 client_order_id: Option<ClientOrderId>,
132 price_precision: u8,
133 size_precision: u8,
134 ts_init: UnixNanos,
135) -> anyhow::Result<OrderStatusReport> {
136 let expire_time = order
137 .expiration
138 .as_deref()
139 .and_then(parse_expiration_nanos)
140 .map(UnixNanos::from);
141 parse_validated_order_status_report(
142 order,
143 OrderReportParseContext {
144 instrument_id,
145 account_id,
146 client_order_id,
147 venue_order_id: VenueOrderId::from(order.id.as_str()),
148 price_precision,
149 size_precision,
150 ts_accepted: UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND),
151 expire_time,
152 ts_init,
153 },
154 )
155}
156
157#[derive(Clone, Copy, Debug)]
158pub(super) struct OrderReportParseContext {
159 pub instrument_id: InstrumentId,
160 pub account_id: AccountId,
161 pub client_order_id: Option<ClientOrderId>,
162 pub venue_order_id: VenueOrderId,
163 pub price_precision: u8,
164 pub size_precision: u8,
165 pub ts_accepted: UnixNanos,
166 pub expire_time: Option<UnixNanos>,
167 pub ts_init: UnixNanos,
168}
169
170pub(super) fn parse_validated_order_status_report(
171 order: &PolymarketOpenOrder,
172 ctx: OrderReportParseContext,
173) -> anyhow::Result<OrderStatusReport> {
174 let order_side = OrderSide::from(order.side);
175 let time_in_force = TimeInForce::from(order.order_type);
176 let quantity = Quantity::from_decimal_dp(order.original_size, ctx.size_precision)?;
177 let raw_filled_qty = Quantity::from_decimal_dp(order.size_matched, ctx.size_precision)?;
178 let order_status = if order.status == PolymarketOrderStatus::Matched {
180 recovered_terminal_order_status(time_in_force, quantity, raw_filled_qty)
181 } else {
182 OrderStatus::from(order.status)
183 };
184 let filled_qty = snap_filled_qty_to_quantity(quantity, raw_filled_qty, order_status);
185 let price = Price::from_decimal_dp(order.price, ctx.price_precision)?;
186
187 let mut report = OrderStatusReport::new(
188 ctx.account_id,
189 ctx.instrument_id,
190 ctx.client_order_id,
191 ctx.venue_order_id,
192 order_side.into(),
193 OrderType::Limit,
194 time_in_force,
195 order_status,
196 quantity,
197 filled_qty,
198 ctx.ts_accepted,
199 ctx.ts_accepted, ctx.ts_init,
201 None, );
203 report.price = Some(price);
204 report.expire_time = ctx.expire_time;
205 Ok(report)
206}
207
208pub(super) fn parse_expiration_nanos(value: &str) -> Option<u64> {
213 let secs: u64 = value.parse().ok()?;
214 if secs == 0 {
215 return None;
216 }
217 secs.checked_mul(NANOSECONDS_IN_SECOND)
218}
219
220#[expect(clippy::too_many_arguments)]
237pub fn parse_fill_report(
238 trade: &PolymarketTradeReport,
239 instrument_id: InstrumentId,
240 account_id: AccountId,
241 client_order_id: Option<ClientOrderId>,
242 price_precision: u8,
243 size_precision: u8,
244 currency: Currency,
245 taker_fee_rate: Decimal,
246 fee_exponent: Decimal,
247 ts_init: UnixNanos,
248) -> anyhow::Result<FillReport> {
249 parse_validated_fill_report(
250 trade,
251 TakerFillParseContext {
252 instrument_id,
253 account_id,
254 client_order_id,
255 venue_order_id: VenueOrderId::from(trade.taker_order_id.as_str()),
256 trade_id: TradeId::from(trade.id.as_str()),
257 price_precision,
258 size_precision,
259 currency,
260 taker_fee_rate,
261 fee_exponent,
262 ts_event: parse_timestamp(&trade.match_time).unwrap_or(ts_init),
263 ts_init,
264 },
265 )
266}
267
268#[derive(Clone, Copy, Debug)]
269pub(super) struct TakerFillParseContext {
270 pub instrument_id: InstrumentId,
271 pub account_id: AccountId,
272 pub client_order_id: Option<ClientOrderId>,
273 pub venue_order_id: VenueOrderId,
274 pub trade_id: TradeId,
275 pub price_precision: u8,
276 pub size_precision: u8,
277 pub currency: Currency,
278 pub taker_fee_rate: Decimal,
279 pub fee_exponent: Decimal,
280 pub ts_event: UnixNanos,
281 pub ts_init: UnixNanos,
282}
283
284pub(super) fn parse_validated_fill_report(
285 trade: &PolymarketTradeReport,
286 ctx: TakerFillParseContext,
287) -> anyhow::Result<FillReport> {
288 let order_side = OrderSide::from(trade.side);
289 let last_qty = Quantity::from_decimal_dp(trade.size, ctx.size_precision)?;
290 let last_px = Price::from_decimal_dp(trade.price, ctx.price_precision)?;
291 let liquidity_side = parse_liquidity_side(trade.trader_side);
292
293 let commission_value = compute_commission(
294 ctx.taker_fee_rate,
295 ctx.fee_exponent,
296 trade.size,
297 trade.price,
298 liquidity_side,
299 )?;
300 let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
301 format!(
302 "failed to represent commission {commission_value} for {} as Money",
303 ctx.instrument_id
304 )
305 })?;
306
307 Ok(FillReport {
308 account_id: ctx.account_id,
309 instrument_id: ctx.instrument_id,
310 venue_order_id: ctx.venue_order_id,
311 trade_id: ctx.trade_id,
312 order_side,
313 last_qty,
314 last_px,
315 commission,
316 liquidity_side,
317 avg_px: None,
318 report_id: UUID4::new(),
319 ts_event: ctx.ts_event,
320 ts_init: ctx.ts_init,
321 client_order_id: ctx.client_order_id,
322 venue_position_id: None,
323 })
324}
325
326#[expect(clippy::too_many_arguments)]
343pub fn build_maker_fill_report(
344 mo: &PolymarketMakerOrder,
345 trade_id: &str,
346 trader_side: PolymarketLiquiditySide,
347 trade_side: PolymarketOrderSide,
348 taker_asset_id: &str,
349 account_id: AccountId,
350 instrument_id: InstrumentId,
351 price_precision: u8,
352 size_precision: u8,
353 currency: Currency,
354 liquidity_side: LiquiditySide,
355 ts_event: UnixNanos,
356 ts_init: UnixNanos,
357) -> anyhow::Result<FillReport> {
358 parse_validated_maker_fill_report(
359 mo,
360 trader_side,
361 trade_side,
362 taker_asset_id,
363 MakerFillParseContext {
364 account_id,
365 instrument_id,
366 venue_order_id: VenueOrderId::from(mo.order_id.as_str()),
367 trade_id: make_composite_trade_id(trade_id, &mo.order_id),
368 price_precision,
369 size_precision,
370 currency,
371 liquidity_side,
372 ts_event,
373 ts_init,
374 },
375 )
376}
377
378#[derive(Clone, Copy, Debug)]
379pub(super) struct MakerFillParseContext {
380 pub account_id: AccountId,
381 pub instrument_id: InstrumentId,
382 pub venue_order_id: VenueOrderId,
383 pub trade_id: TradeId,
384 pub price_precision: u8,
385 pub size_precision: u8,
386 pub currency: Currency,
387 pub liquidity_side: LiquiditySide,
388 pub ts_event: UnixNanos,
389 pub ts_init: UnixNanos,
390}
391
392pub(super) fn parse_validated_maker_fill_report(
393 mo: &PolymarketMakerOrder,
394 trader_side: PolymarketLiquiditySide,
395 trade_side: PolymarketOrderSide,
396 taker_asset_id: &str,
397 ctx: MakerFillParseContext,
398) -> anyhow::Result<FillReport> {
399 let order_side = determine_order_side(
400 trader_side,
401 trade_side,
402 taker_asset_id,
403 mo.asset_id.as_str(),
404 );
405 let last_qty = Quantity::from_decimal_dp(mo.matched_amount, ctx.size_precision)?;
406 let last_px = Price::from_decimal_dp(mo.price, ctx.price_precision)?;
407 let commission_value = compute_commission(
408 Decimal::ZERO,
409 Decimal::ONE,
410 mo.matched_amount,
411 mo.price,
412 ctx.liquidity_side,
413 )?;
414 let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
415 format!(
416 "failed to represent commission {commission_value} for {} as Money",
417 ctx.instrument_id
418 )
419 })?;
420
421 Ok(FillReport {
422 account_id: ctx.account_id,
423 instrument_id: ctx.instrument_id,
424 venue_order_id: ctx.venue_order_id,
425 trade_id: ctx.trade_id,
426 order_side,
427 last_qty,
428 last_px,
429 commission,
430 liquidity_side: ctx.liquidity_side,
431 avg_px: None,
432 report_id: UUID4::new(),
433 ts_event: ctx.ts_event,
434 ts_init: ctx.ts_init,
435 client_order_id: None,
436 venue_position_id: None,
437 })
438}
439
440#[must_use]
446pub fn instrument_taker_fee(instrument: &InstrumentAny) -> Decimal {
447 match instrument {
448 InstrumentAny::BinaryOption(bo) => bo.taker_fee,
449 _ => Decimal::ZERO,
450 }
451}
452
453pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> anyhow::Result<Decimal> {
463 let value = match instrument {
464 InstrumentAny::BinaryOption(bo) => {
465 bo.info.as_ref().and_then(|info| info.get("fee_schedule"))
466 }
467 _ => None,
468 };
469 let Some(schedule) = value else {
470 return Ok(Decimal::ONE);
471 };
472 let value = schedule
473 .get("exponent")
474 .context("fee schedule is missing exponent")?;
475 let exponent = match value {
476 serde_json::Value::String(value) => parse_decimal_exact(value)?,
477 serde_json::Value::Number(value) => parse_decimal_exact(&value.to_string())?,
478 _ => anyhow::bail!("fee exponent must be a decimal number or numeric string"),
479 };
480 anyhow::ensure!(
481 exponent >= Decimal::ZERO,
482 "fee exponent must be non-negative"
483 );
484 Ok(exponent)
485}
486
487pub fn adjust_market_buy_amount(
510 amount: Decimal,
511 user_pusd_balance: Decimal,
512 price: Decimal,
513 fee_rate: Decimal,
514 fee_exponent: Decimal,
515 builder_taker_fee_rate: Decimal,
516) -> anyhow::Result<Decimal> {
517 if price <= Decimal::ZERO || price >= Decimal::ONE {
518 anyhow::bail!(
519 "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
520 );
521 }
522
523 let platform_fee_rate = fee_curve_rate(fee_rate, price, fee_exponent)?;
524
525 anyhow::ensure!(amount > Decimal::ZERO, "market-buy amount must be positive");
526 anyhow::ensure!(
527 user_pusd_balance > Decimal::ZERO,
528 "market-buy balance must be positive"
529 );
530 anyhow::ensure!(
531 builder_taker_fee_rate >= Decimal::ZERO,
532 "builder fee rate must be non-negative"
533 );
534 let platform_fee = amount
535 .checked_div(price)
536 .and_then(|shares| shares.checked_mul(platform_fee_rate))
537 .context("market-buy platform fee overflow")?;
538 let builder_fee = amount
539 .checked_mul(builder_taker_fee_rate)
540 .context("market-buy builder fee overflow")?;
541 let total_cost = amount
542 .checked_add(platform_fee)
543 .and_then(|cost| cost.checked_add(builder_fee))
544 .context("market-buy total cost overflow")?;
545
546 let raw = if user_pusd_balance <= total_cost {
547 let divisor = platform_fee_rate
548 .checked_div(price)
549 .and_then(|rate| Decimal::ONE.checked_add(rate))
550 .and_then(|rate| rate.checked_add(builder_taker_fee_rate))
551 .context("market-buy fee divisor overflow")?;
552 user_pusd_balance
553 .checked_div(divisor)
554 .context("market-buy adjustment overflow")?
555 } else {
556 amount
557 };
558
559 let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
560 if adjusted.is_zero() {
561 anyhow::bail!(
562 "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
563 fee-adjusted amount truncated to zero"
564 );
565 }
566 Ok(adjusted)
567}
568
569pub fn compute_commission(
588 fee_rate: Decimal,
589 fee_exponent: Decimal,
590 size: Decimal,
591 price: Decimal,
592 liquidity_side: LiquiditySide,
593) -> anyhow::Result<Decimal> {
594 anyhow::ensure!(size >= Decimal::ZERO, "fee quantity must be non-negative");
595 let rate = fee_curve_rate(fee_rate, price, fee_exponent)?;
596
597 if liquidity_side != LiquiditySide::Taker {
598 return Ok(Decimal::ZERO);
599 }
600 let commission = size
601 .checked_mul(rate)
602 .context("commission calculation overflow")?;
603 Ok(commission.round_dp(5))
604}
605
606fn fee_curve_rate(
607 fee_rate: Decimal,
608 price: Decimal,
609 fee_exponent: Decimal,
610) -> anyhow::Result<Decimal> {
611 anyhow::ensure!(fee_rate >= Decimal::ZERO, "fee rate must be non-negative");
612 anyhow::ensure!(
613 fee_exponent >= Decimal::ZERO,
614 "fee exponent must be non-negative"
615 );
616 anyhow::ensure!(
617 (Decimal::ZERO..=Decimal::ONE).contains(&price),
618 "fee price must be in [0, 1]"
619 );
620
621 if fee_rate.is_zero() {
622 return Ok(Decimal::ZERO);
623 }
624 let base = price * (Decimal::ONE - price);
625 let base_f64: f64 = base
626 .try_into()
627 .context("fee curve base is not representable")?;
628 let exponent_f64: f64 = fee_exponent
629 .try_into()
630 .context("fee exponent is not representable")?;
631 let curve =
632 Decimal::try_from(base_f64.powf(exponent_f64)).context("fee curve is not representable")?;
633 fee_rate
634 .checked_mul(curve)
635 .context("fee curve calculation overflow")
636}
637
638pub(crate) fn sum_filled_quantity(fills: &[FillReport]) -> Decimal {
640 fills.iter().map(|f| f.last_qty.as_decimal()).sum()
641}
642
643pub(crate) fn weighted_average_price(
646 fills: &[FillReport],
647 total_filled: Decimal,
648) -> Option<Decimal> {
649 if total_filled.is_zero() {
650 return None;
651 }
652 let weighted: Decimal = fills
653 .iter()
654 .map(|f| f.last_qty.as_decimal() * f.last_px.as_decimal())
655 .sum();
656 Some(weighted / total_filled)
657}
658
659pub(crate) fn recovered_terminal_order_status(
664 time_in_force: TimeInForce,
665 quantity: Quantity,
666 filled_qty: Quantity,
667) -> OrderStatus {
668 if time_in_force == TimeInForce::Ioc && filled_qty < quantity {
669 return OrderStatus::Canceled;
670 }
671
672 let dust_diff = (quantity.as_decimal() - filled_qty.as_decimal()).abs();
673 if filled_qty >= quantity || dust_diff < DUST_SNAP_THRESHOLD_DEC {
674 OrderStatus::Filled
675 } else {
676 OrderStatus::Canceled
677 }
678}
679
680pub(crate) fn snap_filled_qty_to_quantity(
688 quantity: Quantity,
689 filled_qty: Quantity,
690 order_status: OrderStatus,
691) -> Quantity {
692 if order_status != OrderStatus::Filled {
693 return filled_qty;
694 }
695 let diff = quantity.as_decimal() - filled_qty.as_decimal();
696 if !diff.is_zero() && diff.abs() < DUST_SNAP_THRESHOLD_DEC {
697 quantity
698 } else {
699 filled_qty
700 }
701}
702
703const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
705
706pub fn parse_balance_allowance(
712 balance_raw: Decimal,
713 currency: Currency,
714) -> anyhow::Result<AccountBalance> {
715 let balance_pusd = balance_raw / USDC_SCALE;
716 AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
717 .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
718}
719
720#[derive(Debug)]
722pub struct MarketPriceResult {
723 pub crossing_price: Decimal,
725 pub expected_base_qty: Decimal,
727}
728
729pub fn calculate_market_price(
744 book_levels: &[ClobBookLevel],
745 amount: Decimal,
746 side: PolymarketOrderSide,
747) -> anyhow::Result<MarketPriceResult> {
748 if book_levels.is_empty() {
749 anyhow::bail!("Empty order book: no liquidity available for market order");
750 }
751
752 anyhow::ensure!(amount > Decimal::ZERO, "market amount must be positive");
755 let mut parsed_levels = Vec::with_capacity(book_levels.len());
756 for level in book_levels {
757 let price = parse_decimal_exact(&level.price).context("invalid market-book price")?;
758 let size = parse_decimal_exact(&level.size).context("invalid market-book size")?;
759 anyhow::ensure!(
760 price > Decimal::ZERO && price < Decimal::ONE,
761 InvalidMarketPriceError("market-book price must be in (0, 1)".to_string())
762 );
763 anyhow::ensure!(
764 size >= Decimal::ZERO,
765 "market-book size must be non-negative"
766 );
767
768 if !size.is_zero() {
769 parsed_levels.push((price, size));
770 }
771 }
772
773 if parsed_levels.is_empty() {
774 anyhow::bail!("Empty order book: no valid price levels for market order");
775 }
776
777 match side {
778 PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
779 PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
780 }
781
782 let mut remaining = amount;
783 let mut last_price = Decimal::ZERO;
784 let mut total_base_qty = Decimal::ZERO;
785
786 for &(price, size) in &parsed_levels {
787 last_price = price;
788
789 match side {
790 PolymarketOrderSide::Buy => {
791 let level_usdc = size
792 .checked_mul(price)
793 .context("market-book notional overflow")?;
794 let consumed_usdc = level_usdc.min(remaining);
795 let shares_at_level = consumed_usdc
796 .checked_div(price)
797 .context("market-book quantity overflow")?;
798 total_base_qty = total_base_qty
799 .checked_add(shares_at_level)
800 .context("market-book quantity overflow")?;
801 remaining -= consumed_usdc;
802 }
803 PolymarketOrderSide::Sell => {
804 let consumed_shares = size.min(remaining);
805 total_base_qty = total_base_qty
806 .checked_add(consumed_shares)
807 .context("market-book quantity overflow")?;
808 remaining -= consumed_shares;
809 }
810 }
811
812 if remaining <= Decimal::ZERO {
813 return Ok(MarketPriceResult {
814 crossing_price: last_price,
815 expected_base_qty: total_base_qty,
816 });
817 }
818 }
819
820 Ok(MarketPriceResult {
823 crossing_price: last_price,
824 expected_base_qty: total_base_qty,
825 })
826}
827
828#[derive(Debug, Error)]
829#[error("{0}")]
830pub(crate) struct InvalidMarketPriceError(pub(super) String);
831
832pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
837 if let Ok(n) = ts_str.parse::<u64>() {
838 return if n > 1_000_000_000_000 {
839 n.checked_mul(NANOSECONDS_IN_MILLISECOND)
840 .map(UnixNanos::from)
841 } else {
842 n.checked_mul(NANOSECONDS_IN_SECOND).map(UnixNanos::from)
843 };
844 }
845 let dt = ts_str.parse::<Timestamp>().ok()?;
846 Some(UnixNanos::from(u64::try_from(dt.as_nanosecond()).ok()?))
847}
848
849#[cfg(test)]
850mod tests {
851 use nautilus_execution::models::fee::{FeeModel, ProbabilityPriceFeeModel};
852 use nautilus_model::{
853 enums::{OrderSide, OrderType},
854 instruments::{Instrument, InstrumentAny, stubs::binary_option},
855 orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
856 };
857 use rstest::rstest;
858 use rust_decimal_macros::dec;
859 use ustr::Ustr;
860
861 use super::*;
862 use crate::common::enums::{
863 PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
864 };
865
866 #[rstest]
870 #[case::filled_underfill_dust(100.000000, 99.995000, OrderStatus::Filled, 100.000000)]
872 #[case::filled_overfill_dust(714.285710, 714.285714, OrderStatus::Filled, 714.285710)]
874 #[case::filled_underfill_at_band(100.000000, 99.990000, OrderStatus::Filled, 99.990000)]
876 #[case::filled_underfill_above_band(100.000000, 99.000000, OrderStatus::Filled, 99.000000)]
878 #[case::filled_exact(100.000000, 100.000000, OrderStatus::Filled, 100.000000)]
880 #[case::accepted_underfill_dust(100.000000, 99.995000, OrderStatus::Accepted, 99.995000)]
882 #[case::canceled_underfill_dust(100.000000, 99.995000, OrderStatus::Canceled, 99.995000)]
884 fn test_snap_filled_qty_to_quantity(
885 #[case] quantity: f64,
886 #[case] filled: f64,
887 #[case] status: OrderStatus,
888 #[case] expected: f64,
889 ) {
890 let snapped = snap_filled_qty_to_quantity(
891 Quantity::new(quantity, 6),
892 Quantity::new(filled, 6),
893 status,
894 );
895 assert_eq!(snapped, Quantity::new(expected, 6));
896 }
897
898 fn make_test_fill(qty: f64, px: f64) -> FillReport {
899 FillReport::new(
900 AccountId::from("POLY-001"),
901 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
902 VenueOrderId::from("0xabc"),
903 TradeId::from("trade-1"),
904 OrderSide::Buy,
905 Quantity::new(qty, 4),
906 Price::new(px, 4),
907 Money::zero(Currency::pUSD()),
908 LiquiditySide::Taker,
909 None,
910 None,
911 UnixNanos::default(),
912 UnixNanos::default(),
913 None,
914 )
915 }
916
917 fn binary_option_fill_order(
918 instrument: &InstrumentAny,
919 liquidity_side: LiquiditySide,
920 price: &str,
921 ) -> OrderAny {
922 let limit_order = OrderTestBuilder::new(OrderType::Limit)
923 .instrument_id(instrument.id())
924 .side(OrderSide::Buy)
925 .price(Price::from(price))
926 .quantity(Quantity::from("100.00"))
927 .build();
928
929 TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
930 }
931
932 #[rstest]
933 fn test_sum_filled_quantity_empty() {
934 assert_eq!(sum_filled_quantity(&[]), Decimal::ZERO);
935 }
936
937 #[rstest]
938 fn test_sum_filled_quantity_multiple() {
939 let fills = vec![
940 make_test_fill(2.5, 0.50),
941 make_test_fill(1.0, 0.60),
942 make_test_fill(3.0, 0.55),
943 ];
944 assert_eq!(sum_filled_quantity(&fills), dec!(6.5));
945 }
946
947 #[rstest]
948 fn test_weighted_average_price_zero_total_returns_none() {
949 assert!(weighted_average_price(&[], Decimal::ZERO).is_none());
950 }
951
952 #[rstest]
953 fn test_weighted_average_price_single_fill() {
954 let fills = vec![make_test_fill(10.0, 0.5)];
955 let total = sum_filled_quantity(&fills);
956 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.5)));
957 }
958
959 #[rstest]
960 fn test_weighted_average_price_weighted_by_quantity() {
961 let fills = vec![make_test_fill(2.0, 0.40), make_test_fill(8.0, 0.60)];
963 let total = sum_filled_quantity(&fills);
964 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.56)));
965 }
966
967 #[rstest]
968 #[case(dec!(20_000_000), dec!(20))] #[case(dec!(1_000_000), dec!(1))] #[case(dec!(500_000), dec!(0.5))] #[case(dec!(0), dec!(0))]
972 #[case(dec!(123_456_789), dec!(123.456789))]
973 #[case(dec!(12345678901123456), dec!(12345678901.123456))]
974 fn test_parse_balance_allowance(#[case] raw: Decimal, #[case] expected: Decimal) {
975 let currency = Currency::pUSD();
976 let balance = parse_balance_allowance(raw, currency).unwrap();
977 assert_eq!(
978 balance.total,
979 Money::from_decimal(expected, currency).unwrap()
980 );
981 assert_eq!(balance.free, balance.total);
982 assert_eq!(
983 balance.locked,
984 Money::from_decimal(Decimal::ZERO, currency).unwrap()
985 );
986 }
987
988 #[rstest]
989 #[case::crypto_p50("0.07", "0.50", dec!(1.75))]
990 #[case::crypto_p01("0.07", "0.01", dec!(0.0693))]
991 #[case::crypto_p05("0.07", "0.05", dec!(0.3325))]
992 #[case::crypto_p10("0.07", "0.10", dec!(0.63))]
993 #[case::crypto_p30("0.07", "0.30", dec!(1.47))]
994 #[case::crypto_p70("0.07", "0.70", dec!(1.47))]
995 #[case::crypto_p90("0.07", "0.90", dec!(0.63))]
996 #[case::crypto_p99("0.07", "0.99", dec!(0.0693))]
997 #[case::sports_p50("0.05", "0.50", dec!(1.25))]
998 #[case::sports_p30("0.05", "0.30", dec!(1.05))]
999 #[case::sports_p70("0.05", "0.70", dec!(1.05))]
1000 #[case::politics_p50("0.04", "0.50", dec!(1.0))]
1001 #[case::politics_p30("0.04", "0.30", dec!(0.84))]
1002 #[case::economics_p50("0.05", "0.50", dec!(1.25))]
1003 #[case::economics_p30("0.05", "0.30", dec!(1.05))]
1004 #[case::geopolitics_p50("0", "0.50", dec!(0.0))]
1005 fn test_compute_commission_docs_table(
1006 #[case] fee_rate: &str,
1007 #[case] price: &str,
1008 #[case] expected: Decimal,
1009 ) {
1010 let commission = compute_commission(
1011 Decimal::from_str_exact(fee_rate).unwrap(),
1012 dec!(1),
1013 dec!(100),
1014 Decimal::from_str_exact(price).unwrap(),
1015 LiquiditySide::Taker,
1016 )
1017 .unwrap();
1018 assert_eq!(commission, expected);
1019 }
1020
1021 #[rstest]
1022 fn test_compute_commission_issue_3860_strategy_buy() {
1023 let commission = compute_commission(
1027 dec!(0.072),
1028 dec!(1),
1029 Decimal::from_str_exact("15.463900").unwrap(),
1030 dec!(0.97),
1031 LiquiditySide::Taker,
1032 )
1033 .unwrap();
1034 assert_eq!(commission, dec!(0.03240));
1035 }
1036
1037 #[rstest]
1038 fn test_compute_commission_issue_3860_reconciliation_sell() {
1039 let commission = compute_commission(
1044 dec!(0.072),
1045 dec!(1),
1046 Decimal::from_str_exact("0.033400").unwrap(),
1047 dec!(0.98),
1048 LiquiditySide::Taker,
1049 )
1050 .unwrap();
1051 assert_eq!(commission, dec!(0.00005));
1052 }
1053
1054 #[rstest]
1055 fn test_compute_commission_maker_is_zero() {
1056 let commission = compute_commission(
1057 Decimal::from_str_exact("0.072").unwrap(),
1058 dec!(1),
1059 dec!(100),
1060 Decimal::from_str_exact("0.50").unwrap(),
1061 LiquiditySide::Maker,
1062 )
1063 .unwrap();
1064 assert_eq!(commission, dec!(0));
1065 }
1066
1067 #[rstest]
1068 fn test_compute_commission_uses_fee_exponent() {
1069 let commission = compute_commission(
1070 dec!(0.04),
1071 dec!(2),
1072 dec!(10),
1073 dec!(0.5),
1074 LiquiditySide::Taker,
1075 )
1076 .unwrap();
1077 assert_eq!(commission, dec!(0.025));
1078 }
1079
1080 #[rstest]
1081 #[case::crypto_taker("0.07", "0.970", LiquiditySide::Taker)]
1082 #[case::sports_taker("0.05", "0.500", LiquiditySide::Taker)]
1083 #[case::politics_taker("0.04", "0.300", LiquiditySide::Taker)]
1084 #[case::maker_zero("0.03", "0.500", LiquiditySide::Maker)]
1085 fn test_probability_price_fee_model_matches_polymarket_commission(
1086 #[case] taker_fee: &str,
1087 #[case] price: &str,
1088 #[case] liquidity_side: LiquiditySide,
1089 ) {
1090 let mut binary = binary_option();
1091 binary.maker_fee = Decimal::ZERO;
1092 binary.taker_fee = Decimal::from_str_exact(taker_fee).unwrap();
1093 let instrument = InstrumentAny::BinaryOption(binary);
1094 let order = binary_option_fill_order(&instrument, liquidity_side, price);
1095 let fee_model = ProbabilityPriceFeeModel;
1096
1097 let commission = fee_model
1098 .get_commission(
1099 &order,
1100 Quantity::from("100.00"),
1101 Price::from(price),
1102 &instrument,
1103 )
1104 .unwrap();
1105
1106 let expected = compute_commission(
1107 Decimal::from_str_exact(taker_fee).unwrap(),
1108 dec!(1),
1109 dec!(100),
1110 Decimal::from_str_exact(price).unwrap(),
1111 liquidity_side,
1112 )
1113 .unwrap();
1114
1115 assert_eq!(commission.as_decimal(), expected);
1116 }
1117
1118 #[rstest]
1129 fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
1130 let adjusted =
1134 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), dec!(1), dec!(0))
1135 .unwrap();
1136 assert_eq!(adjusted, dec!(10.000000));
1137 }
1138
1139 #[rstest]
1140 fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
1141 let adjusted = adjust_market_buy_amount(
1145 dec!(10),
1146 dec!(10.2),
1147 dec!(0.5),
1148 dec!(0.04),
1149 dec!(1),
1150 dec!(0),
1151 )
1152 .unwrap();
1153 assert_eq!(adjusted, dec!(10.000000));
1155 }
1156
1157 #[rstest]
1158 fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
1159 let adjusted =
1163 adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), dec!(1), dec!(0))
1164 .unwrap();
1165 assert_eq!(adjusted, dec!(5.000000));
1166 }
1167
1168 #[rstest]
1169 fn test_adjust_market_buy_amount_with_builder_fee() {
1170 let adjusted = adjust_market_buy_amount(
1176 dec!(10),
1177 dec!(10),
1178 dec!(0.5),
1179 dec!(0.04),
1180 dec!(1),
1181 dec!(0.001),
1182 )
1183 .unwrap();
1184 assert_eq!(adjusted, dec!(9.794319));
1185 }
1186
1187 #[rstest]
1188 fn test_adjust_market_buy_amount_crypto_fee_rate() {
1189 let adjusted = adjust_market_buy_amount(
1195 dec!(100),
1196 dec!(100),
1197 dec!(0.5),
1198 dec!(0.07),
1199 dec!(1),
1200 dec!(0),
1201 )
1202 .unwrap();
1203 assert_eq!(adjusted, dec!(96.618357));
1205 }
1206
1207 #[rstest]
1208 fn test_adjust_market_buy_amount_extreme_low_price() {
1209 let adjusted = adjust_market_buy_amount(
1216 dec!(10),
1217 dec!(10),
1218 dec!(0.001),
1219 dec!(0.04),
1220 dec!(1),
1221 dec!(0),
1222 )
1223 .unwrap();
1224 let expected = dec!(9.615755);
1227 assert!(
1228 (adjusted - expected).abs() < dec!(0.00001),
1229 "expected ~{expected}, was {adjusted}",
1230 );
1231 }
1232
1233 #[rstest]
1234 fn test_adjust_market_buy_amount_integer_exponent_two() {
1235 let adjusted =
1242 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), dec!(2), dec!(0))
1243 .unwrap();
1244 assert!(
1245 (adjusted - dec!(9.950248)).abs() < dec!(0.00001),
1246 "expected ~9.950248, was {adjusted}",
1247 );
1248 }
1249
1250 #[rstest]
1251 fn test_adjust_market_buy_amount_fractional_exponent() {
1252 let adjusted = adjust_market_buy_amount(
1259 dec!(10),
1260 dec!(10),
1261 dec!(0.5),
1262 dec!(0.04),
1263 dec!(0.5),
1264 dec!(0),
1265 )
1266 .unwrap();
1267 assert!(
1268 (adjusted - dec!(9.615384)).abs() < dec!(0.00001),
1269 "expected ~9.615384, was {adjusted}",
1270 );
1271 }
1272
1273 #[rstest]
1274 fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
1275 let adjusted =
1277 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), dec!(1), dec!(0))
1278 .unwrap();
1279 assert_eq!(adjusted, dec!(10.000000));
1280 }
1281
1282 #[rstest]
1283 fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
1284 let adjusted =
1286 adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), dec!(1), dec!(0))
1287 .unwrap();
1288 assert_eq!(adjusted, dec!(7.500000));
1289 }
1290
1291 #[rstest]
1292 fn test_adjust_market_buy_amount_balance_too_small_errors() {
1293 let err = adjust_market_buy_amount(
1297 dec!(10),
1298 dec!(0.0000001),
1299 dec!(0.5),
1300 dec!(0.04),
1301 dec!(1),
1302 dec!(0),
1303 )
1304 .unwrap_err();
1305 assert!(err.to_string().contains("too small"));
1306 }
1307
1308 #[rstest]
1309 #[case::zero_price(dec!(0))]
1310 #[case::one_price(dec!(1))]
1311 #[case::negative_price(dec!(-0.1))]
1312 #[case::above_one_price(dec!(1.5))]
1313 fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
1314 let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), dec!(1), dec!(0))
1315 .unwrap_err();
1316 assert!(
1317 err.to_string().contains("invalid market-buy price"),
1318 "expected price-domain error, was {err}",
1319 );
1320 }
1321
1322 #[rstest]
1323 fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
1324 let adjusted = adjust_market_buy_amount(
1327 dec!(10),
1328 dec!(9.123456789),
1329 dec!(0.5),
1330 dec!(0.04),
1331 dec!(1),
1332 dec!(0),
1333 )
1334 .unwrap();
1335 assert!(adjusted.scale() <= 6);
1337 let expected = dec!(8.944565);
1339 assert!(
1340 (adjusted - expected).abs() < dec!(0.000001),
1341 "expected ~{expected}, was {adjusted}",
1342 );
1343 }
1344
1345 fn calc_platform_fee_sdk(
1353 amount: Decimal,
1354 price: Decimal,
1355 rate: Decimal,
1356 exponent: u32,
1357 ) -> Decimal {
1358 let base = price * (Decimal::ONE - price);
1359 let base_f64 = f64::try_from(base).unwrap_or(0.0);
1360 let rate_factor = rate
1361 * Decimal::try_from(base_f64.powi(i32::try_from(exponent).unwrap_or(0)))
1362 .unwrap_or(Decimal::ZERO);
1363 (amount / price) * rate_factor
1364 }
1365
1366 fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
1368 amount * rate
1369 }
1370
1371 fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
1372 let diff = (actual - expected).abs();
1373 assert!(
1374 diff <= tol,
1375 "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
1376 );
1377 }
1378
1379 #[rstest]
1380 fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
1381 let result = adjust_market_buy_amount(
1383 dec!(100),
1384 dec!(1000),
1385 dec!(0.5),
1386 dec!(0.02),
1387 dec!(1),
1388 dec!(0),
1389 )
1390 .unwrap();
1391 assert_eq!(result, dec!(100));
1392 }
1393
1394 #[rstest]
1395 fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
1396 let result = adjust_market_buy_amount(
1398 dec!(100),
1399 dec!(100),
1400 dec!(0.5),
1401 dec!(0.02),
1402 dec!(1),
1403 dec!(0),
1404 )
1405 .unwrap();
1406 assert!(result < dec!(100));
1407 assert!(result > dec!(0));
1408 }
1409
1410 #[rstest]
1411 fn test_sdk_adjust_market_buy_with_builder_fee() {
1412 let result = adjust_market_buy_amount(
1414 dec!(100),
1415 dec!(100),
1416 dec!(0.5),
1417 dec!(0),
1418 dec!(1),
1419 dec!(0.005),
1420 )
1421 .unwrap();
1422 let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
1424 assert_eq!(result, expected);
1425 }
1426
1427 #[rstest]
1428 fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
1429 let err = adjust_market_buy_amount(
1431 dec!(100),
1432 dec!(0.0000001),
1433 dec!(0.5),
1434 dec!(0.02),
1435 dec!(1),
1436 dec!(0.005),
1437 )
1438 .unwrap_err();
1439 assert!(err.to_string().contains("truncated to zero"));
1440 }
1441
1442 #[rstest]
1443 fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
1444 let amount = dec!(50);
1447 let price = dec!(0.5);
1448 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1449 let balance = amount + fee + dec!(1);
1450 let result =
1451 adjust_market_buy_amount(amount, balance, price, dec!(0.25), dec!(2), dec!(0)).unwrap();
1452 assert_eq!(result, amount);
1453 }
1454
1455 #[rstest]
1456 fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
1457 let amount = dec!(50);
1461 let price = dec!(0.5);
1462 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1463 let total_cost = amount + fee;
1464 let result =
1465 adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), dec!(2), dec!(0))
1466 .unwrap();
1467 close_to(result, amount, dec!(0.000001));
1468 }
1469
1470 #[rstest]
1471 fn test_sdk_adjust_buy_conserves_notional_platform_only() {
1472 let amount = dec!(50);
1475 let price = dec!(0.5);
1476 let adjusted =
1477 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), dec!(0)).unwrap();
1478 let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1479 close_to(adjusted + fee, amount, dec!(0.000001));
1480 assert!(adjusted < amount);
1481 }
1482
1483 #[rstest]
1484 fn test_sdk_adjust_buy_conserves_notional_builder_only() {
1485 let amount = dec!(50);
1487 let price = dec!(0.5);
1488 let builder_rate = dec!(0.01);
1489 let adjusted = adjust_market_buy_amount(
1490 amount,
1491 amount,
1492 price,
1493 dec!(0),
1494 Decimal::from(0u32),
1495 builder_rate,
1496 )
1497 .unwrap();
1498 let fee = calc_builder_fee_sdk(adjusted, builder_rate);
1499 close_to(adjusted + fee, amount, dec!(0.000001));
1500 }
1501
1502 #[rstest]
1503 fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
1504 let amount = dec!(50);
1506 let price = dec!(0.5);
1507 let builder_rate = dec!(0.01);
1508 let adjusted =
1509 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), builder_rate)
1510 .unwrap();
1511 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1512 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1513 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1514 }
1515
1516 #[rstest]
1517 fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
1518 let amount = dec!(30);
1520 let price = dec!(0.3);
1521 let builder_rate = dec!(0.02);
1522 let adjusted =
1523 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), builder_rate)
1524 .unwrap();
1525 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1526 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1527 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1528 }
1529
1530 #[rstest]
1531 fn test_parse_timestamp_ms() {
1532 let ts = parse_timestamp("1703875200000").unwrap();
1533 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1534 }
1535
1536 #[rstest]
1537 fn test_parse_timestamp_secs() {
1538 let ts = parse_timestamp("1703875200").unwrap();
1539 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1540 }
1541
1542 #[rstest]
1543 fn test_parse_timestamp_rfc3339() {
1544 let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1545 assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1546 }
1547
1548 #[rstest]
1549 fn test_parse_liquidity_side_maker() {
1550 assert_eq!(
1551 parse_liquidity_side(PolymarketLiquiditySide::Maker),
1552 LiquiditySide::Maker
1553 );
1554 }
1555
1556 #[rstest]
1557 fn test_parse_liquidity_side_taker() {
1558 assert_eq!(
1559 parse_liquidity_side(PolymarketLiquiditySide::Taker),
1560 LiquiditySide::Taker
1561 );
1562 }
1563
1564 #[rstest]
1565 fn test_parse_order_status_report_from_fixture() {
1566 let path = "test_data/http_open_order.json";
1567 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1568 let order: PolymarketOpenOrder =
1569 serde_json::from_str(&content).expect("Failed to parse test data");
1570
1571 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1572 let account_id = AccountId::from("POLYMARKET-001");
1573
1574 let report = parse_order_status_report(
1575 &order,
1576 instrument_id,
1577 account_id,
1578 None,
1579 4,
1580 6,
1581 UnixNanos::from(1_000_000_000u64),
1582 )
1583 .unwrap();
1584
1585 assert_eq!(report.account_id, account_id);
1586 assert_eq!(report.instrument_id, instrument_id);
1587 assert_eq!(report.order_side, Some(OrderSide::Buy));
1588 assert_eq!(report.order_type, OrderType::Limit);
1589 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1590 assert_eq!(report.order_status, OrderStatus::Accepted);
1591 assert!(report.price.is_some());
1592 assert_eq!(
1593 report.ts_accepted,
1594 UnixNanos::from(1_703_875_200_000_000_000u64)
1595 );
1596 assert_eq!(
1597 report.ts_last,
1598 UnixNanos::from(1_703_875_200_000_000_000u64)
1599 );
1600 assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1601 assert_eq!(report.expire_time, None);
1603 }
1604
1605 #[rstest]
1608 #[case::matched_underfill_dust(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.995000), 100.000000)]
1610 #[case::matched_overfill_dust(PolymarketOrderStatus::Matched, dec!(714.285710), dec!(714.285714), 714.285710)]
1612 #[case::live_underfill_dust(PolymarketOrderStatus::Live, dec!(100.000000), dec!(99.995000), 99.995000)]
1614 #[case::matched_real_partial(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.000000), 99.000000)]
1616 fn test_parse_order_status_report_snaps_dust_filled_qty(
1617 #[case] status: PolymarketOrderStatus,
1618 #[case] original_size: Decimal,
1619 #[case] size_matched: Decimal,
1620 #[case] expected_filled: f64,
1621 ) {
1622 let order = PolymarketOpenOrder {
1623 associate_trades: None,
1624 id: "0xid".to_string(),
1625 status,
1626 market: Ustr::from("0xm"),
1627 original_size,
1628 outcome: PolymarketOutcome::yes(),
1629 maker_address: "0xmaker".to_string(),
1630 owner: "owner".to_string(),
1631 price: dec!(0.5),
1632 side: PolymarketOrderSide::Buy,
1633 size_matched,
1634 asset_id: Ustr::from("token"),
1635 expiration: None,
1636 order_type: PolymarketOrderType::GTC,
1637 created_at: 1_703_875_200,
1638 };
1639
1640 let report = parse_order_status_report(
1641 &order,
1642 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1643 AccountId::from("POLYMARKET-001"),
1644 None,
1645 3,
1646 6,
1647 UnixNanos::from(1_000_000_000u64),
1648 )
1649 .unwrap();
1650
1651 assert_eq!(report.filled_qty, Quantity::new(expected_filled, 6));
1652 assert_eq!(
1653 report.quantity,
1654 Quantity::new(original_size.try_into().unwrap_or(0.0), 6)
1655 );
1656 }
1657
1658 #[rstest]
1661 #[case::gtc_real_partial(PolymarketOrderType::GTC, dec!(10), dec!(7), OrderStatus::Canceled)]
1663 #[case::gtc_dust_underfill(PolymarketOrderType::GTC, dec!(100), dec!(99.997714), OrderStatus::Filled)]
1665 #[case::gtc_exact(PolymarketOrderType::GTC, dec!(10), dec!(10), OrderStatus::Filled)]
1667 #[case::fak_partial(PolymarketOrderType::FAK, dec!(30), dec!(20), OrderStatus::Canceled)]
1669 fn test_parse_order_status_report_matched_resolves_terminal_status(
1670 #[case] order_type: PolymarketOrderType,
1671 #[case] original_size: Decimal,
1672 #[case] size_matched: Decimal,
1673 #[case] expected_status: OrderStatus,
1674 ) {
1675 let order = PolymarketOpenOrder {
1676 associate_trades: None,
1677 id: "0xterminal".to_string(),
1678 status: PolymarketOrderStatus::Matched,
1679 market: Ustr::from("0xmarket"),
1680 original_size,
1681 outcome: PolymarketOutcome::yes(),
1682 maker_address: "0xmaker".to_string(),
1683 owner: "owner".to_string(),
1684 price: dec!(0.5),
1685 side: PolymarketOrderSide::Buy,
1686 size_matched,
1687 asset_id: Ustr::from("token"),
1688 expiration: None,
1689 order_type,
1690 created_at: 1_784_118_677,
1691 };
1692
1693 let report = parse_order_status_report(
1694 &order,
1695 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1696 AccountId::from("POLYMARKET-001"),
1697 None,
1698 3,
1699 6,
1700 UnixNanos::from(1_000_000_000u64),
1701 )
1702 .unwrap();
1703
1704 assert_eq!(report.order_status, expected_status);
1705 if report.order_status == OrderStatus::Filled {
1706 assert!(
1707 report.filled_qty >= report.quantity,
1708 "a Filled report must not carry filled_qty < quantity, was filled_qty={} quantity={}",
1709 report.filled_qty,
1710 report.quantity
1711 );
1712 }
1713 }
1714
1715 #[rstest]
1716 fn test_parse_order_status_report_maps_partial_fak_match_to_canceled() {
1717 let order = PolymarketOpenOrder {
1718 associate_trades: Some(vec!["trade-partial-fak".to_string()]),
1719 id: "0xpartial-fak".to_string(),
1720 status: PolymarketOrderStatus::Matched,
1721 market: Ustr::from("0xmarket"),
1722 original_size: dec!(30),
1723 outcome: PolymarketOutcome::yes(),
1724 maker_address: "0xmaker".to_string(),
1725 owner: "owner".to_string(),
1726 price: dec!(0.093),
1727 side: PolymarketOrderSide::Buy,
1728 size_matched: dec!(20),
1729 asset_id: Ustr::from("token"),
1730 expiration: Some("0".to_string()),
1731 order_type: PolymarketOrderType::FAK,
1732 created_at: 1_784_118_677,
1733 };
1734
1735 let report = parse_order_status_report(
1736 &order,
1737 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1738 AccountId::from("POLYMARKET-001"),
1739 None,
1740 3,
1741 6,
1742 UnixNanos::from(1_000_000_000u64),
1743 )
1744 .unwrap();
1745
1746 assert_eq!(report.order_status, OrderStatus::Canceled);
1747 assert_eq!(report.time_in_force, TimeInForce::Ioc);
1748 assert_eq!(report.quantity, Quantity::from("30.000000"));
1749 assert_eq!(report.filled_qty, Quantity::from("20.000000"));
1750 }
1751
1752 #[rstest]
1753 #[case::null(None, None)]
1754 #[case::zero_string(Some("0"), None)]
1755 #[case::empty_string(Some(""), None)]
1756 #[case::garbage(Some("not-a-number"), None)]
1757 #[case::positive_seconds(
1758 Some("1735689600"),
1759 Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1760 )]
1761 fn test_parse_order_status_report_expiration(
1762 #[case] raw: Option<&str>,
1763 #[case] expected: Option<UnixNanos>,
1764 ) {
1765 let order = PolymarketOpenOrder {
1766 associate_trades: None,
1767 id: "0xid".to_string(),
1768 status: PolymarketOrderStatus::Live,
1769 market: Ustr::from("0xm"),
1770 original_size: dec!(100),
1771 outcome: PolymarketOutcome::yes(),
1772 maker_address: "0xmaker".to_string(),
1773 owner: "owner".to_string(),
1774 price: dec!(0.5),
1775 side: PolymarketOrderSide::Buy,
1776 size_matched: dec!(0),
1777 asset_id: Ustr::from("token"),
1778 expiration: raw.map(|s| s.to_string()),
1779 order_type: PolymarketOrderType::GTD,
1780 created_at: 1_703_875_200,
1781 };
1782
1783 let report = parse_order_status_report(
1784 &order,
1785 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1786 AccountId::from("POLYMARKET-001"),
1787 None,
1788 4,
1789 6,
1790 UnixNanos::from(1_000_000_000u64),
1791 )
1792 .unwrap();
1793
1794 assert_eq!(report.expire_time, expected);
1795 }
1796
1797 #[rstest]
1798 #[case::price(Decimal::MAX, dec!(10), dec!(0))]
1799 #[case::quantity(dec!(0.5), Decimal::MAX, dec!(0))]
1800 #[case::filled(dec!(0.5), dec!(10), Decimal::MAX)]
1801 fn test_order_report_rejects_unrepresentable_values(
1802 #[case] price: Decimal,
1803 #[case] quantity: Decimal,
1804 #[case] filled: Decimal,
1805 ) {
1806 let mut order: PolymarketOpenOrder =
1807 serde_json::from_str(include_str!("../../test_data/http_open_order.json")).unwrap();
1808 order.price = price;
1809 order.original_size = quantity;
1810 order.size_matched = filled;
1811 assert!(
1812 parse_order_status_report(
1813 &order,
1814 InstrumentId::from("TEST.POLYMARKET"),
1815 AccountId::from("POLY-001"),
1816 None,
1817 4,
1818 6,
1819 UnixNanos::default()
1820 )
1821 .is_err()
1822 );
1823 }
1824
1825 #[rstest]
1826 #[case::negative_rate(dec!(-0.1), dec!(1), dec!(10), dec!(0.5))]
1827 #[case::negative_exponent(dec!(0.1), dec!(-1), dec!(10), dec!(0.5))]
1828 #[case::negative_size(dec!(0.1), dec!(1), dec!(-10), dec!(0.5))]
1829 #[case::invalid_price(dec!(0.1), dec!(1), dec!(10), dec!(1.1))]
1830 #[case::overflow(Decimal::MAX, dec!(0), dec!(10), dec!(0.5))]
1831 fn test_commission_rejects_invalid_values(
1832 #[case] rate: Decimal,
1833 #[case] exponent: Decimal,
1834 #[case] size: Decimal,
1835 #[case] price: Decimal,
1836 ) {
1837 assert!(compute_commission(rate, exponent, size, price, LiquiditySide::Taker).is_err());
1838 }
1839
1840 #[rstest]
1841 #[case::negative_amount(dec!(-1), dec!(10), dec!(0))]
1842 #[case::negative_balance(dec!(1), dec!(-10), dec!(0))]
1843 #[case::negative_builder(dec!(1), dec!(10), dec!(-0.1))]
1844 #[case::overflow(Decimal::MAX, Decimal::MAX, dec!(1))]
1845 fn test_market_buy_rejects_invalid_values(
1846 #[case] amount: Decimal,
1847 #[case] balance: Decimal,
1848 #[case] builder: Decimal,
1849 ) {
1850 assert!(
1851 adjust_market_buy_amount(
1852 amount,
1853 balance,
1854 dec!(0.5),
1855 dec!(0.07),
1856 Decimal::ONE,
1857 builder
1858 )
1859 .is_err()
1860 );
1861 }
1862
1863 #[rstest]
1864 fn test_fee_exponent_preserves_exact_metadata_and_rejects_invalid_metadata() {
1865 let mut binary = binary_option();
1866 let mut info = nautilus_core::Params::new();
1867 info.insert(
1868 "fee_schedule".into(),
1869 serde_json::json!({"exponent": "1.234567890123456789012345678"}),
1870 );
1871 binary.info = Some(info.clone());
1872 assert_eq!(
1873 instrument_fee_exponent(&InstrumentAny::BinaryOption(binary.clone())).unwrap(),
1874 dec!(1.234567890123456789012345678)
1875 );
1876
1877 for value in [
1878 serde_json::Value::Null,
1879 serde_json::json!("bad"),
1880 serde_json::json!(-1),
1881 ] {
1882 info.insert(
1883 "fee_schedule".into(),
1884 serde_json::json!({"exponent": value}),
1885 );
1886 binary.info = Some(info.clone());
1887 assert!(instrument_fee_exponent(&InstrumentAny::BinaryOption(binary.clone())).is_err());
1888 }
1889 info.insert("fee_schedule".into(), serde_json::json!({"exponent": 2}));
1890 binary.info = Some(info);
1891 assert_eq!(
1892 instrument_fee_exponent(&InstrumentAny::BinaryOption(binary)).unwrap(),
1893 dec!(2)
1894 );
1895 }
1896
1897 #[rstest]
1898 fn test_parse_fill_report_errors_when_commission_is_unrepresentable() {
1899 let path = "test_data/http_trade_report.json";
1900 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1901 let trade: PolymarketTradeReport =
1902 serde_json::from_str(&content).expect("Failed to parse test data");
1903
1904 let result = parse_fill_report(
1905 &trade,
1906 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1907 AccountId::from("POLYMARKET-001"),
1908 None,
1909 4,
1910 6,
1911 Currency::pUSD(),
1912 Decimal::from_i128_with_scale(100_000_000_000_000_000_000_000_000i128, 0),
1915 Decimal::ONE,
1916 UnixNanos::from(1_000_000_000u64),
1917 );
1918
1919 assert!(
1920 result.is_err(),
1921 "an unrepresentable commission must surface as an error rather than panicking"
1922 );
1923 }
1924
1925 #[rstest]
1926 fn test_parse_fill_report_from_fixture() {
1927 let path = "test_data/http_trade_report.json";
1928 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1929 let trade: PolymarketTradeReport =
1930 serde_json::from_str(&content).expect("Failed to parse test data");
1931
1932 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1933 let account_id = AccountId::from("POLYMARKET-001");
1934 let currency = Currency::pUSD();
1935
1936 let report = parse_fill_report(
1937 &trade,
1938 instrument_id,
1939 account_id,
1940 None,
1941 4,
1942 6,
1943 currency,
1944 Decimal::ZERO,
1945 dec!(1),
1946 UnixNanos::from(1_000_000_000u64),
1947 )
1948 .expect("fixture commission is representable");
1949
1950 assert_eq!(report.account_id, account_id);
1951 assert_eq!(report.instrument_id, instrument_id);
1952 assert_eq!(report.order_side, OrderSide::Buy);
1953 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1954 assert_eq!(report.commission.as_decimal(), dec!(0.0));
1955 }
1956
1957 #[rstest]
1958 fn test_parse_fill_report_forwards_fee_schedule() {
1959 let path = "test_data/http_trade_report.json";
1960 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1961 let trade: PolymarketTradeReport =
1962 serde_json::from_str(&content).expect("Failed to parse test data");
1963
1964 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1965 let account_id = AccountId::from("POLYMARKET-001");
1966 let currency = Currency::pUSD();
1967
1968 let report = parse_fill_report(
1970 &trade,
1971 instrument_id,
1972 account_id,
1973 None,
1974 4,
1975 6,
1976 currency,
1977 dec!(0.03),
1978 dec!(2),
1979 UnixNanos::from(1_000_000_000u64),
1980 )
1981 .expect("fixture commission is representable");
1982
1983 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1984 assert_eq!(report.commission.as_decimal(), dec!(0.04688));
1985 }
1986
1987 #[rstest]
1988 fn test_instrument_taker_fee_reads_binary_option() {
1989 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
1990
1991 let path = "test_data/gamma_market_sports_market_money_line.json";
1992 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1993 let market = serde_json::from_str(&content).expect("Failed to parse test data");
1994 let defs = parse_gamma_market(&market).unwrap();
1995 let instrument =
1996 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1997
1998 assert_eq!(instrument_taker_fee(&instrument), dec!(0.03));
1999 assert_eq!(instrument_fee_exponent(&instrument).unwrap(), Decimal::ONE);
2000 }
2001
2002 #[rstest]
2003 #[case(
2004 PolymarketLiquiditySide::Taker,
2005 PolymarketOrderSide::Buy,
2006 "token_a",
2007 "token_b",
2008 OrderSide::Buy
2009 )]
2010 #[case(
2011 PolymarketLiquiditySide::Taker,
2012 PolymarketOrderSide::Sell,
2013 "token_a",
2014 "token_b",
2015 OrderSide::Sell
2016 )]
2017 #[case(
2018 PolymarketLiquiditySide::Maker,
2019 PolymarketOrderSide::Buy,
2020 "token_a",
2021 "token_b",
2022 OrderSide::Buy
2023 )]
2024 #[case(
2025 PolymarketLiquiditySide::Maker,
2026 PolymarketOrderSide::Buy,
2027 "token_a",
2028 "token_a",
2029 OrderSide::Sell
2030 )]
2031 #[case(
2032 PolymarketLiquiditySide::Maker,
2033 PolymarketOrderSide::Sell,
2034 "token_a",
2035 "token_a",
2036 OrderSide::Buy
2037 )]
2038 fn test_determine_order_side(
2039 #[case] trader_side: PolymarketLiquiditySide,
2040 #[case] trade_side: PolymarketOrderSide,
2041 #[case] taker_asset: &str,
2042 #[case] maker_asset: &str,
2043 #[case] expected: OrderSide,
2044 ) {
2045 let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
2046 assert_eq!(result, expected);
2047 }
2048
2049 #[rstest]
2050 fn test_make_composite_trade_id_basic() {
2051 let trade_id = "trade-abc123";
2052 let venue_order_id = "order-xyz789";
2053 let result = make_composite_trade_id(trade_id, venue_order_id);
2054 assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
2055 }
2056
2057 #[rstest]
2058 fn test_make_composite_trade_id_truncates_long_ids() {
2059 let trade_id = "a]".repeat(30);
2060 let venue_order_id = "b".repeat(20);
2061 let result = make_composite_trade_id(&trade_id, &venue_order_id);
2062 assert!(result.as_str().len() <= 36);
2063 }
2064
2065 #[rstest]
2066 fn test_make_composite_trade_id_short_venue_id() {
2067 let trade_id = "t123";
2068 let venue_order_id = "ab";
2069 let result = make_composite_trade_id(trade_id, venue_order_id);
2070 assert_eq!(result.as_str(), "t123-ab");
2071 }
2072
2073 #[rstest]
2074 fn test_make_composite_trade_id_uniqueness() {
2075 let id_a = make_composite_trade_id("same-trade", "order-aaa");
2076 let id_b = make_composite_trade_id("same-trade", "order-bbb");
2077 assert_ne!(id_a, id_b);
2078 }
2079
2080 #[rstest]
2083 fn test_calculate_market_price_buy_single_level() {
2084 let levels = vec![ClobBookLevel {
2085 price: "0.55".to_string(),
2086 size: "200.0".to_string(),
2087 }];
2088 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
2089 assert_eq!(result.crossing_price, dec!(0.55));
2090 assert!(result.expected_base_qty > dec!(90));
2092 }
2093
2094 #[rstest]
2095 fn test_calculate_market_price_buy_walks_multiple_levels() {
2096 let levels = vec![
2098 ClobBookLevel {
2099 price: "0.55".to_string(),
2100 size: "100.0".to_string(),
2101 },
2102 ClobBookLevel {
2103 price: "0.50".to_string(),
2104 size: "10.0".to_string(),
2105 },
2106 ClobBookLevel {
2107 price: "0.60".to_string(),
2108 size: "200.0".to_string(),
2109 },
2110 ];
2111 let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
2114 assert_eq!(result.crossing_price, dec!(0.55));
2115 let expected = dec!(10) + dec!(15) / dec!(0.55);
2116 assert_eq!(result.expected_base_qty, expected);
2117 }
2118
2119 #[rstest]
2120 fn test_calculate_market_price_buy_small_order_uses_best_ask() {
2121 let levels = vec![
2123 ClobBookLevel {
2124 price: "0.50".to_string(),
2125 size: "50.0".to_string(),
2126 },
2127 ClobBookLevel {
2128 price: "0.999".to_string(),
2129 size: "100.0".to_string(),
2130 },
2131 ClobBookLevel {
2132 price: "0.20".to_string(),
2133 size: "72.0".to_string(),
2134 },
2135 ];
2136 let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
2139 assert_eq!(result.crossing_price, dec!(0.20));
2140 assert_eq!(result.expected_base_qty, dec!(25)); }
2142
2143 #[rstest]
2144 fn test_calculate_market_price_sell_walks_levels() {
2145 let levels = vec![
2147 ClobBookLevel {
2148 price: "0.48".to_string(),
2149 size: "100.0".to_string(),
2150 },
2151 ClobBookLevel {
2152 price: "0.50".to_string(),
2153 size: "50.0".to_string(),
2154 },
2155 ];
2156 let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
2159 assert_eq!(result.crossing_price, dec!(0.48));
2160 assert_eq!(result.expected_base_qty, dec!(80));
2161 }
2162
2163 #[rstest]
2164 fn test_calculate_market_price_empty_book() {
2165 let levels: Vec<ClobBookLevel> = vec![];
2166 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
2167 assert!(result.is_err());
2168 }
2169
2170 #[rstest]
2171 #[case("bad", "10", "invalid market-book price")]
2172 #[case("0.5", "bad", "invalid market-book size")]
2173 #[case("0.5", "-10", "market-book size must be non-negative")]
2174 #[case("1.1", "10", "market-book price must be in (0, 1)")]
2175 fn test_market_book_rejects_invalid_values(
2176 #[case] price: &str,
2177 #[case] size: &str,
2178 #[case] expected: &str,
2179 ) {
2180 let levels = [ClobBookLevel {
2181 price: price.into(),
2182 size: size.into(),
2183 }];
2184 assert_eq!(
2185 calculate_market_price(&levels, dec!(1), PolymarketOrderSide::Buy)
2186 .unwrap_err()
2187 .to_string(),
2188 expected
2189 );
2190 }
2191
2192 #[rstest]
2193 fn test_calculate_market_price_all_zero_levels_returns_error() {
2194 let levels = vec![
2195 ClobBookLevel {
2196 price: "0".to_string(),
2197 size: "100.0".to_string(),
2198 },
2199 ClobBookLevel {
2200 price: "0.50".to_string(),
2201 size: "0".to_string(),
2202 },
2203 ];
2204 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
2205 assert!(result.is_err());
2206 }
2207
2208 #[rstest]
2209 fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
2210 let levels = vec![ClobBookLevel {
2211 price: "0.55".to_string(),
2212 size: "10.0".to_string(),
2213 }];
2214 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
2216 assert_eq!(result.crossing_price, dec!(0.55));
2217 assert_eq!(result.expected_base_qty, dec!(10)); }
2219
2220 #[rstest]
2221 fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
2222 let levels_ascending = vec![
2223 ClobBookLevel {
2224 price: "0.20".to_string(),
2225 size: "72.0".to_string(),
2226 },
2227 ClobBookLevel {
2228 price: "0.50".to_string(),
2229 size: "50.0".to_string(),
2230 },
2231 ClobBookLevel {
2232 price: "0.999".to_string(),
2233 size: "100.0".to_string(),
2234 },
2235 ];
2236 let levels_descending = vec![
2237 ClobBookLevel {
2238 price: "0.999".to_string(),
2239 size: "100.0".to_string(),
2240 },
2241 ClobBookLevel {
2242 price: "0.50".to_string(),
2243 size: "50.0".to_string(),
2244 },
2245 ClobBookLevel {
2246 price: "0.20".to_string(),
2247 size: "72.0".to_string(),
2248 },
2249 ];
2250 let levels_shuffled = vec![
2251 ClobBookLevel {
2252 price: "0.50".to_string(),
2253 size: "50.0".to_string(),
2254 },
2255 ClobBookLevel {
2256 price: "0.20".to_string(),
2257 size: "72.0".to_string(),
2258 },
2259 ClobBookLevel {
2260 price: "0.999".to_string(),
2261 size: "100.0".to_string(),
2262 },
2263 ];
2264
2265 let r1 =
2266 calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
2267 let r2 =
2268 calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
2269 let r3 =
2270 calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
2271
2272 assert_eq!(r1.crossing_price, r2.crossing_price);
2273 assert_eq!(r2.crossing_price, r3.crossing_price);
2274 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2275 assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
2276 }
2277
2278 #[rstest]
2279 fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
2280 let levels_a = vec![
2281 ClobBookLevel {
2282 price: "0.48".to_string(),
2283 size: "100.0".to_string(),
2284 },
2285 ClobBookLevel {
2286 price: "0.50".to_string(),
2287 size: "50.0".to_string(),
2288 },
2289 ];
2290 let levels_b = vec![
2291 ClobBookLevel {
2292 price: "0.50".to_string(),
2293 size: "50.0".to_string(),
2294 },
2295 ClobBookLevel {
2296 price: "0.48".to_string(),
2297 size: "100.0".to_string(),
2298 },
2299 ];
2300
2301 let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
2302 let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
2303
2304 assert_eq!(r1.crossing_price, r2.crossing_price);
2305 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2306 }
2307
2308 mod adjust_market_buy_amount_property_tests {
2309 use proptest::prelude::*;
2310 use rstest::rstest;
2311
2312 use super::*;
2313
2314 fn decimal_at_usdc_scale(micros: u64) -> Decimal {
2318 Decimal::new(micros as i64, USDC_DECIMALS)
2319 }
2320
2321 fn decimal_from_bps(bps: u32) -> Decimal {
2323 Decimal::new(i64::from(bps), 4)
2324 }
2325
2326 fn compute_total_cost(
2329 amount: Decimal,
2330 price: Decimal,
2331 fee_rate: Decimal,
2332 fee_exponent: Decimal,
2333 builder: Decimal,
2334 ) -> Decimal {
2335 let base = price * (Decimal::ONE - price);
2336 let base_f64: f64 = base.try_into().unwrap_or(0.0);
2337 let curve = Decimal::try_from(base_f64.powf(fee_exponent.try_into().unwrap()))
2338 .unwrap_or(Decimal::ZERO);
2339 let platform_fee_rate = fee_rate * curve;
2340 let platform_fee = amount / price * platform_fee_rate;
2341 amount + platform_fee + amount * builder
2342 }
2343
2344 proptest! {
2345 #[rstest]
2348 fn prop_adjust_market_buy_amount_is_deterministic(
2349 amount_micros in 1u64..=1_000_000_000_000u64,
2350 balance_micros in 1u64..=1_000_000_000_000u64,
2351 price_milli in 1u32..=999u32,
2352 fee_rate_bps in 0u32..=1_000u32,
2353 fee_exponent_milli in 1_000u32..=3_000u32,
2354 builder_bps in 0u32..=500u32,
2355 ) {
2356 let amount = decimal_at_usdc_scale(amount_micros);
2357 let balance = decimal_at_usdc_scale(balance_micros);
2358 let price = Decimal::new(i64::from(price_milli), 3);
2359 let fee_rate = decimal_from_bps(fee_rate_bps);
2360 let builder = decimal_from_bps(builder_bps);
2361 let fee_exponent = Decimal::new(i64::from(fee_exponent_milli), 3);
2362
2363 let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2364 let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2365 prop_assert_eq!(r1.is_ok(), r2.is_ok());
2366 if let (Ok(a), Ok(b)) = (r1, r2) {
2367 prop_assert_eq!(a, b);
2368 }
2369 }
2370
2371 #[rstest]
2376 fn prop_adjust_market_buy_amount_non_binding_returns_amount(
2377 amount_micros in 1u64..=1_000_000_000u64,
2378 price_milli in 1u32..=999u32,
2379 fee_rate_bps in 0u32..=1_000u32,
2380 fee_exponent_milli in 1_000u32..=3_000u32,
2381 builder_bps in 0u32..=500u32,
2382 ) {
2383 let amount = decimal_at_usdc_scale(amount_micros);
2384 let price = Decimal::new(i64::from(price_milli), 3);
2385 let fee_rate = decimal_from_bps(fee_rate_bps);
2386 let builder = decimal_from_bps(builder_bps);
2387 let fee_exponent = Decimal::new(i64::from(fee_exponent_milli), 3);
2388
2389 let total_cost =
2393 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2394 let balance = total_cost * Decimal::from(10);
2395
2396 let adjusted = adjust_market_buy_amount(
2397 amount, balance, price, fee_rate, fee_exponent, builder,
2398 )
2399 .expect("non-binding balance must yield Ok");
2400 prop_assert_eq!(
2401 adjusted, amount,
2402 "non-binding branch must return the input amount unchanged",
2403 );
2404 }
2405
2406 #[rstest]
2411 fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
2412 amount_micros in 1_000u64..=1_000_000_000u64,
2413 price_milli in 10u32..=990u32,
2414 fee_rate_bps in 0u32..=1_000u32,
2415 fee_exponent_milli in 1_000u32..=3_000u32,
2416 builder_bps in 0u32..=500u32,
2417 fraction_thousandths in 100u32..=900u32,
2418 ) {
2419 let amount = decimal_at_usdc_scale(amount_micros);
2420 let price = Decimal::new(i64::from(price_milli), 3);
2421 let fee_rate = decimal_from_bps(fee_rate_bps);
2422 let builder = decimal_from_bps(builder_bps);
2423 let fee_exponent = Decimal::new(i64::from(fee_exponent_milli), 3);
2424
2425 let total_cost =
2428 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2429 let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
2430 let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
2431 if balance.is_zero() {
2432 return Ok(()); }
2434
2435 let adjusted = adjust_market_buy_amount(
2436 amount, balance, price, fee_rate, fee_exponent, builder,
2437 )
2438 .expect("non-zero balance fraction must yield Ok in binding branch");
2439
2440 prop_assert!(
2441 adjusted < amount,
2442 "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
2443 );
2444 prop_assert!(
2445 adjusted > Decimal::ZERO,
2446 "adjusted must be strictly positive",
2447 );
2448 prop_assert_eq!(
2449 adjusted,
2450 adjusted.trunc_with_scale(USDC_DECIMALS),
2451 "adjusted must be at USDC_DECIMALS scale",
2452 );
2453 let recomputed_cost =
2454 compute_total_cost(adjusted, price, fee_rate, fee_exponent, builder);
2455 prop_assert!(
2456 recomputed_cost <= balance,
2457 "total_cost {recomputed_cost} must fit balance {balance}",
2458 );
2459 }
2460
2461 #[rstest]
2465 fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
2466 amount_pico in 1_000_000u64..=1_000_000_000_000u64,
2467 price_milli in 1u32..=999u32,
2468 fee_rate_bps in 0u32..=1_000u32,
2469 fee_exponent_milli in 1_000u32..=3_000u32,
2470 builder_bps in 0u32..=500u32,
2471 ) {
2472 let amount = Decimal::new(amount_pico as i64, 9);
2475 let price = Decimal::new(i64::from(price_milli), 3);
2476 let fee_rate = decimal_from_bps(fee_rate_bps);
2477 let builder = decimal_from_bps(builder_bps);
2478 let fee_exponent = Decimal::new(i64::from(fee_exponent_milli), 3);
2479
2480 let total_cost =
2482 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2483 let balance = total_cost * Decimal::from(10);
2484
2485 if let Ok(adjusted) = adjust_market_buy_amount(
2486 amount, balance, price, fee_rate, fee_exponent, builder,
2487 ) {
2488 prop_assert_eq!(
2489 adjusted,
2490 adjusted.trunc_with_scale(USDC_DECIMALS),
2491 "result must be at USDC_DECIMALS scale",
2492 );
2493 prop_assert!(
2494 adjusted <= amount,
2495 "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
2496 );
2497 }
2498 }
2499 }
2500 }
2501}