1use nautilus_core::{
19 UUID4, UnixNanos,
20 datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
21};
22use nautilus_model::{
23 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
24 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
25 instruments::InstrumentAny,
26 reports::{FillReport, OrderStatusReport},
27 types::{AccountBalance, Currency, Money, Price, Quantity},
28};
29use rust_decimal::Decimal;
30
31use crate::{
32 common::{
33 consts::{DUST_SNAP_THRESHOLD, USDC_DECIMALS},
34 enums::{
35 PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
36 PolymarketOrderStatus,
37 },
38 models::PolymarketMakerOrder,
39 },
40 http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
41};
42
43pub const fn parse_liquidity_side(side: PolymarketLiquiditySide) -> LiquiditySide {
45 match side {
46 PolymarketLiquiditySide::Maker => LiquiditySide::Maker,
47 PolymarketLiquiditySide::Taker => LiquiditySide::Taker,
48 }
49}
50
51pub fn resolve_order_status(
56 status: PolymarketOrderStatus,
57 event_type: PolymarketEventType,
58) -> OrderStatus {
59 if status == PolymarketOrderStatus::Invalid && event_type == PolymarketEventType::Cancellation {
60 OrderStatus::Canceled
61 } else {
62 OrderStatus::from(status)
63 }
64}
65
66pub fn determine_order_side(
73 trader_side: PolymarketLiquiditySide,
74 trade_side: PolymarketOrderSide,
75 taker_asset_id: &str,
76 maker_asset_id: &str,
77) -> OrderSide {
78 let order_side = OrderSide::from(trade_side);
79
80 if trader_side == PolymarketLiquiditySide::Taker {
81 return order_side;
82 }
83
84 let is_cross_asset = maker_asset_id != taker_asset_id;
85
86 if is_cross_asset {
87 order_side
88 } else {
89 match order_side {
90 OrderSide::Buy => OrderSide::Sell,
91 OrderSide::Sell => OrderSide::Buy,
92 other => other,
93 }
94 }
95}
96
97pub fn make_composite_trade_id(trade_id: &str, venue_order_id: &str) -> TradeId {
105 let prefix_len = trade_id.len().min(27);
106 let suffix_len = venue_order_id.len().min(8);
107 let suffix_start = venue_order_id.len().saturating_sub(suffix_len);
108 TradeId::from(
109 format!(
110 "{}-{}",
111 &trade_id[..prefix_len],
112 &venue_order_id[suffix_start..]
113 )
114 .as_str(),
115 )
116}
117
118pub fn parse_order_status_report(
120 order: &PolymarketOpenOrder,
121 instrument_id: InstrumentId,
122 account_id: AccountId,
123 client_order_id: Option<ClientOrderId>,
124 price_precision: u8,
125 size_precision: u8,
126 ts_init: UnixNanos,
127) -> OrderStatusReport {
128 let venue_order_id = VenueOrderId::from(order.id.as_str());
129 let order_side = OrderSide::from(order.side);
130 let time_in_force = TimeInForce::from(order.order_type);
131 let order_status = OrderStatus::from(order.status);
132 let quantity = Quantity::from_decimal_dp(order.original_size, size_precision)
133 .unwrap_or_else(|_| Quantity::zero(size_precision));
134 let raw_filled_qty = Quantity::from_decimal_dp(order.size_matched, size_precision)
135 .unwrap_or_else(|_| Quantity::zero(size_precision));
136 let filled_qty = snap_filled_qty_to_quantity(quantity, raw_filled_qty, order_status);
137 let price = Price::from_decimal_dp(order.price, price_precision)
138 .unwrap_or_else(|_| Price::zero(price_precision));
139
140 let ts_accepted = UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND);
141
142 let mut report = OrderStatusReport::new(
143 account_id,
144 instrument_id,
145 client_order_id,
146 venue_order_id,
147 order_side,
148 OrderType::Limit,
149 time_in_force,
150 order_status,
151 quantity,
152 filled_qty,
153 ts_accepted,
154 ts_accepted, ts_init,
156 None, );
158 report.price = Some(price);
159 if let Some(nanos) = order.expiration.as_deref().and_then(parse_expiration_nanos) {
161 report.expire_time = Some(UnixNanos::from(nanos));
162 }
163 report
164}
165
166fn parse_expiration_nanos(value: &str) -> Option<u64> {
171 let secs: u64 = value.parse().ok()?;
172 if secs == 0 {
173 return None;
174 }
175 secs.checked_mul(NANOSECONDS_IN_SECOND)
176}
177
178#[expect(clippy::too_many_arguments)]
184pub fn parse_fill_report(
185 trade: &PolymarketTradeReport,
186 instrument_id: InstrumentId,
187 account_id: AccountId,
188 client_order_id: Option<ClientOrderId>,
189 price_precision: u8,
190 size_precision: u8,
191 currency: Currency,
192 taker_fee_rate: Decimal,
193 ts_init: UnixNanos,
194) -> FillReport {
195 let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
196 let trade_id = TradeId::from(trade.id.as_str());
197 let order_side = OrderSide::from(trade.side);
198 let last_qty = Quantity::from_decimal_dp(trade.size, size_precision)
199 .unwrap_or_else(|_| Quantity::zero(size_precision));
200 let last_px = Price::from_decimal_dp(trade.price, price_precision)
201 .unwrap_or_else(|_| Price::zero(price_precision));
202 let liquidity_side = parse_liquidity_side(trade.trader_side);
203
204 let commission_value =
205 compute_commission(taker_fee_rate, trade.size, trade.price, liquidity_side);
206 let commission = Money::new(commission_value, currency);
207
208 let ts_event = parse_timestamp(&trade.match_time).unwrap_or(ts_init);
209
210 FillReport {
211 account_id,
212 instrument_id,
213 venue_order_id,
214 trade_id,
215 order_side,
216 last_qty,
217 last_px,
218 commission,
219 liquidity_side,
220 avg_px: None,
221 report_id: UUID4::new(),
222 ts_event,
223 ts_init,
224 client_order_id,
225 venue_position_id: None,
226 }
227}
228
229#[expect(clippy::too_many_arguments)]
235pub fn build_maker_fill_report(
236 mo: &PolymarketMakerOrder,
237 trade_id: &str,
238 trader_side: PolymarketLiquiditySide,
239 trade_side: PolymarketOrderSide,
240 taker_asset_id: &str,
241 account_id: AccountId,
242 instrument_id: InstrumentId,
243 price_precision: u8,
244 size_precision: u8,
245 currency: Currency,
246 liquidity_side: LiquiditySide,
247 ts_event: UnixNanos,
248 ts_init: UnixNanos,
249) -> FillReport {
250 let venue_order_id = VenueOrderId::from(mo.order_id.as_str());
251 let fill_trade_id = make_composite_trade_id(trade_id, &mo.order_id);
252 let order_side = determine_order_side(
253 trader_side,
254 trade_side,
255 taker_asset_id,
256 mo.asset_id.as_str(),
257 );
258 let last_qty = Quantity::from_decimal_dp(mo.matched_amount, size_precision)
259 .unwrap_or_else(|_| Quantity::zero(size_precision));
260 let last_px = Price::from_decimal_dp(mo.price, price_precision)
261 .unwrap_or_else(|_| Price::zero(price_precision));
262 let commission_value =
265 compute_commission(Decimal::ZERO, mo.matched_amount, mo.price, liquidity_side);
266
267 FillReport {
268 account_id,
269 instrument_id,
270 venue_order_id,
271 trade_id: fill_trade_id,
272 order_side,
273 last_qty,
274 last_px,
275 commission: Money::new(commission_value, currency),
276 liquidity_side,
277 avg_px: None,
278 report_id: UUID4::new(),
279 ts_event,
280 ts_init,
281 client_order_id: None,
282 venue_position_id: None,
283 }
284}
285
286#[must_use]
292pub fn instrument_taker_fee(instrument: &InstrumentAny) -> Decimal {
293 match instrument {
294 InstrumentAny::BinaryOption(bo) => bo.taker_fee,
295 _ => Decimal::ZERO,
296 }
297}
298
299#[must_use]
304pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> f64 {
305 match instrument {
306 InstrumentAny::BinaryOption(bo) => bo
307 .info
308 .as_ref()
309 .and_then(|info| info.get("fee_schedule"))
310 .and_then(|fs| fs.get("exponent"))
311 .and_then(serde_json::Value::as_f64)
312 .unwrap_or(1.0),
313 _ => 1.0,
314 }
315}
316
317pub fn adjust_market_buy_amount(
340 amount: Decimal,
341 user_pusd_balance: Decimal,
342 price: Decimal,
343 fee_rate: Decimal,
344 fee_exponent: f64,
345 builder_taker_fee_rate: Decimal,
346) -> anyhow::Result<Decimal> {
347 if price <= Decimal::ZERO || price >= Decimal::ONE {
348 anyhow::bail!(
349 "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
350 );
351 }
352
353 let base = price * (Decimal::ONE - price);
354 let base_f64: f64 = base.try_into().unwrap_or(0.0);
355 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
356 let platform_fee_rate = fee_rate * curve;
357
358 let platform_fee = amount / price * platform_fee_rate;
359 let total_cost = amount + platform_fee + amount * builder_taker_fee_rate;
360
361 let raw = if user_pusd_balance <= total_cost {
362 let divisor = Decimal::ONE + platform_fee_rate / price + builder_taker_fee_rate;
363 user_pusd_balance / divisor
364 } else {
365 amount
366 };
367
368 let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
369 if adjusted.is_zero() {
370 anyhow::bail!(
371 "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
372 fee-adjusted amount truncated to zero"
373 );
374 }
375 Ok(adjusted)
376}
377
378pub fn compute_commission(
394 fee_rate: Decimal,
395 size: Decimal,
396 price: Decimal,
397 liquidity_side: LiquiditySide,
398) -> f64 {
399 if liquidity_side != LiquiditySide::Taker || fee_rate.is_zero() {
400 return 0.0;
401 }
402
403 let commission = size * fee_rate * price * (Decimal::ONE - price);
404 let rounded = commission.round_dp(5);
405 rounded.to_string().parse().unwrap_or(0.0)
406}
407
408pub(crate) fn sum_filled_quantity(fills: &[FillReport]) -> Decimal {
410 fills.iter().map(|f| f.last_qty.as_decimal()).sum()
411}
412
413pub(crate) fn weighted_average_price(
416 fills: &[FillReport],
417 total_filled: Decimal,
418) -> Option<Decimal> {
419 if total_filled.is_zero() {
420 return None;
421 }
422 let weighted: Decimal = fills
423 .iter()
424 .map(|f| f.last_qty.as_decimal() * f.last_px.as_decimal())
425 .sum();
426 Some(weighted / total_filled)
427}
428
429pub(crate) fn snap_filled_qty_to_quantity(
437 quantity: Quantity,
438 filled_qty: Quantity,
439 order_status: OrderStatus,
440) -> Quantity {
441 if order_status != OrderStatus::Filled {
442 return filled_qty;
443 }
444 let diff = quantity.as_f64() - filled_qty.as_f64();
445 if diff != 0.0 && diff.abs() < DUST_SNAP_THRESHOLD {
446 quantity
447 } else {
448 filled_qty
449 }
450}
451
452const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
454
455pub fn parse_balance_allowance(
461 balance_raw: Decimal,
462 currency: Currency,
463) -> anyhow::Result<AccountBalance> {
464 let balance_pusd = balance_raw / USDC_SCALE;
465 AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
466 .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
467}
468
469#[derive(Debug)]
471pub struct MarketPriceResult {
472 pub crossing_price: Decimal,
474 pub expected_base_qty: Decimal,
476}
477
478pub fn calculate_market_price(
493 book_levels: &[ClobBookLevel],
494 amount: Decimal,
495 side: PolymarketOrderSide,
496) -> anyhow::Result<MarketPriceResult> {
497 if book_levels.is_empty() {
498 anyhow::bail!("Empty order book: no liquidity available for market order");
499 }
500
501 let mut parsed_levels: Vec<(Decimal, Decimal)> = book_levels
504 .iter()
505 .map(|l| {
506 let price = Decimal::from_str_exact(&l.price).unwrap_or(Decimal::ZERO);
507 let size = Decimal::from_str_exact(&l.size).unwrap_or(Decimal::ZERO);
508 (price, size)
509 })
510 .filter(|(p, s)| !p.is_zero() && !s.is_zero())
511 .collect();
512
513 if parsed_levels.is_empty() {
514 anyhow::bail!("Empty order book: no valid price levels for market order");
515 }
516
517 match side {
518 PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
519 PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
520 }
521
522 let mut remaining = amount;
523 let mut last_price = Decimal::ZERO;
524 let mut total_base_qty = Decimal::ZERO;
525
526 for &(price, size) in &parsed_levels {
527 last_price = price;
528
529 match side {
530 PolymarketOrderSide::Buy => {
531 let level_usdc = size * price;
532 let consumed_usdc = level_usdc.min(remaining);
533 let shares_at_level = consumed_usdc / price;
534 total_base_qty += shares_at_level;
535 remaining -= consumed_usdc;
536 }
537 PolymarketOrderSide::Sell => {
538 let consumed_shares = size.min(remaining);
539 total_base_qty += consumed_shares;
540 remaining -= consumed_shares;
541 }
542 }
543
544 if remaining <= Decimal::ZERO {
545 return Ok(MarketPriceResult {
546 crossing_price: last_price,
547 expected_base_qty: total_base_qty,
548 });
549 }
550 }
551
552 Ok(MarketPriceResult {
555 crossing_price: last_price,
556 expected_base_qty: total_base_qty,
557 })
558}
559
560pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
565 if let Ok(n) = ts_str.parse::<u64>() {
566 return if n > 1_000_000_000_000 {
567 Some(UnixNanos::from(n * NANOSECONDS_IN_MILLISECOND))
568 } else {
569 Some(UnixNanos::from(n * NANOSECONDS_IN_SECOND))
570 };
571 }
572 let dt = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
573 Some(UnixNanos::from(dt.timestamp_nanos_opt()? as u64))
574}
575
576#[cfg(test)]
577mod tests {
578 use nautilus_execution::models::fee::{FeeModel, ProbabilityPriceFeeModel};
579 use nautilus_model::{
580 enums::OrderType,
581 instruments::{Instrument, InstrumentAny, stubs::binary_option},
582 orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
583 };
584 use rstest::rstest;
585 use rust_decimal_macros::dec;
586 use ustr::Ustr;
587
588 use super::*;
589 use crate::common::enums::{
590 PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
591 };
592
593 #[rstest]
597 #[case::filled_underfill_dust(100.000000, 99.995000, OrderStatus::Filled, 100.000000)]
599 #[case::filled_overfill_dust(714.285710, 714.285714, OrderStatus::Filled, 714.285710)]
601 #[case::filled_underfill_at_band(100.000000, 99.990000, OrderStatus::Filled, 99.990000)]
603 #[case::filled_underfill_above_band(100.000000, 99.000000, OrderStatus::Filled, 99.000000)]
605 #[case::filled_exact(100.000000, 100.000000, OrderStatus::Filled, 100.000000)]
607 #[case::accepted_underfill_dust(100.000000, 99.995000, OrderStatus::Accepted, 99.995000)]
609 #[case::canceled_underfill_dust(100.000000, 99.995000, OrderStatus::Canceled, 99.995000)]
611 fn test_snap_filled_qty_to_quantity(
612 #[case] quantity: f64,
613 #[case] filled: f64,
614 #[case] status: OrderStatus,
615 #[case] expected: f64,
616 ) {
617 let snapped = snap_filled_qty_to_quantity(
618 Quantity::new(quantity, 6),
619 Quantity::new(filled, 6),
620 status,
621 );
622 assert_eq!(snapped, Quantity::new(expected, 6));
623 }
624
625 fn make_test_fill(qty: f64, px: f64) -> FillReport {
626 FillReport::new(
627 AccountId::from("POLY-001"),
628 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
629 VenueOrderId::from("0xabc"),
630 TradeId::from("trade-1"),
631 OrderSide::Buy,
632 Quantity::new(qty, 4),
633 Price::new(px, 4),
634 Money::new(0.0, Currency::pUSD()),
635 LiquiditySide::Taker,
636 None,
637 None,
638 UnixNanos::default(),
639 UnixNanos::default(),
640 None,
641 )
642 }
643
644 fn binary_option_fill_order(
645 instrument: &InstrumentAny,
646 liquidity_side: LiquiditySide,
647 price: &str,
648 ) -> OrderAny {
649 let limit_order = OrderTestBuilder::new(OrderType::Limit)
650 .instrument_id(instrument.id())
651 .side(OrderSide::Buy)
652 .price(Price::from(price))
653 .quantity(Quantity::from("100.00"))
654 .build();
655
656 TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
657 }
658
659 #[rstest]
660 fn test_sum_filled_quantity_empty() {
661 assert_eq!(sum_filled_quantity(&[]), Decimal::ZERO);
662 }
663
664 #[rstest]
665 fn test_sum_filled_quantity_multiple() {
666 let fills = vec![
667 make_test_fill(2.5, 0.50),
668 make_test_fill(1.0, 0.60),
669 make_test_fill(3.0, 0.55),
670 ];
671 assert_eq!(sum_filled_quantity(&fills), dec!(6.5));
672 }
673
674 #[rstest]
675 fn test_weighted_average_price_zero_total_returns_none() {
676 assert!(weighted_average_price(&[], Decimal::ZERO).is_none());
677 }
678
679 #[rstest]
680 fn test_weighted_average_price_single_fill() {
681 let fills = vec![make_test_fill(10.0, 0.5)];
682 let total = sum_filled_quantity(&fills);
683 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.5)));
684 }
685
686 #[rstest]
687 fn test_weighted_average_price_weighted_by_quantity() {
688 let fills = vec![make_test_fill(2.0, 0.40), make_test_fill(8.0, 0.60)];
690 let total = sum_filled_quantity(&fills);
691 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.56)));
692 }
693
694 #[rstest]
695 #[case(dec!(20_000_000), 20.0)] #[case(dec!(1_000_000), 1.0)] #[case(dec!(500_000), 0.5)] #[case(dec!(0), 0.0)] #[case(dec!(123_456_789), 123.456789)] fn test_parse_balance_allowance(#[case] raw: Decimal, #[case] expected: f64) {
701 let currency = Currency::pUSD();
702 let balance = parse_balance_allowance(raw, currency).unwrap();
703 let total_f64: f64 = balance.total.as_decimal().to_string().parse().unwrap();
704 assert!(
705 (total_f64 - expected).abs() < 1e-8,
706 "expected {expected}, was {total_f64}"
707 );
708 assert_eq!(balance.free, balance.total);
709 }
710
711 #[rstest]
715 #[case::crypto_p50("0.072", "0.50", 1.8)]
716 #[case::crypto_p01("0.072", "0.01", 0.07128)]
717 #[case::crypto_p05("0.072", "0.05", 0.342)]
718 #[case::crypto_p10("0.072", "0.10", 0.648)]
719 #[case::crypto_p30("0.072", "0.30", 1.512)]
720 #[case::crypto_p70("0.072", "0.70", 1.512)]
721 #[case::crypto_p90("0.072", "0.90", 0.648)]
722 #[case::crypto_p99("0.072", "0.99", 0.07128)]
723 #[case::sports_p50("0.03", "0.50", 0.75)]
724 #[case::sports_p30("0.03", "0.30", 0.63)]
725 #[case::sports_p70("0.03", "0.70", 0.63)]
726 #[case::politics_p50("0.04", "0.50", 1.0)]
727 #[case::politics_p30("0.04", "0.30", 0.84)]
728 #[case::economics_p50("0.05", "0.50", 1.25)]
729 #[case::economics_p30("0.05", "0.30", 1.05)]
730 #[case::geopolitics_p50("0", "0.50", 0.0)]
731 fn test_compute_commission_docs_table(
732 #[case] fee_rate: &str,
733 #[case] price: &str,
734 #[case] expected: f64,
735 ) {
736 let commission = compute_commission(
737 Decimal::from_str_exact(fee_rate).unwrap(),
738 dec!(100),
739 Decimal::from_str_exact(price).unwrap(),
740 LiquiditySide::Taker,
741 );
742 assert!(
743 (commission - expected).abs() < 1e-10,
744 "at p={price}, fee_rate={fee_rate}: expected {expected}, was {commission}"
745 );
746 }
747
748 #[rstest]
749 fn test_compute_commission_issue_3860_strategy_buy() {
750 let commission = compute_commission(
754 dec!(0.072),
755 Decimal::from_str_exact("15.463900").unwrap(),
756 dec!(0.97),
757 LiquiditySide::Taker,
758 );
759 assert!(
760 (commission - 0.03240).abs() < 1e-5,
761 "expected 0.03240, was {commission}"
762 );
763 }
764
765 #[rstest]
766 fn test_compute_commission_issue_3860_reconciliation_sell() {
767 let commission = compute_commission(
772 dec!(0.072),
773 Decimal::from_str_exact("0.033400").unwrap(),
774 dec!(0.98),
775 LiquiditySide::Taker,
776 );
777 assert!(
778 (commission - 0.00005).abs() < 1e-5,
779 "expected 0.00005, was {commission}"
780 );
781 }
782
783 #[rstest]
784 fn test_compute_commission_maker_is_zero() {
785 let commission = compute_commission(
786 Decimal::from_str_exact("0.072").unwrap(),
787 dec!(100),
788 Decimal::from_str_exact("0.50").unwrap(),
789 LiquiditySide::Maker,
790 );
791 assert_eq!(commission, 0.0);
792 }
793
794 #[rstest]
795 #[case::crypto_taker("0.072", "0.970", LiquiditySide::Taker)]
796 #[case::sports_taker("0.03", "0.500", LiquiditySide::Taker)]
797 #[case::politics_taker("0.04", "0.300", LiquiditySide::Taker)]
798 #[case::maker_zero("0.03", "0.500", LiquiditySide::Maker)]
799 fn test_probability_price_fee_model_matches_polymarket_commission(
800 #[case] taker_fee: &str,
801 #[case] price: &str,
802 #[case] liquidity_side: LiquiditySide,
803 ) {
804 let mut binary = binary_option();
805 binary.maker_fee = Decimal::ZERO;
806 binary.taker_fee = Decimal::from_str_exact(taker_fee).unwrap();
807 let instrument = InstrumentAny::BinaryOption(binary);
808 let order = binary_option_fill_order(&instrument, liquidity_side, price);
809 let fee_model = ProbabilityPriceFeeModel;
810
811 let commission = fee_model
812 .get_commission(
813 &order,
814 Quantity::from("100.00"),
815 Price::from(price),
816 &instrument,
817 )
818 .unwrap();
819
820 let expected = compute_commission(
821 Decimal::from_str_exact(taker_fee).unwrap(),
822 dec!(100),
823 Decimal::from_str_exact(price).unwrap(),
824 liquidity_side,
825 );
826
827 let expected = Decimal::from_str_exact(expected.to_string().as_str()).unwrap();
828
829 assert_eq!(commission.as_decimal(), expected);
830 }
831
832 #[rstest]
843 fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
844 let adjusted =
848 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), 1.0, dec!(0))
849 .unwrap();
850 assert_eq!(adjusted, dec!(10.000000));
851 }
852
853 #[rstest]
854 fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
855 let adjusted =
859 adjust_market_buy_amount(dec!(10), dec!(10.2), dec!(0.5), dec!(0.04), 1.0, dec!(0))
860 .unwrap();
861 assert_eq!(adjusted, dec!(10.000000));
863 }
864
865 #[rstest]
866 fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
867 let adjusted =
871 adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), 1.0, dec!(0))
872 .unwrap();
873 assert_eq!(adjusted, dec!(5.000000));
874 }
875
876 #[rstest]
877 fn test_adjust_market_buy_amount_with_builder_fee() {
878 let adjusted =
884 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 1.0, dec!(0.001))
885 .unwrap();
886 assert_eq!(adjusted, dec!(9.794319));
887 }
888
889 #[rstest]
890 fn test_adjust_market_buy_amount_crypto_fee_rate() {
891 let adjusted =
897 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.072), 1.0, dec!(0))
898 .unwrap();
899 assert_eq!(adjusted, dec!(96.525096));
901 }
902
903 #[rstest]
904 fn test_adjust_market_buy_amount_extreme_low_price() {
905 let adjusted =
912 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.001), dec!(0.04), 1.0, dec!(0))
913 .unwrap();
914 let expected = dec!(9.615755);
917 assert!(
918 (adjusted - expected).abs() < dec!(0.00001),
919 "expected ~{expected}, was {adjusted}",
920 );
921 }
922
923 #[rstest]
924 fn test_adjust_market_buy_amount_integer_exponent_two() {
925 let adjusted =
932 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 2.0, dec!(0))
933 .unwrap();
934 assert!(
935 (adjusted - dec!(9.950248)).abs() < dec!(0.00001),
936 "expected ~9.950248, was {adjusted}",
937 );
938 }
939
940 #[rstest]
941 fn test_adjust_market_buy_amount_fractional_exponent() {
942 let adjusted =
949 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 0.5, dec!(0))
950 .unwrap();
951 assert!(
952 (adjusted - dec!(9.615384)).abs() < dec!(0.00001),
953 "expected ~9.615384, was {adjusted}",
954 );
955 }
956
957 #[rstest]
958 fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
959 let adjusted =
961 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), 1.0, dec!(0)).unwrap();
962 assert_eq!(adjusted, dec!(10.000000));
963 }
964
965 #[rstest]
966 fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
967 let adjusted =
969 adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), 1.0, dec!(0))
970 .unwrap();
971 assert_eq!(adjusted, dec!(7.500000));
972 }
973
974 #[rstest]
975 fn test_adjust_market_buy_amount_balance_too_small_errors() {
976 let err = adjust_market_buy_amount(
980 dec!(10),
981 dec!(0.0000001),
982 dec!(0.5),
983 dec!(0.04),
984 1.0,
985 dec!(0),
986 )
987 .unwrap_err();
988 assert!(err.to_string().contains("too small"));
989 }
990
991 #[rstest]
992 #[case::zero_price(dec!(0))]
993 #[case::one_price(dec!(1))]
994 #[case::negative_price(dec!(-0.1))]
995 #[case::above_one_price(dec!(1.5))]
996 fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
997 let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), 1.0, dec!(0))
998 .unwrap_err();
999 assert!(
1000 err.to_string().contains("invalid market-buy price"),
1001 "expected price-domain error, was {err}",
1002 );
1003 }
1004
1005 #[rstest]
1006 fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
1007 let adjusted = adjust_market_buy_amount(
1010 dec!(10),
1011 dec!(9.123456789),
1012 dec!(0.5),
1013 dec!(0.04),
1014 1.0,
1015 dec!(0),
1016 )
1017 .unwrap();
1018 assert!(adjusted.scale() <= 6);
1020 let expected = dec!(8.944565);
1022 assert!(
1023 (adjusted - expected).abs() < dec!(0.000001),
1024 "expected ~{expected}, was {adjusted}",
1025 );
1026 }
1027
1028 fn calc_platform_fee_sdk(
1036 amount: Decimal,
1037 price: Decimal,
1038 rate: Decimal,
1039 exponent: u32,
1040 ) -> Decimal {
1041 let base = price * (Decimal::ONE - price);
1042 let base_f64 = f64::try_from(base).unwrap_or(0.0);
1043 let rate_factor = rate
1044 * Decimal::try_from(base_f64.powi(i32::try_from(exponent).unwrap_or(0)))
1045 .unwrap_or(Decimal::ZERO);
1046 (amount / price) * rate_factor
1047 }
1048
1049 fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
1051 amount * rate
1052 }
1053
1054 fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
1055 let diff = (actual - expected).abs();
1056 assert!(
1057 diff <= tol,
1058 "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
1059 );
1060 }
1061
1062 #[rstest]
1063 fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
1064 let result =
1066 adjust_market_buy_amount(dec!(100), dec!(1000), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1067 .unwrap();
1068 assert_eq!(result, dec!(100));
1069 }
1070
1071 #[rstest]
1072 fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
1073 let result =
1075 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1076 .unwrap();
1077 assert!(result < dec!(100));
1078 assert!(result > dec!(0));
1079 }
1080
1081 #[rstest]
1082 fn test_sdk_adjust_market_buy_with_builder_fee() {
1083 let result =
1085 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0), 1.0, dec!(0.005))
1086 .unwrap();
1087 let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
1089 assert_eq!(result, expected);
1090 }
1091
1092 #[rstest]
1093 fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
1094 let err = adjust_market_buy_amount(
1096 dec!(100),
1097 dec!(0.0000001),
1098 dec!(0.5),
1099 dec!(0.02),
1100 1.0,
1101 dec!(0.005),
1102 )
1103 .unwrap_err();
1104 assert!(err.to_string().contains("truncated to zero"));
1105 }
1106
1107 #[rstest]
1108 fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
1109 let amount = dec!(50);
1112 let price = dec!(0.5);
1113 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1114 let balance = amount + fee + dec!(1);
1115 let result =
1116 adjust_market_buy_amount(amount, balance, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1117 assert_eq!(result, amount);
1118 }
1119
1120 #[rstest]
1121 fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
1122 let amount = dec!(50);
1126 let price = dec!(0.5);
1127 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1128 let total_cost = amount + fee;
1129 let result =
1130 adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1131 close_to(result, amount, dec!(0.000001));
1132 }
1133
1134 #[rstest]
1135 fn test_sdk_adjust_buy_conserves_notional_platform_only() {
1136 let amount = dec!(50);
1139 let price = dec!(0.5);
1140 let adjusted =
1141 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1142 let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1143 close_to(adjusted + fee, amount, dec!(0.000001));
1144 assert!(adjusted < amount);
1145 }
1146
1147 #[rstest]
1148 fn test_sdk_adjust_buy_conserves_notional_builder_only() {
1149 let amount = dec!(50);
1151 let price = dec!(0.5);
1152 let builder_rate = dec!(0.01);
1153 let adjusted =
1154 adjust_market_buy_amount(amount, amount, price, dec!(0), 0.0, builder_rate).unwrap();
1155 let fee = calc_builder_fee_sdk(adjusted, builder_rate);
1156 close_to(adjusted + fee, amount, dec!(0.000001));
1157 }
1158
1159 #[rstest]
1160 fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
1161 let amount = dec!(50);
1163 let price = dec!(0.5);
1164 let builder_rate = dec!(0.01);
1165 let adjusted =
1166 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1167 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1168 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1169 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1170 }
1171
1172 #[rstest]
1173 fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
1174 let amount = dec!(30);
1176 let price = dec!(0.3);
1177 let builder_rate = dec!(0.02);
1178 let adjusted =
1179 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1180 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1181 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1182 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1183 }
1184
1185 #[rstest]
1186 fn test_parse_timestamp_ms() {
1187 let ts = parse_timestamp("1703875200000").unwrap();
1188 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1189 }
1190
1191 #[rstest]
1192 fn test_parse_timestamp_secs() {
1193 let ts = parse_timestamp("1703875200").unwrap();
1194 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1195 }
1196
1197 #[rstest]
1198 fn test_parse_timestamp_rfc3339() {
1199 let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1200 assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1201 }
1202
1203 #[rstest]
1204 fn test_parse_liquidity_side_maker() {
1205 assert_eq!(
1206 parse_liquidity_side(PolymarketLiquiditySide::Maker),
1207 LiquiditySide::Maker
1208 );
1209 }
1210
1211 #[rstest]
1212 fn test_parse_liquidity_side_taker() {
1213 assert_eq!(
1214 parse_liquidity_side(PolymarketLiquiditySide::Taker),
1215 LiquiditySide::Taker
1216 );
1217 }
1218
1219 #[rstest]
1220 fn test_parse_order_status_report_from_fixture() {
1221 let path = "test_data/http_open_order.json";
1222 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1223 let order: PolymarketOpenOrder =
1224 serde_json::from_str(&content).expect("Failed to parse test data");
1225
1226 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1227 let account_id = AccountId::from("POLYMARKET-001");
1228
1229 let report = parse_order_status_report(
1230 &order,
1231 instrument_id,
1232 account_id,
1233 None,
1234 4,
1235 6,
1236 UnixNanos::from(1_000_000_000u64),
1237 );
1238
1239 assert_eq!(report.account_id, account_id);
1240 assert_eq!(report.instrument_id, instrument_id);
1241 assert_eq!(report.order_side, OrderSide::Buy);
1242 assert_eq!(report.order_type, OrderType::Limit);
1243 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1244 assert_eq!(report.order_status, OrderStatus::Accepted);
1245 assert!(report.price.is_some());
1246 assert_eq!(
1247 report.ts_accepted,
1248 UnixNanos::from(1_703_875_200_000_000_000u64)
1249 );
1250 assert_eq!(
1251 report.ts_last,
1252 UnixNanos::from(1_703_875_200_000_000_000u64)
1253 );
1254 assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1255 assert_eq!(report.expire_time, None);
1257 }
1258
1259 #[rstest]
1262 #[case::matched_underfill_dust(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.995000), 100.000000)]
1264 #[case::matched_overfill_dust(PolymarketOrderStatus::Matched, dec!(714.285710), dec!(714.285714), 714.285710)]
1266 #[case::live_underfill_dust(PolymarketOrderStatus::Live, dec!(100.000000), dec!(99.995000), 99.995000)]
1268 #[case::matched_real_partial(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.000000), 99.000000)]
1270 fn test_parse_order_status_report_snaps_dust_filled_qty(
1271 #[case] status: PolymarketOrderStatus,
1272 #[case] original_size: Decimal,
1273 #[case] size_matched: Decimal,
1274 #[case] expected_filled: f64,
1275 ) {
1276 let order = PolymarketOpenOrder {
1277 associate_trades: None,
1278 id: "0xid".to_string(),
1279 status,
1280 market: Ustr::from("0xm"),
1281 original_size,
1282 outcome: PolymarketOutcome::yes(),
1283 maker_address: "0xmaker".to_string(),
1284 owner: "owner".to_string(),
1285 price: dec!(0.5),
1286 side: PolymarketOrderSide::Buy,
1287 size_matched,
1288 asset_id: Ustr::from("token"),
1289 expiration: None,
1290 order_type: PolymarketOrderType::GTC,
1291 created_at: 1_703_875_200,
1292 };
1293
1294 let report = parse_order_status_report(
1295 &order,
1296 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1297 AccountId::from("POLYMARKET-001"),
1298 None,
1299 3,
1300 6,
1301 UnixNanos::from(1_000_000_000u64),
1302 );
1303
1304 assert_eq!(report.filled_qty, Quantity::new(expected_filled, 6));
1305 assert_eq!(
1306 report.quantity,
1307 Quantity::new(original_size.try_into().unwrap_or(0.0), 6)
1308 );
1309 }
1310
1311 #[rstest]
1312 #[case::null(None, None)]
1313 #[case::zero_string(Some("0"), None)]
1314 #[case::empty_string(Some(""), None)]
1315 #[case::garbage(Some("not-a-number"), None)]
1316 #[case::positive_seconds(
1317 Some("1735689600"),
1318 Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1319 )]
1320 fn test_parse_order_status_report_expiration(
1321 #[case] raw: Option<&str>,
1322 #[case] expected: Option<UnixNanos>,
1323 ) {
1324 let order = PolymarketOpenOrder {
1325 associate_trades: None,
1326 id: "0xid".to_string(),
1327 status: PolymarketOrderStatus::Live,
1328 market: Ustr::from("0xm"),
1329 original_size: dec!(100),
1330 outcome: PolymarketOutcome::yes(),
1331 maker_address: "0xmaker".to_string(),
1332 owner: "owner".to_string(),
1333 price: dec!(0.5),
1334 side: PolymarketOrderSide::Buy,
1335 size_matched: dec!(0),
1336 asset_id: Ustr::from("token"),
1337 expiration: raw.map(|s| s.to_string()),
1338 order_type: PolymarketOrderType::GTD,
1339 created_at: 1_703_875_200,
1340 };
1341
1342 let report = parse_order_status_report(
1343 &order,
1344 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1345 AccountId::from("POLYMARKET-001"),
1346 None,
1347 4,
1348 6,
1349 UnixNanos::from(1_000_000_000u64),
1350 );
1351
1352 assert_eq!(report.expire_time, expected);
1353 }
1354
1355 #[rstest]
1356 fn test_parse_fill_report_from_fixture() {
1357 let path = "test_data/http_trade_report.json";
1358 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1359 let trade: PolymarketTradeReport =
1360 serde_json::from_str(&content).expect("Failed to parse test data");
1361
1362 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1363 let account_id = AccountId::from("POLYMARKET-001");
1364 let currency = Currency::pUSD();
1365
1366 let report = parse_fill_report(
1367 &trade,
1368 instrument_id,
1369 account_id,
1370 None,
1371 4,
1372 6,
1373 currency,
1374 Decimal::ZERO,
1375 UnixNanos::from(1_000_000_000u64),
1376 );
1377
1378 assert_eq!(report.account_id, account_id);
1379 assert_eq!(report.instrument_id, instrument_id);
1380 assert_eq!(report.order_side, OrderSide::Buy);
1381 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1382 assert_eq!(report.commission.as_f64(), 0.0);
1383 }
1384
1385 #[rstest]
1386 fn test_parse_fill_report_forwards_taker_fee_rate() {
1387 let path = "test_data/http_trade_report.json";
1388 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1389 let trade: PolymarketTradeReport =
1390 serde_json::from_str(&content).expect("Failed to parse test data");
1391
1392 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1393 let account_id = AccountId::from("POLYMARKET-001");
1394 let currency = Currency::pUSD();
1395
1396 let report = parse_fill_report(
1398 &trade,
1399 instrument_id,
1400 account_id,
1401 None,
1402 4,
1403 6,
1404 currency,
1405 dec!(0.03),
1406 UnixNanos::from(1_000_000_000u64),
1407 );
1408
1409 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1410 assert!((report.commission.as_f64() - 0.1875).abs() < 1e-10);
1411 }
1412
1413 #[rstest]
1414 fn test_instrument_taker_fee_reads_binary_option() {
1415 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
1416
1417 let path = "test_data/gamma_market_sports_market_money_line.json";
1418 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1419 let market = serde_json::from_str(&content).expect("Failed to parse test data");
1420 let defs = parse_gamma_market(&market).unwrap();
1421 let instrument =
1422 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1423
1424 assert_eq!(instrument_taker_fee(&instrument), dec!(0.03));
1425 }
1426
1427 #[rstest]
1428 #[case(
1429 PolymarketLiquiditySide::Taker,
1430 PolymarketOrderSide::Buy,
1431 "token_a",
1432 "token_b",
1433 OrderSide::Buy
1434 )]
1435 #[case(
1436 PolymarketLiquiditySide::Taker,
1437 PolymarketOrderSide::Sell,
1438 "token_a",
1439 "token_b",
1440 OrderSide::Sell
1441 )]
1442 #[case(
1443 PolymarketLiquiditySide::Maker,
1444 PolymarketOrderSide::Buy,
1445 "token_a",
1446 "token_b",
1447 OrderSide::Buy
1448 )]
1449 #[case(
1450 PolymarketLiquiditySide::Maker,
1451 PolymarketOrderSide::Buy,
1452 "token_a",
1453 "token_a",
1454 OrderSide::Sell
1455 )]
1456 #[case(
1457 PolymarketLiquiditySide::Maker,
1458 PolymarketOrderSide::Sell,
1459 "token_a",
1460 "token_a",
1461 OrderSide::Buy
1462 )]
1463 fn test_determine_order_side(
1464 #[case] trader_side: PolymarketLiquiditySide,
1465 #[case] trade_side: PolymarketOrderSide,
1466 #[case] taker_asset: &str,
1467 #[case] maker_asset: &str,
1468 #[case] expected: OrderSide,
1469 ) {
1470 let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
1471 assert_eq!(result, expected);
1472 }
1473
1474 #[rstest]
1475 fn test_make_composite_trade_id_basic() {
1476 let trade_id = "trade-abc123";
1477 let venue_order_id = "order-xyz789";
1478 let result = make_composite_trade_id(trade_id, venue_order_id);
1479 assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
1480 }
1481
1482 #[rstest]
1483 fn test_make_composite_trade_id_truncates_long_ids() {
1484 let trade_id = "a]".repeat(30);
1485 let venue_order_id = "b".repeat(20);
1486 let result = make_composite_trade_id(&trade_id, &venue_order_id);
1487 assert!(result.as_str().len() <= 36);
1488 }
1489
1490 #[rstest]
1491 fn test_make_composite_trade_id_short_venue_id() {
1492 let trade_id = "t123";
1493 let venue_order_id = "ab";
1494 let result = make_composite_trade_id(trade_id, venue_order_id);
1495 assert_eq!(result.as_str(), "t123-ab");
1496 }
1497
1498 #[rstest]
1499 fn test_make_composite_trade_id_uniqueness() {
1500 let id_a = make_composite_trade_id("same-trade", "order-aaa");
1501 let id_b = make_composite_trade_id("same-trade", "order-bbb");
1502 assert_ne!(id_a, id_b);
1503 }
1504
1505 #[rstest]
1508 fn test_calculate_market_price_buy_single_level() {
1509 let levels = vec![ClobBookLevel {
1510 price: "0.55".to_string(),
1511 size: "200.0".to_string(),
1512 }];
1513 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1514 assert_eq!(result.crossing_price, dec!(0.55));
1515 assert!(result.expected_base_qty > dec!(90));
1517 }
1518
1519 #[rstest]
1520 fn test_calculate_market_price_buy_walks_multiple_levels() {
1521 let levels = vec![
1523 ClobBookLevel {
1524 price: "0.55".to_string(),
1525 size: "100.0".to_string(),
1526 },
1527 ClobBookLevel {
1528 price: "0.50".to_string(),
1529 size: "10.0".to_string(),
1530 },
1531 ClobBookLevel {
1532 price: "0.60".to_string(),
1533 size: "200.0".to_string(),
1534 },
1535 ];
1536 let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
1539 assert_eq!(result.crossing_price, dec!(0.55));
1540 let expected = dec!(10) + dec!(15) / dec!(0.55);
1541 assert_eq!(result.expected_base_qty, expected);
1542 }
1543
1544 #[rstest]
1545 fn test_calculate_market_price_buy_small_order_uses_best_ask() {
1546 let levels = vec![
1548 ClobBookLevel {
1549 price: "0.50".to_string(),
1550 size: "50.0".to_string(),
1551 },
1552 ClobBookLevel {
1553 price: "0.999".to_string(),
1554 size: "100.0".to_string(),
1555 },
1556 ClobBookLevel {
1557 price: "0.20".to_string(),
1558 size: "72.0".to_string(),
1559 },
1560 ];
1561 let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
1564 assert_eq!(result.crossing_price, dec!(0.20));
1565 assert_eq!(result.expected_base_qty, dec!(25)); }
1567
1568 #[rstest]
1569 fn test_calculate_market_price_sell_walks_levels() {
1570 let levels = vec![
1572 ClobBookLevel {
1573 price: "0.48".to_string(),
1574 size: "100.0".to_string(),
1575 },
1576 ClobBookLevel {
1577 price: "0.50".to_string(),
1578 size: "50.0".to_string(),
1579 },
1580 ];
1581 let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
1584 assert_eq!(result.crossing_price, dec!(0.48));
1585 assert_eq!(result.expected_base_qty, dec!(80));
1586 }
1587
1588 #[rstest]
1589 fn test_calculate_market_price_empty_book() {
1590 let levels: Vec<ClobBookLevel> = vec![];
1591 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1592 assert!(result.is_err());
1593 }
1594
1595 #[rstest]
1596 fn test_calculate_market_price_all_zero_levels_returns_error() {
1597 let levels = vec![
1598 ClobBookLevel {
1599 price: "0".to_string(),
1600 size: "100.0".to_string(),
1601 },
1602 ClobBookLevel {
1603 price: "0.50".to_string(),
1604 size: "0".to_string(),
1605 },
1606 ];
1607 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1608 assert!(result.is_err());
1609 }
1610
1611 #[rstest]
1612 fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
1613 let levels = vec![ClobBookLevel {
1614 price: "0.55".to_string(),
1615 size: "10.0".to_string(),
1616 }];
1617 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1619 assert_eq!(result.crossing_price, dec!(0.55));
1620 assert_eq!(result.expected_base_qty, dec!(10)); }
1622
1623 #[rstest]
1624 fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
1625 let levels_ascending = vec![
1626 ClobBookLevel {
1627 price: "0.20".to_string(),
1628 size: "72.0".to_string(),
1629 },
1630 ClobBookLevel {
1631 price: "0.50".to_string(),
1632 size: "50.0".to_string(),
1633 },
1634 ClobBookLevel {
1635 price: "0.999".to_string(),
1636 size: "100.0".to_string(),
1637 },
1638 ];
1639 let levels_descending = vec![
1640 ClobBookLevel {
1641 price: "0.999".to_string(),
1642 size: "100.0".to_string(),
1643 },
1644 ClobBookLevel {
1645 price: "0.50".to_string(),
1646 size: "50.0".to_string(),
1647 },
1648 ClobBookLevel {
1649 price: "0.20".to_string(),
1650 size: "72.0".to_string(),
1651 },
1652 ];
1653 let levels_shuffled = vec![
1654 ClobBookLevel {
1655 price: "0.50".to_string(),
1656 size: "50.0".to_string(),
1657 },
1658 ClobBookLevel {
1659 price: "0.20".to_string(),
1660 size: "72.0".to_string(),
1661 },
1662 ClobBookLevel {
1663 price: "0.999".to_string(),
1664 size: "100.0".to_string(),
1665 },
1666 ];
1667
1668 let r1 =
1669 calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1670 let r2 =
1671 calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1672 let r3 =
1673 calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
1674
1675 assert_eq!(r1.crossing_price, r2.crossing_price);
1676 assert_eq!(r2.crossing_price, r3.crossing_price);
1677 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1678 assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
1679 }
1680
1681 #[rstest]
1682 fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
1683 let levels_a = vec![
1684 ClobBookLevel {
1685 price: "0.48".to_string(),
1686 size: "100.0".to_string(),
1687 },
1688 ClobBookLevel {
1689 price: "0.50".to_string(),
1690 size: "50.0".to_string(),
1691 },
1692 ];
1693 let levels_b = vec![
1694 ClobBookLevel {
1695 price: "0.50".to_string(),
1696 size: "50.0".to_string(),
1697 },
1698 ClobBookLevel {
1699 price: "0.48".to_string(),
1700 size: "100.0".to_string(),
1701 },
1702 ];
1703
1704 let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
1705 let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
1706
1707 assert_eq!(r1.crossing_price, r2.crossing_price);
1708 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1709 }
1710
1711 mod adjust_market_buy_amount_property_tests {
1712 use proptest::prelude::*;
1713 use rstest::rstest;
1714
1715 use super::*;
1716
1717 fn decimal_at_usdc_scale(micros: u64) -> Decimal {
1721 Decimal::new(micros as i64, USDC_DECIMALS)
1722 }
1723
1724 fn decimal_from_bps(bps: u32) -> Decimal {
1726 Decimal::new(i64::from(bps), 4)
1727 }
1728
1729 fn compute_total_cost(
1732 amount: Decimal,
1733 price: Decimal,
1734 fee_rate: Decimal,
1735 fee_exponent: f64,
1736 builder: Decimal,
1737 ) -> Decimal {
1738 let base = price * (Decimal::ONE - price);
1739 let base_f64: f64 = base.try_into().unwrap_or(0.0);
1740 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
1741 let platform_fee_rate = fee_rate * curve;
1742 let platform_fee = amount / price * platform_fee_rate;
1743 amount + platform_fee + amount * builder
1744 }
1745
1746 proptest! {
1747 #[rstest]
1750 fn prop_adjust_market_buy_amount_is_deterministic(
1751 amount_micros in 1u64..=1_000_000_000_000u64,
1752 balance_micros in 1u64..=1_000_000_000_000u64,
1753 price_milli in 1u32..=999u32,
1754 fee_rate_bps in 0u32..=1_000u32,
1755 fee_exponent in 1.0f64..=3.0f64,
1756 builder_bps in 0u32..=500u32,
1757 ) {
1758 let amount = decimal_at_usdc_scale(amount_micros);
1759 let balance = decimal_at_usdc_scale(balance_micros);
1760 let price = Decimal::new(i64::from(price_milli), 3);
1761 let fee_rate = decimal_from_bps(fee_rate_bps);
1762 let builder = decimal_from_bps(builder_bps);
1763
1764 let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
1765 let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
1766 prop_assert_eq!(r1.is_ok(), r2.is_ok());
1767 if let (Ok(a), Ok(b)) = (r1, r2) {
1768 prop_assert_eq!(a, b);
1769 }
1770 }
1771
1772 #[rstest]
1777 fn prop_adjust_market_buy_amount_non_binding_returns_amount(
1778 amount_micros in 1u64..=1_000_000_000u64,
1779 price_milli in 1u32..=999u32,
1780 fee_rate_bps in 0u32..=1_000u32,
1781 fee_exponent in 1.0f64..=3.0f64,
1782 builder_bps in 0u32..=500u32,
1783 ) {
1784 let amount = decimal_at_usdc_scale(amount_micros);
1785 let price = Decimal::new(i64::from(price_milli), 3);
1786 let fee_rate = decimal_from_bps(fee_rate_bps);
1787 let builder = decimal_from_bps(builder_bps);
1788
1789 let total_cost =
1793 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1794 let balance = total_cost * Decimal::from(10);
1795
1796 let adjusted = adjust_market_buy_amount(
1797 amount, balance, price, fee_rate, fee_exponent, builder,
1798 )
1799 .expect("non-binding balance must yield Ok");
1800 prop_assert_eq!(
1801 adjusted, amount,
1802 "non-binding branch must return the input amount unchanged",
1803 );
1804 }
1805
1806 #[rstest]
1811 fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
1812 amount_micros in 1_000u64..=1_000_000_000u64,
1813 price_milli in 10u32..=990u32,
1814 fee_rate_bps in 0u32..=1_000u32,
1815 fee_exponent in 1.0f64..=3.0f64,
1816 builder_bps in 0u32..=500u32,
1817 fraction_thousandths in 100u32..=900u32,
1818 ) {
1819 let amount = decimal_at_usdc_scale(amount_micros);
1820 let price = Decimal::new(i64::from(price_milli), 3);
1821 let fee_rate = decimal_from_bps(fee_rate_bps);
1822 let builder = decimal_from_bps(builder_bps);
1823
1824 let total_cost =
1827 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1828 let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
1829 let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
1830 if balance.is_zero() {
1831 return Ok(()); }
1833
1834 let adjusted = adjust_market_buy_amount(
1835 amount, balance, price, fee_rate, fee_exponent, builder,
1836 )
1837 .expect("non-zero balance fraction must yield Ok in binding branch");
1838
1839 prop_assert!(
1840 adjusted < amount,
1841 "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
1842 );
1843 prop_assert!(
1844 adjusted > Decimal::ZERO,
1845 "adjusted must be strictly positive",
1846 );
1847 prop_assert_eq!(
1848 adjusted,
1849 adjusted.trunc_with_scale(USDC_DECIMALS),
1850 "adjusted must be at USDC_DECIMALS scale",
1851 );
1852 let recomputed_cost =
1853 compute_total_cost(adjusted, price, fee_rate, fee_exponent, builder);
1854 prop_assert!(
1855 recomputed_cost <= balance,
1856 "total_cost {recomputed_cost} must fit balance {balance}",
1857 );
1858 }
1859
1860 #[rstest]
1864 fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
1865 amount_pico in 1_000_000u64..=1_000_000_000_000u64,
1866 price_milli in 1u32..=999u32,
1867 fee_rate_bps in 0u32..=1_000u32,
1868 fee_exponent in 1.0f64..=3.0f64,
1869 builder_bps in 0u32..=500u32,
1870 ) {
1871 let amount = Decimal::new(amount_pico as i64, 9);
1874 let price = Decimal::new(i64::from(price_milli), 3);
1875 let fee_rate = decimal_from_bps(fee_rate_bps);
1876 let builder = decimal_from_bps(builder_bps);
1877
1878 let total_cost =
1880 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1881 let balance = total_cost * Decimal::from(10);
1882
1883 if let Ok(adjusted) = adjust_market_buy_amount(
1884 amount, balance, price, fee_rate, fee_exponent, builder,
1885 ) {
1886 prop_assert_eq!(
1887 adjusted,
1888 adjusted.trunc_with_scale(USDC_DECIMALS),
1889 "result must be at USDC_DECIMALS scale",
1890 );
1891 prop_assert!(
1892 adjusted <= amount,
1893 "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
1894 );
1895 }
1896 }
1897 }
1898 }
1899}