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, RoundingStrategy, prelude::ToPrimitive};
32use thiserror::Error;
33
34use crate::{
35 common::{
36 consts::{DUST_SNAP_THRESHOLD_DEC, USDC_DECIMALS},
37 enums::{
38 PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
39 PolymarketOrderStatus,
40 },
41 models::PolymarketMakerOrder,
42 parse::parse_decimal_exact,
43 },
44 http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
45};
46
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(crate) 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)]
341pub fn build_maker_fill_report(
342 mo: &PolymarketMakerOrder,
343 trade_id: &str,
344 trader_side: PolymarketLiquiditySide,
345 trade_side: PolymarketOrderSide,
346 taker_asset_id: &str,
347 account_id: AccountId,
348 instrument_id: InstrumentId,
349 price_precision: u8,
350 size_precision: u8,
351 currency: Currency,
352 liquidity_side: LiquiditySide,
353 ts_event: UnixNanos,
354 ts_init: UnixNanos,
355) -> anyhow::Result<FillReport> {
356 parse_validated_maker_fill_report(
357 mo,
358 trader_side,
359 trade_side,
360 taker_asset_id,
361 MakerFillParseContext {
362 account_id,
363 instrument_id,
364 venue_order_id: VenueOrderId::from(mo.order_id.as_str()),
365 trade_id: make_composite_trade_id(trade_id, &mo.order_id),
366 price_precision,
367 size_precision,
368 currency,
369 liquidity_side,
370 ts_event,
371 ts_init,
372 },
373 )
374}
375
376#[derive(Clone, Copy, Debug)]
377pub(super) struct MakerFillParseContext {
378 pub account_id: AccountId,
379 pub instrument_id: InstrumentId,
380 pub venue_order_id: VenueOrderId,
381 pub trade_id: TradeId,
382 pub price_precision: u8,
383 pub size_precision: u8,
384 pub currency: Currency,
385 pub liquidity_side: LiquiditySide,
386 pub ts_event: UnixNanos,
387 pub ts_init: UnixNanos,
388}
389
390pub(super) fn parse_validated_maker_fill_report(
391 mo: &PolymarketMakerOrder,
392 trader_side: PolymarketLiquiditySide,
393 trade_side: PolymarketOrderSide,
394 taker_asset_id: &str,
395 ctx: MakerFillParseContext,
396) -> anyhow::Result<FillReport> {
397 let order_side = determine_order_side(
398 trader_side,
399 trade_side,
400 taker_asset_id,
401 mo.asset_id.as_str(),
402 );
403 let last_qty = Quantity::from_decimal_dp(mo.matched_amount, ctx.size_precision)?;
404 let last_px = Price::from_decimal_dp(mo.price, ctx.price_precision)?;
405 let commission_value = compute_commission(
406 Decimal::ZERO,
407 Decimal::ONE,
408 mo.matched_amount,
409 mo.price,
410 ctx.liquidity_side,
411 )?;
412 let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
413 format!(
414 "failed to represent commission {commission_value} for {} as Money",
415 ctx.instrument_id
416 )
417 })?;
418
419 Ok(FillReport {
420 account_id: ctx.account_id,
421 instrument_id: ctx.instrument_id,
422 venue_order_id: ctx.venue_order_id,
423 trade_id: ctx.trade_id,
424 order_side,
425 last_qty,
426 last_px,
427 commission,
428 liquidity_side: ctx.liquidity_side,
429 avg_px: None,
430 report_id: UUID4::new(),
431 ts_event: ctx.ts_event,
432 ts_init: ctx.ts_init,
433 client_order_id: None,
434 venue_position_id: None,
435 })
436}
437
438pub fn instrument_taker_fee(instrument: &InstrumentAny) -> anyhow::Result<Decimal> {
448 let Some(schedule) = instrument_fee_schedule(instrument) else {
449 return Ok(Decimal::ZERO);
450 };
451 fee_schedule_decimal(schedule, "rate")
452}
453
454pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> anyhow::Result<Decimal> {
464 let Some(schedule) = instrument_fee_schedule(instrument) else {
465 return Ok(Decimal::ONE);
466 };
467 fee_schedule_decimal(schedule, "exponent")
468}
469
470fn instrument_fee_schedule(instrument: &InstrumentAny) -> Option<&serde_json::Value> {
471 match instrument {
472 InstrumentAny::BinaryOption(bo) => {
473 bo.info.as_ref().and_then(|info| info.get("fee_schedule"))
474 }
475 _ => None,
476 }
477}
478
479fn fee_schedule_decimal(schedule: &serde_json::Value, field: &str) -> anyhow::Result<Decimal> {
480 let value = schedule
481 .get(field)
482 .with_context(|| format!("fee schedule is missing {field}"))?;
483 let decimal = match value {
484 serde_json::Value::String(value) => parse_decimal_exact(value)?,
485 serde_json::Value::Number(value) => parse_decimal_exact(&value.to_string())?,
486 _ => anyhow::bail!("fee {field} must be a decimal number or numeric string"),
487 };
488 anyhow::ensure!(decimal >= Decimal::ZERO, "fee {field} must be non-negative");
489 Ok(decimal)
490}
491
492pub fn adjust_market_buy_amount(
513 amount: Decimal,
514 user_pusd_balance: Decimal,
515 price: Decimal,
516 fee_rate: Decimal,
517 fee_exponent: Decimal,
518 builder_taker_fee_rate: Decimal,
519) -> anyhow::Result<Decimal> {
520 if price <= Decimal::ZERO || price >= Decimal::ONE {
521 anyhow::bail!(
522 "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
523 );
524 }
525
526 let platform_fee_rate = fee_curve_rate(fee_rate, price, fee_exponent)?;
527
528 anyhow::ensure!(amount > Decimal::ZERO, "market-buy amount must be positive");
529 anyhow::ensure!(
530 user_pusd_balance > Decimal::ZERO,
531 "market-buy balance must be positive"
532 );
533 anyhow::ensure!(
534 builder_taker_fee_rate >= Decimal::ZERO,
535 "builder fee rate must be non-negative"
536 );
537 let platform_fee = amount
538 .checked_div(price)
539 .and_then(|shares| shares.checked_mul(platform_fee_rate))
540 .context("market-buy platform fee overflow")?;
541 let builder_fee = amount
542 .checked_mul(builder_taker_fee_rate)
543 .context("market-buy builder fee overflow")?;
544 let total_cost = amount
545 .checked_add(platform_fee)
546 .and_then(|cost| cost.checked_add(builder_fee))
547 .context("market-buy total cost overflow")?;
548
549 let raw = if user_pusd_balance <= total_cost {
550 let divisor = platform_fee_rate
551 .checked_div(price)
552 .and_then(|rate| Decimal::ONE.checked_add(rate))
553 .and_then(|rate| rate.checked_add(builder_taker_fee_rate))
554 .context("market-buy fee divisor overflow")?;
555 user_pusd_balance
556 .checked_div(divisor)
557 .context("market-buy adjustment overflow")?
558 } else {
559 amount
560 };
561
562 let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
563 if adjusted.is_zero() {
564 anyhow::bail!(
565 "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
566 fee-adjusted amount truncated to zero"
567 );
568 }
569 Ok(adjusted)
570}
571
572pub fn compute_commission(
591 fee_rate: Decimal,
592 fee_exponent: Decimal,
593 size: Decimal,
594 price: Decimal,
595 liquidity_side: LiquiditySide,
596) -> anyhow::Result<Decimal> {
597 anyhow::ensure!(size >= Decimal::ZERO, "fee quantity must be non-negative");
598 let rate = fee_curve_rate(fee_rate, price, fee_exponent)?;
599
600 if liquidity_side != LiquiditySide::Taker {
601 return Ok(Decimal::ZERO);
602 }
603 let commission = size
604 .checked_mul(rate)
605 .context("commission calculation overflow")?;
606 Ok(commission.round_dp_with_strategy(5, RoundingStrategy::ToZero))
607}
608
609fn fee_curve_rate(
610 fee_rate: Decimal,
611 price: Decimal,
612 fee_exponent: Decimal,
613) -> anyhow::Result<Decimal> {
614 anyhow::ensure!(fee_rate >= Decimal::ZERO, "fee rate must be non-negative");
615
616 let Some(mut exponent) = fee_exponent
617 .to_u64()
618 .filter(|_| fee_exponent.fract().is_zero())
619 else {
620 anyhow::bail!("fee exponent must be a non-negative whole number, was {fee_exponent}");
621 };
622
623 anyhow::ensure!(
624 (Decimal::ZERO..=Decimal::ONE).contains(&price),
625 "fee price must be in [0, 1]"
626 );
627
628 if fee_rate.is_zero() {
629 return Ok(Decimal::ZERO);
630 }
631
632 let mut base = price * (Decimal::ONE - price);
634 let mut curve = Decimal::ONE;
635
636 while exponent > 0 {
637 if exponent & 1 == 1 {
638 curve *= base;
639 }
640
641 exponent >>= 1;
642 base *= base;
643 }
644
645 fee_rate
646 .checked_mul(curve)
647 .context("fee curve calculation overflow")
648}
649
650pub(crate) fn sum_filled_quantity(fills: &[FillReport]) -> Decimal {
652 fills.iter().map(|f| f.last_qty.as_decimal()).sum()
653}
654
655pub(crate) fn weighted_average_price(
658 fills: &[FillReport],
659 total_filled: Decimal,
660) -> Option<Decimal> {
661 if total_filled.is_zero() {
662 return None;
663 }
664 let weighted: Decimal = fills
665 .iter()
666 .map(|f| f.last_qty.as_decimal() * f.last_px.as_decimal())
667 .sum();
668 Some(weighted / total_filled)
669}
670
671pub(crate) fn recovered_terminal_order_status(
676 time_in_force: TimeInForce,
677 quantity: Quantity,
678 filled_qty: Quantity,
679) -> OrderStatus {
680 if time_in_force == TimeInForce::Ioc && filled_qty < quantity {
681 return OrderStatus::Canceled;
682 }
683
684 let dust_diff = (quantity.as_decimal() - filled_qty.as_decimal()).abs();
685 if filled_qty >= quantity || dust_diff < DUST_SNAP_THRESHOLD_DEC {
686 OrderStatus::Filled
687 } else {
688 OrderStatus::Canceled
689 }
690}
691
692pub(crate) fn snap_filled_qty_to_quantity(
699 quantity: Quantity,
700 filled_qty: Quantity,
701 order_status: OrderStatus,
702) -> Quantity {
703 if order_status != OrderStatus::Filled {
704 return filled_qty;
705 }
706 let diff = quantity.as_decimal() - filled_qty.as_decimal();
707 if diff > Decimal::ZERO && diff < DUST_SNAP_THRESHOLD_DEC {
708 quantity
709 } else {
710 filled_qty
711 }
712}
713
714const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
716
717pub fn parse_balance_allowance(
722 balance_raw: Decimal,
723 currency: Currency,
724) -> anyhow::Result<AccountBalance> {
725 let balance_pusd = balance_raw / USDC_SCALE;
726 AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
727 .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
728}
729
730#[derive(Debug)]
732pub struct MarketPriceResult {
733 pub crossing_price: Decimal,
735 pub expected_base_qty: Decimal,
737}
738
739pub fn calculate_market_price(
754 book_levels: &[ClobBookLevel],
755 amount: Decimal,
756 side: PolymarketOrderSide,
757) -> anyhow::Result<MarketPriceResult> {
758 if book_levels.is_empty() {
759 anyhow::bail!("Empty order book: no liquidity available for market order");
760 }
761
762 anyhow::ensure!(amount > Decimal::ZERO, "market amount must be positive");
765 let mut parsed_levels = Vec::with_capacity(book_levels.len());
766 for level in book_levels {
767 let price = parse_decimal_exact(&level.price).context("invalid market-book price")?;
768 let size = parse_decimal_exact(&level.size).context("invalid market-book size")?;
769 anyhow::ensure!(
770 price > Decimal::ZERO && price < Decimal::ONE,
771 InvalidMarketPriceError("market-book price must be in (0, 1)".to_string())
772 );
773 anyhow::ensure!(
774 size >= Decimal::ZERO,
775 "market-book size must be non-negative"
776 );
777
778 if !size.is_zero() {
779 parsed_levels.push((price, size));
780 }
781 }
782
783 if parsed_levels.is_empty() {
784 anyhow::bail!("Empty order book: no valid price levels for market order");
785 }
786
787 match side {
788 PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
789 PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
790 }
791
792 let mut remaining = amount;
793 let mut last_price = Decimal::ZERO;
794 let mut total_base_qty = Decimal::ZERO;
795
796 for &(price, size) in &parsed_levels {
797 last_price = price;
798
799 match side {
800 PolymarketOrderSide::Buy => {
801 let level_usdc = size
802 .checked_mul(price)
803 .context("market-book notional overflow")?;
804 let consumed_usdc = level_usdc.min(remaining);
805 let shares_at_level = consumed_usdc
806 .checked_div(price)
807 .context("market-book quantity overflow")?;
808 total_base_qty = total_base_qty
809 .checked_add(shares_at_level)
810 .context("market-book quantity overflow")?;
811 remaining -= consumed_usdc;
812 }
813 PolymarketOrderSide::Sell => {
814 let consumed_shares = size.min(remaining);
815 total_base_qty = total_base_qty
816 .checked_add(consumed_shares)
817 .context("market-book quantity overflow")?;
818 remaining -= consumed_shares;
819 }
820 }
821
822 if remaining <= Decimal::ZERO {
823 return Ok(MarketPriceResult {
824 crossing_price: last_price,
825 expected_base_qty: total_base_qty,
826 });
827 }
828 }
829
830 Ok(MarketPriceResult {
833 crossing_price: last_price,
834 expected_base_qty: total_base_qty,
835 })
836}
837
838#[derive(Debug, Error)]
839#[error("{0}")]
840pub(crate) struct InvalidMarketPriceError(pub(super) String);
841
842pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
847 if let Ok(n) = ts_str.parse::<u64>() {
848 return if n > 1_000_000_000_000 {
849 n.checked_mul(NANOSECONDS_IN_MILLISECOND)
850 .map(UnixNanos::from)
851 } else {
852 n.checked_mul(NANOSECONDS_IN_SECOND).map(UnixNanos::from)
853 };
854 }
855 let dt = ts_str.parse::<Timestamp>().ok()?;
856 Some(UnixNanos::from(u64::try_from(dt.as_nanosecond()).ok()?))
857}
858
859#[cfg(test)]
860mod tests {
861 use nautilus_execution::models::fee::{FeeModel, ProbabilityPriceFeeModel};
862 use nautilus_model::{
863 enums::{OrderSide, OrderType},
864 instruments::{Instrument, InstrumentAny, stubs::binary_option},
865 orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
866 };
867 use rstest::rstest;
868 use rust_decimal_macros::dec;
869 use ustr::Ustr;
870
871 use super::*;
872 use crate::common::enums::{
873 PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
874 };
875
876 #[rstest]
880 #[case::filled_underfill_dust(100.000000, 99.995000, OrderStatus::Filled, 100.000000)]
882 #[case::filled_overfill_dust(714.285710, 714.285714, OrderStatus::Filled, 714.285714)]
884 #[case::filled_underfill_at_band(100.000000, 99.990000, OrderStatus::Filled, 99.990000)]
886 #[case::filled_underfill_above_band(100.000000, 99.000000, OrderStatus::Filled, 99.000000)]
888 #[case::filled_exact(100.000000, 100.000000, OrderStatus::Filled, 100.000000)]
890 #[case::accepted_underfill_dust(100.000000, 99.995000, OrderStatus::Accepted, 99.995000)]
892 #[case::canceled_underfill_dust(100.000000, 99.995000, OrderStatus::Canceled, 99.995000)]
894 fn test_snap_filled_qty_to_quantity(
895 #[case] quantity: f64,
896 #[case] filled: f64,
897 #[case] status: OrderStatus,
898 #[case] expected: f64,
899 ) {
900 let snapped = snap_filled_qty_to_quantity(
901 Quantity::new(quantity, 6),
902 Quantity::new(filled, 6),
903 status,
904 );
905 assert_eq!(snapped, Quantity::new(expected, 6));
906 }
907
908 fn make_test_fill(qty: f64, px: f64) -> FillReport {
909 FillReport::new(
910 AccountId::from("POLY-001"),
911 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
912 VenueOrderId::from("0xabc"),
913 TradeId::from("trade-1"),
914 OrderSide::Buy,
915 Quantity::new(qty, 4),
916 Price::new(px, 4),
917 Money::zero(Currency::pUSD()),
918 LiquiditySide::Taker,
919 None,
920 None,
921 UnixNanos::default(),
922 UnixNanos::default(),
923 None,
924 )
925 }
926
927 fn binary_option_fill_order(
928 instrument: &InstrumentAny,
929 liquidity_side: LiquiditySide,
930 price: &str,
931 ) -> OrderAny {
932 let limit_order = OrderTestBuilder::new(OrderType::Limit)
933 .instrument_id(instrument.id())
934 .side(OrderSide::Buy)
935 .price(Price::from(price))
936 .quantity(Quantity::from("100.00"))
937 .build();
938
939 TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
940 }
941
942 #[rstest]
943 fn test_sum_filled_quantity_empty() {
944 assert_eq!(sum_filled_quantity(&[]), Decimal::ZERO);
945 }
946
947 #[rstest]
948 fn test_sum_filled_quantity_multiple() {
949 let fills = vec![
950 make_test_fill(2.5, 0.50),
951 make_test_fill(1.0, 0.60),
952 make_test_fill(3.0, 0.55),
953 ];
954 assert_eq!(sum_filled_quantity(&fills), dec!(6.5));
955 }
956
957 #[rstest]
958 fn test_weighted_average_price_zero_total_returns_none() {
959 assert!(weighted_average_price(&[], Decimal::ZERO).is_none());
960 }
961
962 #[rstest]
963 fn test_weighted_average_price_single_fill() {
964 let fills = vec![make_test_fill(10.0, 0.5)];
965 let total = sum_filled_quantity(&fills);
966 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.5)));
967 }
968
969 #[rstest]
970 fn test_weighted_average_price_weighted_by_quantity() {
971 let fills = vec![make_test_fill(2.0, 0.40), make_test_fill(8.0, 0.60)];
973 let total = sum_filled_quantity(&fills);
974 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.56)));
975 }
976
977 #[rstest]
978 #[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))]
982 #[case(dec!(123_456_789), dec!(123.456789))]
983 #[case(dec!(12345678901123456), dec!(12345678901.123456))]
984 fn test_parse_balance_allowance(#[case] raw: Decimal, #[case] expected: Decimal) {
985 let currency = Currency::pUSD();
986 let balance = parse_balance_allowance(raw, currency).unwrap();
987 assert_eq!(
988 balance.total,
989 Money::from_decimal(expected, currency).unwrap()
990 );
991 assert_eq!(balance.free, balance.total);
992 assert_eq!(
993 balance.locked,
994 Money::from_decimal(Decimal::ZERO, currency).unwrap()
995 );
996 }
997
998 #[rstest]
999 #[case::crypto_p50("0.07", "0.50", dec!(1.75))]
1000 #[case::crypto_p01("0.07", "0.01", dec!(0.0693))]
1001 #[case::crypto_p05("0.07", "0.05", dec!(0.3325))]
1002 #[case::crypto_p10("0.07", "0.10", dec!(0.63))]
1003 #[case::crypto_p30("0.07", "0.30", dec!(1.47))]
1004 #[case::crypto_p70("0.07", "0.70", dec!(1.47))]
1005 #[case::crypto_p90("0.07", "0.90", dec!(0.63))]
1006 #[case::crypto_p99("0.07", "0.99", dec!(0.0693))]
1007 #[case::sports_p50("0.05", "0.50", dec!(1.25))]
1008 #[case::sports_p30("0.05", "0.30", dec!(1.05))]
1009 #[case::sports_p70("0.05", "0.70", dec!(1.05))]
1010 #[case::politics_p50("0.04", "0.50", dec!(1.0))]
1011 #[case::politics_p30("0.04", "0.30", dec!(0.84))]
1012 #[case::economics_p50("0.05", "0.50", dec!(1.25))]
1013 #[case::economics_p30("0.05", "0.30", dec!(1.05))]
1014 #[case::geopolitics_p50("0", "0.50", dec!(0.0))]
1015 fn test_compute_commission_docs_table(
1016 #[case] fee_rate: &str,
1017 #[case] price: &str,
1018 #[case] expected: Decimal,
1019 ) {
1020 let commission = compute_commission(
1021 Decimal::from_str_exact(fee_rate).unwrap(),
1022 dec!(1),
1023 dec!(100),
1024 Decimal::from_str_exact(price).unwrap(),
1025 LiquiditySide::Taker,
1026 )
1027 .unwrap();
1028 assert_eq!(commission, expected);
1029 }
1030
1031 #[rstest]
1032 fn test_compute_commission_issue_3860_strategy_buy() {
1033 let commission = compute_commission(
1037 dec!(0.072),
1038 dec!(1),
1039 Decimal::from_str_exact("15.463900").unwrap(),
1040 dec!(0.97),
1041 LiquiditySide::Taker,
1042 )
1043 .unwrap();
1044 assert_eq!(commission, dec!(0.03239));
1045 }
1046
1047 #[rstest]
1048 fn test_compute_commission_issue_3860_reconciliation_sell() {
1049 let commission = compute_commission(
1054 dec!(0.072),
1055 dec!(1),
1056 Decimal::from_str_exact("0.033400").unwrap(),
1057 dec!(0.98),
1058 LiquiditySide::Taker,
1059 )
1060 .unwrap();
1061 assert_eq!(commission, dec!(0.00004));
1062 }
1063
1064 #[rstest]
1066 #[case::exponent_zero(dec!(0), dec!(0.07))]
1067 #[case::exponent_one(dec!(1), dec!(0.0075720708))]
1068 #[case::exponent_two(dec!(2), dec!(0.000819089374288752))]
1069 #[case::exponent_three(dec!(3), dec!(0.00008860289619488756839488))]
1070 fn test_fee_curve_rate_is_exact(#[case] exponent: Decimal, #[case] expected: Decimal) {
1071 let rate = fee_curve_rate(dec!(0.07), dec!(0.1234), exponent).unwrap();
1072
1073 assert_eq!(rate, expected);
1074 }
1075
1076 #[rstest]
1077 fn test_adjust_market_buy_amount_uses_exact_fee_curve() {
1078 let adjusted = adjust_market_buy_amount(
1082 dec!(2),
1083 dec!(1.0002253055572509765625),
1084 dec!(0.0625),
1085 dec!(0.07),
1086 dec!(3),
1087 dec!(0),
1088 )
1089 .unwrap();
1090
1091 assert_eq!(adjusted, dec!(1.000000));
1092 }
1093
1094 #[rstest]
1095 fn test_compute_commission_maker_is_zero() {
1096 let commission = compute_commission(
1097 Decimal::from_str_exact("0.072").unwrap(),
1098 dec!(1),
1099 dec!(100),
1100 Decimal::from_str_exact("0.50").unwrap(),
1101 LiquiditySide::Maker,
1102 )
1103 .unwrap();
1104 assert_eq!(commission, dec!(0));
1105 }
1106
1107 #[rstest]
1108 fn test_compute_commission_uses_fee_exponent() {
1109 let commission = compute_commission(
1110 dec!(0.04),
1111 dec!(2),
1112 dec!(10),
1113 dec!(0.5),
1114 LiquiditySide::Taker,
1115 )
1116 .unwrap();
1117 assert_eq!(commission, dec!(0.025));
1118 }
1119
1120 #[rstest]
1121 #[case::crypto_taker("0.07", "0.970", LiquiditySide::Taker)]
1122 #[case::sports_taker("0.05", "0.500", LiquiditySide::Taker)]
1123 #[case::politics_taker("0.04", "0.300", LiquiditySide::Taker)]
1124 #[case::maker_zero("0.03", "0.500", LiquiditySide::Maker)]
1125 fn test_probability_price_fee_model_matches_polymarket_commission(
1126 #[case] taker_fee: &str,
1127 #[case] price: &str,
1128 #[case] liquidity_side: LiquiditySide,
1129 ) {
1130 let binary = binary_option();
1131 let instrument = InstrumentAny::BinaryOption(binary);
1132 let order = binary_option_fill_order(&instrument, liquidity_side, price);
1133
1134 let fee_model = ProbabilityPriceFeeModel::new(
1135 Decimal::ZERO,
1136 Decimal::from_str_exact(taker_fee).unwrap(),
1137 );
1138
1139 let commission = fee_model
1140 .get_commission(
1141 &order,
1142 Quantity::from("100.00"),
1143 Price::from(price),
1144 &instrument,
1145 )
1146 .unwrap();
1147
1148 let expected = compute_commission(
1149 Decimal::from_str_exact(taker_fee).unwrap(),
1150 dec!(1),
1151 dec!(100),
1152 Decimal::from_str_exact(price).unwrap(),
1153 liquidity_side,
1154 )
1155 .unwrap();
1156
1157 assert_eq!(commission.as_decimal(), expected);
1158 }
1159
1160 #[rstest]
1171 fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
1172 let adjusted =
1176 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), dec!(1), dec!(0))
1177 .unwrap();
1178 assert_eq!(adjusted, dec!(10.000000));
1179 }
1180
1181 #[rstest]
1182 fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
1183 let adjusted = adjust_market_buy_amount(
1187 dec!(10),
1188 dec!(10.2),
1189 dec!(0.5),
1190 dec!(0.04),
1191 dec!(1),
1192 dec!(0),
1193 )
1194 .unwrap();
1195 assert_eq!(adjusted, dec!(10.000000));
1197 }
1198
1199 #[rstest]
1200 fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
1201 let adjusted =
1205 adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), dec!(1), dec!(0))
1206 .unwrap();
1207 assert_eq!(adjusted, dec!(5.000000));
1208 }
1209
1210 #[rstest]
1211 fn test_adjust_market_buy_amount_with_builder_fee() {
1212 let adjusted = adjust_market_buy_amount(
1218 dec!(10),
1219 dec!(10),
1220 dec!(0.5),
1221 dec!(0.04),
1222 dec!(1),
1223 dec!(0.001),
1224 )
1225 .unwrap();
1226 assert_eq!(adjusted, dec!(9.794319));
1227 }
1228
1229 #[rstest]
1230 fn test_adjust_market_buy_amount_crypto_fee_rate() {
1231 let adjusted = adjust_market_buy_amount(
1237 dec!(100),
1238 dec!(100),
1239 dec!(0.5),
1240 dec!(0.07),
1241 dec!(1),
1242 dec!(0),
1243 )
1244 .unwrap();
1245 assert_eq!(adjusted, dec!(96.618357));
1247 }
1248
1249 #[rstest]
1250 fn test_adjust_market_buy_amount_extreme_low_price() {
1251 let adjusted = adjust_market_buy_amount(
1258 dec!(10),
1259 dec!(10),
1260 dec!(0.001),
1261 dec!(0.04),
1262 dec!(1),
1263 dec!(0),
1264 )
1265 .unwrap();
1266 assert_eq!(adjusted, dec!(9.615754));
1268 }
1269
1270 #[rstest]
1271 fn test_adjust_market_buy_amount_integer_exponent_two() {
1272 let adjusted =
1279 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), dec!(2), dec!(0))
1280 .unwrap();
1281 assert_eq!(adjusted, dec!(9.950248));
1282 }
1283
1284 #[rstest]
1285 #[case::fractional(dec!(0.5))]
1286 #[case::negative(dec!(-1))]
1287 fn test_adjust_market_buy_amount_rejects_non_whole_exponent(#[case] exponent: Decimal) {
1288 let err =
1289 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), exponent, dec!(0))
1290 .unwrap_err();
1291
1292 assert_eq!(
1293 err.to_string(),
1294 format!("fee exponent must be a non-negative whole number, was {exponent}")
1295 );
1296 }
1297
1298 #[rstest]
1299 fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
1300 let adjusted =
1302 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), dec!(1), dec!(0))
1303 .unwrap();
1304 assert_eq!(adjusted, dec!(10.000000));
1305 }
1306
1307 #[rstest]
1308 fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
1309 let adjusted =
1311 adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), dec!(1), dec!(0))
1312 .unwrap();
1313 assert_eq!(adjusted, dec!(7.500000));
1314 }
1315
1316 #[rstest]
1317 fn test_adjust_market_buy_amount_balance_too_small_errors() {
1318 let err = adjust_market_buy_amount(
1322 dec!(10),
1323 dec!(0.0000001),
1324 dec!(0.5),
1325 dec!(0.04),
1326 dec!(1),
1327 dec!(0),
1328 )
1329 .unwrap_err();
1330 assert!(err.to_string().contains("too small"));
1331 }
1332
1333 #[rstest]
1334 #[case::zero_price(dec!(0))]
1335 #[case::one_price(dec!(1))]
1336 #[case::negative_price(dec!(-0.1))]
1337 #[case::above_one_price(dec!(1.5))]
1338 fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
1339 let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), dec!(1), dec!(0))
1340 .unwrap_err();
1341 assert!(
1342 err.to_string().contains("invalid market-buy price"),
1343 "expected price-domain error, was {err}",
1344 );
1345 }
1346
1347 #[rstest]
1348 fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
1349 let adjusted = adjust_market_buy_amount(
1352 dec!(10),
1353 dec!(9.123456789),
1354 dec!(0.5),
1355 dec!(0.04),
1356 dec!(1),
1357 dec!(0),
1358 )
1359 .unwrap();
1360 assert_eq!(adjusted, dec!(8.944565));
1361 assert_eq!(adjusted.scale(), 6);
1362 }
1363
1364 fn calc_platform_fee_sdk(
1372 amount: Decimal,
1373 price: Decimal,
1374 rate: Decimal,
1375 exponent: u32,
1376 ) -> Decimal {
1377 let base = price * (Decimal::ONE - price);
1378 let curve = (0..exponent).fold(Decimal::ONE, |curve, _| curve * base);
1379 (amount / price) * rate * curve
1380 }
1381
1382 fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
1384 amount * rate
1385 }
1386
1387 fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
1388 let diff = (actual - expected).abs();
1389 assert!(
1390 diff <= tol,
1391 "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
1392 );
1393 }
1394
1395 #[rstest]
1396 fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
1397 let result = adjust_market_buy_amount(
1399 dec!(100),
1400 dec!(1000),
1401 dec!(0.5),
1402 dec!(0.02),
1403 dec!(1),
1404 dec!(0),
1405 )
1406 .unwrap();
1407 assert_eq!(result, dec!(100));
1408 }
1409
1410 #[rstest]
1411 fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
1412 let result = adjust_market_buy_amount(
1414 dec!(100),
1415 dec!(100),
1416 dec!(0.5),
1417 dec!(0.02),
1418 dec!(1),
1419 dec!(0),
1420 )
1421 .unwrap();
1422 assert!(result < dec!(100));
1423 assert!(result > dec!(0));
1424 }
1425
1426 #[rstest]
1427 fn test_sdk_adjust_market_buy_with_builder_fee() {
1428 let result = adjust_market_buy_amount(
1430 dec!(100),
1431 dec!(100),
1432 dec!(0.5),
1433 dec!(0),
1434 dec!(1),
1435 dec!(0.005),
1436 )
1437 .unwrap();
1438 let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
1440 assert_eq!(result, expected);
1441 }
1442
1443 #[rstest]
1444 fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
1445 let err = adjust_market_buy_amount(
1447 dec!(100),
1448 dec!(0.0000001),
1449 dec!(0.5),
1450 dec!(0.02),
1451 dec!(1),
1452 dec!(0.005),
1453 )
1454 .unwrap_err();
1455 assert!(err.to_string().contains("truncated to zero"));
1456 }
1457
1458 #[rstest]
1459 fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
1460 let amount = dec!(50);
1463 let price = dec!(0.5);
1464 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1465 let balance = amount + fee + dec!(1);
1466 let result =
1467 adjust_market_buy_amount(amount, balance, price, dec!(0.25), dec!(2), dec!(0)).unwrap();
1468 assert_eq!(result, amount);
1469 }
1470
1471 #[rstest]
1472 fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
1473 let amount = dec!(50);
1477 let price = dec!(0.5);
1478 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1479 let total_cost = amount + fee;
1480 let result =
1481 adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), dec!(2), dec!(0))
1482 .unwrap();
1483 close_to(result, amount, dec!(0.000001));
1484 }
1485
1486 #[rstest]
1487 fn test_sdk_adjust_buy_conserves_notional_platform_only() {
1488 let amount = dec!(50);
1491 let price = dec!(0.5);
1492 let adjusted =
1493 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), dec!(0)).unwrap();
1494 let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1495 close_to(adjusted + fee, amount, dec!(0.000001));
1496 assert!(adjusted < amount);
1497 }
1498
1499 #[rstest]
1500 fn test_sdk_adjust_buy_conserves_notional_builder_only() {
1501 let amount = dec!(50);
1503 let price = dec!(0.5);
1504 let builder_rate = dec!(0.01);
1505 let adjusted = adjust_market_buy_amount(
1506 amount,
1507 amount,
1508 price,
1509 dec!(0),
1510 Decimal::from(0u32),
1511 builder_rate,
1512 )
1513 .unwrap();
1514 let fee = calc_builder_fee_sdk(adjusted, builder_rate);
1515 close_to(adjusted + fee, amount, dec!(0.000001));
1516 }
1517
1518 #[rstest]
1519 fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
1520 let amount = dec!(50);
1522 let price = dec!(0.5);
1523 let builder_rate = dec!(0.01);
1524 let adjusted =
1525 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), builder_rate)
1526 .unwrap();
1527 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1528 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1529 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1530 }
1531
1532 #[rstest]
1533 fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
1534 let amount = dec!(30);
1536 let price = dec!(0.3);
1537 let builder_rate = dec!(0.02);
1538 let adjusted =
1539 adjust_market_buy_amount(amount, amount, price, dec!(0.25), dec!(2), builder_rate)
1540 .unwrap();
1541 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1542 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1543 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1544 }
1545
1546 #[rstest]
1547 fn test_parse_timestamp_ms() {
1548 let ts = parse_timestamp("1703875200000").unwrap();
1549 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1550 }
1551
1552 #[rstest]
1553 fn test_parse_timestamp_secs() {
1554 let ts = parse_timestamp("1703875200").unwrap();
1555 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1556 }
1557
1558 #[rstest]
1559 fn test_parse_timestamp_rfc3339() {
1560 let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1561 assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1562 }
1563
1564 #[rstest]
1565 fn test_parse_liquidity_side_maker() {
1566 assert_eq!(
1567 parse_liquidity_side(PolymarketLiquiditySide::Maker),
1568 LiquiditySide::Maker
1569 );
1570 }
1571
1572 #[rstest]
1573 fn test_parse_liquidity_side_taker() {
1574 assert_eq!(
1575 parse_liquidity_side(PolymarketLiquiditySide::Taker),
1576 LiquiditySide::Taker
1577 );
1578 }
1579
1580 #[rstest]
1581 fn test_parse_order_status_report_from_fixture() {
1582 let path = "test_data/http_open_order.json";
1583 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1584 let order: PolymarketOpenOrder =
1585 serde_json::from_str(&content).expect("Failed to parse test data");
1586
1587 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1588 let account_id = AccountId::from("POLYMARKET-001");
1589
1590 let report = parse_order_status_report(
1591 &order,
1592 instrument_id,
1593 account_id,
1594 None,
1595 4,
1596 6,
1597 UnixNanos::from(1_000_000_000u64),
1598 )
1599 .unwrap();
1600
1601 assert_eq!(report.account_id, account_id);
1602 assert_eq!(report.instrument_id, instrument_id);
1603 assert_eq!(report.client_order_id, None);
1604 assert_eq!(
1605 report.venue_order_id,
1606 VenueOrderId::from(
1607 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
1608 )
1609 );
1610 assert_eq!(report.order_side, Some(OrderSide::Buy));
1611 assert_eq!(report.order_type, OrderType::Limit);
1612 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1613 assert_eq!(report.order_status, OrderStatus::Accepted);
1614 assert_eq!(report.price, Some(Price::from("0.5000")));
1615 assert_eq!(report.price.unwrap().precision, 4);
1616 assert_eq!(report.quantity, Quantity::from("100.000000"));
1617 assert_eq!(report.quantity.precision, 6);
1618 assert_eq!(report.filled_qty, Quantity::from("25.000000"));
1619 assert_eq!(report.filled_qty.precision, 6);
1620 assert_eq!(
1621 report.ts_accepted,
1622 UnixNanos::from(1_703_875_200_000_000_000u64)
1623 );
1624 assert_eq!(
1625 report.ts_last,
1626 UnixNanos::from(1_703_875_200_000_000_000u64)
1627 );
1628 assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1629 assert_eq!(report.expire_time, None);
1631 }
1632
1633 #[rstest]
1636 #[case::matched_underfill_dust(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.995000), dec!(100.000000), dec!(100.000000))]
1638 #[case::matched_overfill_dust(PolymarketOrderStatus::Matched, dec!(714.285710), dec!(714.285714), dec!(714.285710), dec!(714.285714))]
1640 #[case::live_underfill_dust(PolymarketOrderStatus::Live, dec!(100.000000), dec!(99.995000), dec!(100.000000), dec!(99.995000))]
1642 #[case::matched_real_partial(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.000000), dec!(100.000000), dec!(99.000000))]
1644 fn test_parse_order_status_report_snaps_dust_filled_qty(
1645 #[case] status: PolymarketOrderStatus,
1646 #[case] original_size: Decimal,
1647 #[case] size_matched: Decimal,
1648 #[case] expected_qty: Decimal,
1649 #[case] expected_filled: Decimal,
1650 ) {
1651 let order = PolymarketOpenOrder {
1652 associate_trades: None,
1653 id: "0xid".to_string(),
1654 status,
1655 market: Ustr::from("0xm"),
1656 original_size,
1657 outcome: PolymarketOutcome::yes(),
1658 maker_address: "0xmaker".to_string(),
1659 owner: "owner".to_string(),
1660 price: dec!(0.5),
1661 side: PolymarketOrderSide::Buy,
1662 size_matched,
1663 asset_id: Ustr::from("token"),
1664 expiration: None,
1665 order_type: PolymarketOrderType::GTC,
1666 created_at: 1_703_875_200,
1667 };
1668
1669 let report = parse_order_status_report(
1670 &order,
1671 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1672 AccountId::from("POLYMARKET-001"),
1673 None,
1674 3,
1675 6,
1676 UnixNanos::from(1_000_000_000u64),
1677 )
1678 .unwrap();
1679
1680 assert_eq!(report.quantity.as_decimal(), expected_qty);
1681 assert_eq!(report.filled_qty.as_decimal(), expected_filled);
1682 }
1683
1684 #[rstest]
1687 #[case::gtc_real_partial(PolymarketOrderType::GTC, dec!(10), dec!(7), OrderStatus::Canceled)]
1689 #[case::gtc_dust_underfill(PolymarketOrderType::GTC, dec!(100), dec!(99.997714), OrderStatus::Filled)]
1691 #[case::gtc_exact(PolymarketOrderType::GTC, dec!(10), dec!(10), OrderStatus::Filled)]
1693 #[case::fak_partial(PolymarketOrderType::FAK, dec!(30), dec!(20), OrderStatus::Canceled)]
1695 fn test_parse_order_status_report_matched_resolves_terminal_status(
1696 #[case] order_type: PolymarketOrderType,
1697 #[case] original_size: Decimal,
1698 #[case] size_matched: Decimal,
1699 #[case] expected_status: OrderStatus,
1700 ) {
1701 let order = PolymarketOpenOrder {
1702 associate_trades: None,
1703 id: "0xterminal".to_string(),
1704 status: PolymarketOrderStatus::Matched,
1705 market: Ustr::from("0xmarket"),
1706 original_size,
1707 outcome: PolymarketOutcome::yes(),
1708 maker_address: "0xmaker".to_string(),
1709 owner: "owner".to_string(),
1710 price: dec!(0.5),
1711 side: PolymarketOrderSide::Buy,
1712 size_matched,
1713 asset_id: Ustr::from("token"),
1714 expiration: None,
1715 order_type,
1716 created_at: 1_784_118_677,
1717 };
1718
1719 let report = parse_order_status_report(
1720 &order,
1721 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1722 AccountId::from("POLYMARKET-001"),
1723 None,
1724 3,
1725 6,
1726 UnixNanos::from(1_000_000_000u64),
1727 )
1728 .unwrap();
1729
1730 assert_eq!(report.order_status, expected_status);
1731 if report.order_status == OrderStatus::Filled {
1732 assert!(
1733 report.filled_qty >= report.quantity,
1734 "a Filled report must not carry filled_qty < quantity, was filled_qty={} quantity={}",
1735 report.filled_qty,
1736 report.quantity
1737 );
1738 }
1739 }
1740
1741 #[rstest]
1742 fn test_parse_order_status_report_maps_partial_fak_match_to_canceled() {
1743 let order = PolymarketOpenOrder {
1744 associate_trades: Some(vec!["trade-partial-fak".to_string()]),
1745 id: "0xpartial-fak".to_string(),
1746 status: PolymarketOrderStatus::Matched,
1747 market: Ustr::from("0xmarket"),
1748 original_size: dec!(30),
1749 outcome: PolymarketOutcome::yes(),
1750 maker_address: "0xmaker".to_string(),
1751 owner: "owner".to_string(),
1752 price: dec!(0.093),
1753 side: PolymarketOrderSide::Buy,
1754 size_matched: dec!(20),
1755 asset_id: Ustr::from("token"),
1756 expiration: Some("0".to_string()),
1757 order_type: PolymarketOrderType::FAK,
1758 created_at: 1_784_118_677,
1759 };
1760
1761 let report = parse_order_status_report(
1762 &order,
1763 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1764 AccountId::from("POLYMARKET-001"),
1765 None,
1766 3,
1767 6,
1768 UnixNanos::from(1_000_000_000u64),
1769 )
1770 .unwrap();
1771
1772 assert_eq!(report.order_status, OrderStatus::Canceled);
1773 assert_eq!(report.time_in_force, TimeInForce::Ioc);
1774 assert_eq!(report.quantity, Quantity::from("30.000000"));
1775 assert_eq!(report.filled_qty, Quantity::from("20.000000"));
1776 }
1777
1778 #[rstest]
1779 #[case::null(None, None)]
1780 #[case::zero_string(Some("0"), None)]
1781 #[case::empty_string(Some(""), None)]
1782 #[case::garbage(Some("not-a-number"), None)]
1783 #[case::positive_seconds(
1784 Some("1735689600"),
1785 Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1786 )]
1787 fn test_parse_order_status_report_expiration(
1788 #[case] raw: Option<&str>,
1789 #[case] expected: Option<UnixNanos>,
1790 ) {
1791 let order = PolymarketOpenOrder {
1792 associate_trades: None,
1793 id: "0xid".to_string(),
1794 status: PolymarketOrderStatus::Live,
1795 market: Ustr::from("0xm"),
1796 original_size: dec!(100),
1797 outcome: PolymarketOutcome::yes(),
1798 maker_address: "0xmaker".to_string(),
1799 owner: "owner".to_string(),
1800 price: dec!(0.5),
1801 side: PolymarketOrderSide::Buy,
1802 size_matched: dec!(0),
1803 asset_id: Ustr::from("token"),
1804 expiration: raw.map(|s| s.to_string()),
1805 order_type: PolymarketOrderType::GTD,
1806 created_at: 1_703_875_200,
1807 };
1808
1809 let report = parse_order_status_report(
1810 &order,
1811 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1812 AccountId::from("POLYMARKET-001"),
1813 None,
1814 4,
1815 6,
1816 UnixNanos::from(1_000_000_000u64),
1817 )
1818 .unwrap();
1819
1820 assert_eq!(report.expire_time, expected);
1821 }
1822
1823 #[rstest]
1824 #[case::price(Decimal::MAX, dec!(10), dec!(0))]
1825 #[case::quantity(dec!(0.5), Decimal::MAX, dec!(0))]
1826 #[case::filled(dec!(0.5), dec!(10), Decimal::MAX)]
1827 fn test_order_report_rejects_unrepresentable_values(
1828 #[case] price: Decimal,
1829 #[case] quantity: Decimal,
1830 #[case] filled: Decimal,
1831 ) {
1832 let mut order: PolymarketOpenOrder =
1833 serde_json::from_str(include_str!("../../test_data/http_open_order.json")).unwrap();
1834 order.price = price;
1835 order.original_size = quantity;
1836 order.size_matched = filled;
1837 assert!(
1838 parse_order_status_report(
1839 &order,
1840 InstrumentId::from("TEST.POLYMARKET"),
1841 AccountId::from("POLY-001"),
1842 None,
1843 4,
1844 6,
1845 UnixNanos::default()
1846 )
1847 .is_err()
1848 );
1849 }
1850
1851 #[rstest]
1852 #[case::negative_rate(dec!(-0.1), dec!(1), dec!(10), dec!(0.5), "fee rate must be non-negative")]
1853 #[case::negative_exponent(dec!(0.1), dec!(-1), dec!(10), dec!(0.5), "fee exponent must be a non-negative whole number, was -1")]
1854 #[case::fractional_exponent(dec!(0.1), dec!(1.5), dec!(10), dec!(0.5), "fee exponent must be a non-negative whole number, was 1.5")]
1855 #[case::negative_size(dec!(0.1), dec!(1), dec!(-10), dec!(0.5), "fee quantity must be non-negative")]
1856 #[case::invalid_price(dec!(0.1), dec!(1), dec!(10), dec!(1.1), "fee price must be in [0, 1]")]
1857 #[case::overflow(Decimal::MAX, dec!(0), dec!(10), dec!(0.5), "commission calculation overflow")]
1858 fn test_commission_rejects_invalid_values(
1859 #[case] rate: Decimal,
1860 #[case] exponent: Decimal,
1861 #[case] size: Decimal,
1862 #[case] price: Decimal,
1863 #[case] expected: &str,
1864 ) {
1865 let error =
1866 compute_commission(rate, exponent, size, price, LiquiditySide::Taker).unwrap_err();
1867
1868 assert_eq!(error.to_string(), expected);
1869 }
1870
1871 #[rstest]
1872 #[case::negative_amount(dec!(-1), dec!(10), dec!(0), "market-buy amount must be positive")]
1873 #[case::zero_amount(dec!(0), dec!(10), dec!(0), "market-buy amount must be positive")]
1874 #[case::negative_balance(dec!(1), dec!(-10), dec!(0), "market-buy balance must be positive")]
1875 #[case::zero_balance(dec!(1), dec!(0), dec!(0), "market-buy balance must be positive")]
1876 #[case::negative_builder(dec!(1), dec!(10), dec!(-0.1), "builder fee rate must be non-negative")]
1877 #[case::overflow(Decimal::MAX, Decimal::MAX, dec!(1), "market-buy platform fee overflow")]
1878 fn test_market_buy_rejects_invalid_values(
1879 #[case] amount: Decimal,
1880 #[case] balance: Decimal,
1881 #[case] builder: Decimal,
1882 #[case] expected: &str,
1883 ) {
1884 let error = adjust_market_buy_amount(
1885 amount,
1886 balance,
1887 dec!(0.5),
1888 dec!(0.07),
1889 Decimal::ONE,
1890 builder,
1891 )
1892 .unwrap_err();
1893
1894 assert_eq!(error.to_string(), expected);
1895 }
1896
1897 #[rstest]
1898 fn test_fee_exponent_preserves_exact_metadata_and_rejects_invalid_metadata() {
1899 let mut binary = binary_option();
1900 let mut info = nautilus_core::Params::new();
1901 info.insert(
1902 "fee_schedule".into(),
1903 serde_json::json!({"exponent": "1.234567890123456789012345678"}),
1904 );
1905 binary.info = Some(info.clone());
1906 assert_eq!(
1907 instrument_fee_exponent(&InstrumentAny::BinaryOption(binary.clone())).unwrap(),
1908 dec!(1.234567890123456789012345678)
1909 );
1910
1911 for value in [
1912 serde_json::Value::Null,
1913 serde_json::json!("bad"),
1914 serde_json::json!(-1),
1915 ] {
1916 info.insert(
1917 "fee_schedule".into(),
1918 serde_json::json!({"exponent": value}),
1919 );
1920 binary.info = Some(info.clone());
1921 assert!(instrument_fee_exponent(&InstrumentAny::BinaryOption(binary.clone())).is_err());
1922 }
1923 info.insert("fee_schedule".into(), serde_json::json!({"exponent": 2}));
1924 binary.info = Some(info);
1925 assert_eq!(
1926 instrument_fee_exponent(&InstrumentAny::BinaryOption(binary)).unwrap(),
1927 dec!(2)
1928 );
1929 }
1930
1931 #[rstest]
1932 fn test_parse_fill_report_errors_when_commission_is_unrepresentable() {
1933 let path = "test_data/http_trade_report.json";
1934 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1935 let trade: PolymarketTradeReport =
1936 serde_json::from_str(&content).expect("Failed to parse test data");
1937
1938 let result = parse_fill_report(
1939 &trade,
1940 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1941 AccountId::from("POLYMARKET-001"),
1942 None,
1943 4,
1944 6,
1945 Currency::pUSD(),
1946 Decimal::from_i128_with_scale(100_000_000_000_000_000_000_000_000i128, 0),
1949 Decimal::ONE,
1950 UnixNanos::from(1_000_000_000u64),
1951 );
1952
1953 assert!(
1954 result.is_err(),
1955 "an unrepresentable commission must surface as an error rather than panicking"
1956 );
1957 }
1958
1959 #[rstest]
1960 fn test_parse_fill_report_from_fixture() {
1961 let path = "test_data/http_trade_report.json";
1962 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1963 let trade: PolymarketTradeReport =
1964 serde_json::from_str(&content).expect("Failed to parse test data");
1965
1966 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1967 let account_id = AccountId::from("POLYMARKET-001");
1968 let currency = Currency::pUSD();
1969
1970 let report = parse_fill_report(
1971 &trade,
1972 instrument_id,
1973 account_id,
1974 None,
1975 4,
1976 6,
1977 currency,
1978 Decimal::ZERO,
1979 dec!(1),
1980 UnixNanos::from(1_000_000_000u64),
1981 )
1982 .expect("fixture commission is representable");
1983
1984 assert_eq!(report.account_id, account_id);
1985 assert_eq!(report.instrument_id, instrument_id);
1986 assert_eq!(report.order_side, OrderSide::Buy);
1987 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1988 assert_eq!(report.commission.as_decimal(), dec!(0.0));
1989 }
1990
1991 #[rstest]
1992 fn test_parse_fill_report_forwards_fee_schedule() {
1993 let path = "test_data/http_trade_report.json";
1994 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1995 let trade: PolymarketTradeReport =
1996 serde_json::from_str(&content).expect("Failed to parse test data");
1997
1998 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1999 let account_id = AccountId::from("POLYMARKET-001");
2000 let currency = Currency::pUSD();
2001
2002 let report = parse_fill_report(
2004 &trade,
2005 instrument_id,
2006 account_id,
2007 None,
2008 4,
2009 6,
2010 currency,
2011 dec!(0.03),
2012 dec!(2),
2013 UnixNanos::from(1_000_000_000u64),
2014 )
2015 .expect("fixture commission is representable");
2016
2017 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2018 assert_eq!(report.commission.as_decimal(), dec!(0.04687));
2019 }
2020
2021 #[rstest]
2023 fn test_parse_fill_report_matches_chain_taker_fills() {
2024 let content = std::fs::read_to_string("test_data/chain_order_filled_taker_fees.json")
2025 .expect("Failed to read test data");
2026 let vectors: Vec<serde_json::Value> =
2027 serde_json::from_str(&content).expect("Failed to parse test data");
2028 let content = std::fs::read_to_string("test_data/http_trade_report.json")
2029 .expect("Failed to read test data");
2030 let base_trade: PolymarketTradeReport =
2031 serde_json::from_str(&content).expect("Failed to parse test data");
2032 let wire_scale = dec!(1_000_000);
2033
2034 for vector in &vectors {
2035 let field =
2036 |name: &str| Decimal::from_str_exact(vector[name].as_str().unwrap()).unwrap();
2037
2038 let (side, shares) = match vector["side"].as_str().unwrap() {
2039 "BUY" => (
2040 PolymarketOrderSide::Buy,
2041 field("taker_amount_filled") / wire_scale,
2042 ),
2043 _ => (
2044 PolymarketOrderSide::Sell,
2045 field("maker_amount_filled") / wire_scale,
2046 ),
2047 };
2048
2049 let mut trade = base_trade.clone();
2050 trade.side = side;
2051 trade.size = shares;
2052 trade.price = field("price");
2053 trade.trader_side = PolymarketLiquiditySide::Taker;
2054
2055 let report = parse_fill_report(
2056 &trade,
2057 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
2058 AccountId::from("POLYMARKET-001"),
2059 None,
2060 2,
2061 6,
2062 Currency::pUSD(),
2063 field("fee_rate"),
2064 dec!(1),
2065 UnixNanos::from(1_000_000_000u64),
2066 )
2067 .unwrap();
2068
2069 assert_eq!(report.last_qty.as_decimal(), shares, "{vector}");
2070 assert_eq!(
2071 report.commission.as_decimal(),
2072 field("fee") / wire_scale,
2073 "{vector}"
2074 );
2075 }
2076 }
2077
2078 #[rstest]
2079 fn test_instrument_taker_fee_reads_schedule_rate() {
2080 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
2081
2082 let path = "test_data/gamma_market_sports_market_money_line.json";
2083 let content = std::fs::read_to_string(path).expect("Failed to read test data");
2084 let market = serde_json::from_str(&content).expect("Failed to parse test data");
2085 let defs = parse_gamma_market(&market).unwrap();
2086 let instrument =
2087 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
2088
2089 assert_eq!(instrument_taker_fee(&instrument).unwrap(), dec!(0.03));
2090 assert_eq!(instrument_fee_exponent(&instrument).unwrap(), Decimal::ONE);
2091
2092 let bare = InstrumentAny::BinaryOption(binary_option());
2093 assert_eq!(instrument_taker_fee(&bare).unwrap(), Decimal::ZERO);
2094
2095 let mut binary = binary_option();
2096 let mut info = nautilus_core::Params::new();
2097 info.insert("fee_schedule".into(), serde_json::json!({"exponent": "1"}));
2098 binary.info = Some(info.clone());
2099 let missing = instrument_taker_fee(&InstrumentAny::BinaryOption(binary.clone()))
2100 .expect_err("a present schedule must include a rate");
2101 assert!(missing.to_string().contains("missing rate"), "{missing}");
2102
2103 info.insert(
2104 "fee_schedule".into(),
2105 serde_json::json!({"rate": "-0.01", "exponent": "1"}),
2106 );
2107 binary.info = Some(info);
2108 let negative = instrument_taker_fee(&InstrumentAny::BinaryOption(binary))
2109 .expect_err("a negative rate must be rejected");
2110 assert!(
2111 negative.to_string().contains("must be non-negative"),
2112 "{negative}"
2113 );
2114 }
2115
2116 #[rstest]
2117 #[case(
2118 PolymarketLiquiditySide::Taker,
2119 PolymarketOrderSide::Buy,
2120 "token_a",
2121 "token_b",
2122 OrderSide::Buy
2123 )]
2124 #[case(
2125 PolymarketLiquiditySide::Taker,
2126 PolymarketOrderSide::Sell,
2127 "token_a",
2128 "token_b",
2129 OrderSide::Sell
2130 )]
2131 #[case(
2132 PolymarketLiquiditySide::Maker,
2133 PolymarketOrderSide::Buy,
2134 "token_a",
2135 "token_b",
2136 OrderSide::Buy
2137 )]
2138 #[case(
2139 PolymarketLiquiditySide::Maker,
2140 PolymarketOrderSide::Buy,
2141 "token_a",
2142 "token_a",
2143 OrderSide::Sell
2144 )]
2145 #[case(
2146 PolymarketLiquiditySide::Maker,
2147 PolymarketOrderSide::Sell,
2148 "token_a",
2149 "token_a",
2150 OrderSide::Buy
2151 )]
2152 fn test_determine_order_side(
2153 #[case] trader_side: PolymarketLiquiditySide,
2154 #[case] trade_side: PolymarketOrderSide,
2155 #[case] taker_asset: &str,
2156 #[case] maker_asset: &str,
2157 #[case] expected: OrderSide,
2158 ) {
2159 let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
2160 assert_eq!(result, expected);
2161 }
2162
2163 #[rstest]
2164 fn test_make_composite_trade_id_basic() {
2165 let trade_id = "trade-abc123";
2166 let venue_order_id = "order-xyz789";
2167 let result = make_composite_trade_id(trade_id, venue_order_id);
2168 assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
2169 }
2170
2171 #[rstest]
2172 fn test_make_composite_trade_id_truncates_long_ids() {
2173 let trade_id = "a]".repeat(30);
2174 let venue_order_id = "b".repeat(20);
2175 let result = make_composite_trade_id(&trade_id, &venue_order_id);
2176 assert!(result.as_str().len() <= 36);
2177 }
2178
2179 #[rstest]
2180 fn test_make_composite_trade_id_short_venue_id() {
2181 let trade_id = "t123";
2182 let venue_order_id = "ab";
2183 let result = make_composite_trade_id(trade_id, venue_order_id);
2184 assert_eq!(result.as_str(), "t123-ab");
2185 }
2186
2187 #[rstest]
2188 fn test_make_composite_trade_id_uniqueness() {
2189 let id_a = make_composite_trade_id("same-trade", "order-aaa");
2190 let id_b = make_composite_trade_id("same-trade", "order-bbb");
2191 assert_ne!(id_a, id_b);
2192 }
2193
2194 #[rstest]
2197 fn test_calculate_market_price_buy_single_level() {
2198 let levels = vec![ClobBookLevel {
2199 price: "0.55".to_string(),
2200 size: "200.0".to_string(),
2201 }];
2202 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
2203 assert_eq!(result.crossing_price, dec!(0.55));
2204 assert!(result.expected_base_qty > dec!(90));
2206 }
2207
2208 #[rstest]
2209 fn test_calculate_market_price_buy_walks_multiple_levels() {
2210 let levels = vec![
2212 ClobBookLevel {
2213 price: "0.55".to_string(),
2214 size: "100.0".to_string(),
2215 },
2216 ClobBookLevel {
2217 price: "0.50".to_string(),
2218 size: "10.0".to_string(),
2219 },
2220 ClobBookLevel {
2221 price: "0.60".to_string(),
2222 size: "200.0".to_string(),
2223 },
2224 ];
2225 let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
2228 assert_eq!(result.crossing_price, dec!(0.55));
2229 let expected = dec!(10) + dec!(15) / dec!(0.55);
2230 assert_eq!(result.expected_base_qty, expected);
2231 }
2232
2233 #[rstest]
2234 fn test_calculate_market_price_buy_small_order_uses_best_ask() {
2235 let levels = vec![
2237 ClobBookLevel {
2238 price: "0.50".to_string(),
2239 size: "50.0".to_string(),
2240 },
2241 ClobBookLevel {
2242 price: "0.999".to_string(),
2243 size: "100.0".to_string(),
2244 },
2245 ClobBookLevel {
2246 price: "0.20".to_string(),
2247 size: "72.0".to_string(),
2248 },
2249 ];
2250 let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
2253 assert_eq!(result.crossing_price, dec!(0.20));
2254 assert_eq!(result.expected_base_qty, dec!(25)); }
2256
2257 #[rstest]
2258 fn test_calculate_market_price_sell_walks_levels() {
2259 let levels = vec![
2261 ClobBookLevel {
2262 price: "0.48".to_string(),
2263 size: "100.0".to_string(),
2264 },
2265 ClobBookLevel {
2266 price: "0.50".to_string(),
2267 size: "50.0".to_string(),
2268 },
2269 ];
2270 let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
2273 assert_eq!(result.crossing_price, dec!(0.48));
2274 assert_eq!(result.expected_base_qty, dec!(80));
2275 }
2276
2277 #[rstest]
2278 fn test_calculate_market_price_empty_book() {
2279 let levels: Vec<ClobBookLevel> = vec![];
2280 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
2281 assert!(result.is_err());
2282 }
2283
2284 #[rstest]
2285 #[case("bad", "10", "invalid market-book price")]
2286 #[case("0.5", "bad", "invalid market-book size")]
2287 #[case("0.5", "-10", "market-book size must be non-negative")]
2288 #[case("1.1", "10", "market-book price must be in (0, 1)")]
2289 fn test_market_book_rejects_invalid_values(
2290 #[case] price: &str,
2291 #[case] size: &str,
2292 #[case] expected: &str,
2293 ) {
2294 let levels = [ClobBookLevel {
2295 price: price.into(),
2296 size: size.into(),
2297 }];
2298 assert_eq!(
2299 calculate_market_price(&levels, dec!(1), PolymarketOrderSide::Buy)
2300 .unwrap_err()
2301 .to_string(),
2302 expected
2303 );
2304 }
2305
2306 #[rstest]
2307 fn test_calculate_market_price_all_zero_levels_returns_error() {
2308 let levels = vec![
2309 ClobBookLevel {
2310 price: "0".to_string(),
2311 size: "100.0".to_string(),
2312 },
2313 ClobBookLevel {
2314 price: "0.50".to_string(),
2315 size: "0".to_string(),
2316 },
2317 ];
2318 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
2319 assert!(result.is_err());
2320 }
2321
2322 #[rstest]
2323 fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
2324 let levels = vec![ClobBookLevel {
2325 price: "0.55".to_string(),
2326 size: "10.0".to_string(),
2327 }];
2328 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
2330 assert_eq!(result.crossing_price, dec!(0.55));
2331 assert_eq!(result.expected_base_qty, dec!(10)); }
2333
2334 #[rstest]
2335 fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
2336 let levels_ascending = vec![
2337 ClobBookLevel {
2338 price: "0.20".to_string(),
2339 size: "72.0".to_string(),
2340 },
2341 ClobBookLevel {
2342 price: "0.50".to_string(),
2343 size: "50.0".to_string(),
2344 },
2345 ClobBookLevel {
2346 price: "0.999".to_string(),
2347 size: "100.0".to_string(),
2348 },
2349 ];
2350 let levels_descending = vec![
2351 ClobBookLevel {
2352 price: "0.999".to_string(),
2353 size: "100.0".to_string(),
2354 },
2355 ClobBookLevel {
2356 price: "0.50".to_string(),
2357 size: "50.0".to_string(),
2358 },
2359 ClobBookLevel {
2360 price: "0.20".to_string(),
2361 size: "72.0".to_string(),
2362 },
2363 ];
2364 let levels_shuffled = vec![
2365 ClobBookLevel {
2366 price: "0.50".to_string(),
2367 size: "50.0".to_string(),
2368 },
2369 ClobBookLevel {
2370 price: "0.20".to_string(),
2371 size: "72.0".to_string(),
2372 },
2373 ClobBookLevel {
2374 price: "0.999".to_string(),
2375 size: "100.0".to_string(),
2376 },
2377 ];
2378
2379 let r1 =
2380 calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
2381 let r2 =
2382 calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
2383 let r3 =
2384 calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
2385
2386 assert_eq!(r1.crossing_price, r2.crossing_price);
2387 assert_eq!(r2.crossing_price, r3.crossing_price);
2388 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2389 assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
2390 }
2391
2392 #[rstest]
2393 fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
2394 let levels_a = vec![
2395 ClobBookLevel {
2396 price: "0.48".to_string(),
2397 size: "100.0".to_string(),
2398 },
2399 ClobBookLevel {
2400 price: "0.50".to_string(),
2401 size: "50.0".to_string(),
2402 },
2403 ];
2404 let levels_b = vec![
2405 ClobBookLevel {
2406 price: "0.50".to_string(),
2407 size: "50.0".to_string(),
2408 },
2409 ClobBookLevel {
2410 price: "0.48".to_string(),
2411 size: "100.0".to_string(),
2412 },
2413 ];
2414
2415 let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
2416 let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
2417
2418 assert_eq!(r1.crossing_price, r2.crossing_price);
2419 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2420 }
2421
2422 mod adjust_market_buy_amount_property_tests {
2423 use proptest::prelude::*;
2424 use rstest::rstest;
2425
2426 use super::*;
2427
2428 fn decimal_at_usdc_scale(micros: u64) -> Decimal {
2432 Decimal::new(micros as i64, USDC_DECIMALS)
2433 }
2434
2435 fn decimal_from_bps(bps: u32) -> Decimal {
2437 Decimal::new(i64::from(bps), 4)
2438 }
2439
2440 fn compute_total_cost(
2443 amount: Decimal,
2444 price: Decimal,
2445 fee_rate: Decimal,
2446 exponent: u32,
2447 builder: Decimal,
2448 ) -> Decimal {
2449 let base = price * (Decimal::ONE - price);
2450 let curve = (0..exponent).fold(Decimal::ONE, |curve, _| curve * base);
2451 let platform_fee_rate = fee_rate * curve;
2452 let platform_fee = amount / price * platform_fee_rate;
2453 amount + platform_fee + amount * builder
2454 }
2455
2456 proptest! {
2457 #[rstest]
2460 fn prop_adjust_market_buy_amount_is_deterministic(
2461 amount_micros in 1u64..=1_000_000_000_000u64,
2462 balance_micros in 1u64..=1_000_000_000_000u64,
2463 price_milli in 1u32..=999u32,
2464 fee_rate_bps in 0u32..=1_000u32,
2465 exponent in 0u32..=3u32,
2466 builder_bps in 0u32..=500u32,
2467 ) {
2468 let amount = decimal_at_usdc_scale(amount_micros);
2469 let balance = decimal_at_usdc_scale(balance_micros);
2470 let price = Decimal::new(i64::from(price_milli), 3);
2471 let fee_rate = decimal_from_bps(fee_rate_bps);
2472 let builder = decimal_from_bps(builder_bps);
2473 let fee_exponent = Decimal::from(exponent);
2474
2475 let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2476 let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2477 prop_assert_eq!(r1.is_ok(), r2.is_ok());
2478 if let (Ok(a), Ok(b)) = (r1, r2) {
2479 prop_assert_eq!(a, b);
2480 }
2481 }
2482
2483 #[rstest]
2488 fn prop_adjust_market_buy_amount_non_binding_returns_amount(
2489 amount_micros in 1u64..=1_000_000_000u64,
2490 price_milli in 1u32..=999u32,
2491 fee_rate_bps in 0u32..=1_000u32,
2492 exponent in 0u32..=3u32,
2493 builder_bps in 0u32..=500u32,
2494 ) {
2495 let amount = decimal_at_usdc_scale(amount_micros);
2496 let price = Decimal::new(i64::from(price_milli), 3);
2497 let fee_rate = decimal_from_bps(fee_rate_bps);
2498 let builder = decimal_from_bps(builder_bps);
2499 let fee_exponent = Decimal::from(exponent);
2500
2501 let total_cost =
2505 compute_total_cost(amount, price, fee_rate, exponent, builder);
2506 let balance = total_cost * Decimal::from(10);
2507
2508 let adjusted = adjust_market_buy_amount(
2509 amount, balance, price, fee_rate, fee_exponent, builder,
2510 )
2511 .expect("non-binding balance must yield Ok");
2512 prop_assert_eq!(
2513 adjusted, amount,
2514 "non-binding branch must return the input amount unchanged",
2515 );
2516 }
2517
2518 #[rstest]
2523 fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
2524 amount_micros in 1_000u64..=1_000_000_000u64,
2525 price_milli in 10u32..=990u32,
2526 fee_rate_bps in 0u32..=1_000u32,
2527 exponent in 0u32..=3u32,
2528 builder_bps in 0u32..=500u32,
2529 fraction_thousandths in 100u32..=900u32,
2530 ) {
2531 let amount = decimal_at_usdc_scale(amount_micros);
2532 let price = Decimal::new(i64::from(price_milli), 3);
2533 let fee_rate = decimal_from_bps(fee_rate_bps);
2534 let builder = decimal_from_bps(builder_bps);
2535 let fee_exponent = Decimal::from(exponent);
2536
2537 let total_cost =
2540 compute_total_cost(amount, price, fee_rate, exponent, builder);
2541 let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
2542 let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
2543 if balance.is_zero() {
2544 return Ok(()); }
2546
2547 let adjusted = adjust_market_buy_amount(
2548 amount, balance, price, fee_rate, fee_exponent, builder,
2549 )
2550 .expect("non-zero balance fraction must yield Ok in binding branch");
2551
2552 prop_assert!(
2553 adjusted < amount,
2554 "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
2555 );
2556 prop_assert!(
2557 adjusted > Decimal::ZERO,
2558 "adjusted must be strictly positive",
2559 );
2560 prop_assert_eq!(
2561 adjusted,
2562 adjusted.trunc_with_scale(USDC_DECIMALS),
2563 "adjusted must be at USDC_DECIMALS scale",
2564 );
2565 let recomputed_cost =
2566 compute_total_cost(adjusted, price, fee_rate, exponent, builder);
2567 prop_assert!(
2568 recomputed_cost <= balance,
2569 "total_cost {recomputed_cost} must fit balance {balance}",
2570 );
2571 }
2572
2573 #[rstest]
2577 fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
2578 amount_pico in 1_000_000u64..=1_000_000_000_000u64,
2579 price_milli in 1u32..=999u32,
2580 fee_rate_bps in 0u32..=1_000u32,
2581 exponent in 0u32..=3u32,
2582 builder_bps in 0u32..=500u32,
2583 ) {
2584 let amount = Decimal::new(amount_pico as i64, 9);
2587 let price = Decimal::new(i64::from(price_milli), 3);
2588 let fee_rate = decimal_from_bps(fee_rate_bps);
2589 let builder = decimal_from_bps(builder_bps);
2590 let fee_exponent = Decimal::from(exponent);
2591
2592 let total_cost =
2594 compute_total_cost(amount, price, fee_rate, exponent, builder);
2595 let balance = total_cost * Decimal::from(10);
2596
2597 if let Ok(adjusted) = adjust_market_buy_amount(
2598 amount, balance, price, fee_rate, fee_exponent, builder,
2599 ) {
2600 prop_assert_eq!(
2601 adjusted,
2602 adjusted.trunc_with_scale(USDC_DECIMALS),
2603 "result must be at USDC_DECIMALS scale",
2604 );
2605 prop_assert!(
2606 adjusted <= amount,
2607 "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
2608 );
2609 }
2610 }
2611 }
2612 }
2613}