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::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::new(
133 order.original_size.to_string().parse().unwrap_or(0.0),
134 size_precision,
135 );
136 let filled_qty = Quantity::new(
137 order.size_matched.to_string().parse().unwrap_or(0.0),
138 size_precision,
139 );
140 let price = Price::new(
141 order.price.to_string().parse().unwrap_or(0.0),
142 price_precision,
143 );
144
145 let ts_accepted = UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND);
146
147 let mut report = OrderStatusReport::new(
148 account_id,
149 instrument_id,
150 client_order_id,
151 venue_order_id,
152 order_side,
153 OrderType::Limit,
154 time_in_force,
155 order_status,
156 quantity,
157 filled_qty,
158 ts_accepted,
159 ts_accepted, ts_init,
161 None, );
163 report.price = Some(price);
164 if let Some(nanos) = order.expiration.as_deref().and_then(parse_expiration_nanos) {
166 report.expire_time = Some(UnixNanos::from(nanos));
167 }
168 report
169}
170
171fn parse_expiration_nanos(value: &str) -> Option<u64> {
176 let secs: u64 = value.parse().ok()?;
177 if secs == 0 {
178 return None;
179 }
180 secs.checked_mul(NANOSECONDS_IN_SECOND)
181}
182
183#[expect(clippy::too_many_arguments)]
189pub fn parse_fill_report(
190 trade: &PolymarketTradeReport,
191 instrument_id: InstrumentId,
192 account_id: AccountId,
193 client_order_id: Option<ClientOrderId>,
194 price_precision: u8,
195 size_precision: u8,
196 currency: Currency,
197 taker_fee_rate: Decimal,
198 ts_init: UnixNanos,
199) -> FillReport {
200 let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
201 let trade_id = TradeId::from(trade.id.as_str());
202 let order_side = OrderSide::from(trade.side);
203 let last_qty = Quantity::new(
204 trade.size.to_string().parse().unwrap_or(0.0),
205 size_precision,
206 );
207 let last_px = Price::new(
208 trade.price.to_string().parse().unwrap_or(0.0),
209 price_precision,
210 );
211 let liquidity_side = parse_liquidity_side(trade.trader_side);
212
213 let commission_value =
214 compute_commission(taker_fee_rate, trade.size, trade.price, liquidity_side);
215 let commission = Money::new(commission_value, currency);
216
217 let ts_event = parse_timestamp(&trade.match_time).unwrap_or(ts_init);
218
219 FillReport {
220 account_id,
221 instrument_id,
222 venue_order_id,
223 trade_id,
224 order_side,
225 last_qty,
226 last_px,
227 commission,
228 liquidity_side,
229 avg_px: None,
230 report_id: UUID4::new(),
231 ts_event,
232 ts_init,
233 client_order_id,
234 venue_position_id: None,
235 }
236}
237
238#[expect(clippy::too_many_arguments)]
244pub fn build_maker_fill_report(
245 mo: &PolymarketMakerOrder,
246 trade_id: &str,
247 trader_side: PolymarketLiquiditySide,
248 trade_side: PolymarketOrderSide,
249 taker_asset_id: &str,
250 account_id: AccountId,
251 instrument_id: InstrumentId,
252 price_precision: u8,
253 size_precision: u8,
254 currency: Currency,
255 liquidity_side: LiquiditySide,
256 ts_event: UnixNanos,
257 ts_init: UnixNanos,
258) -> FillReport {
259 let venue_order_id = VenueOrderId::from(mo.order_id.as_str());
260 let fill_trade_id = make_composite_trade_id(trade_id, &mo.order_id);
261 let order_side = determine_order_side(
262 trader_side,
263 trade_side,
264 taker_asset_id,
265 mo.asset_id.as_str(),
266 );
267 let last_qty = Quantity::new(
268 mo.matched_amount.to_string().parse::<f64>().unwrap_or(0.0),
269 size_precision,
270 );
271 let last_px = Price::new(
272 mo.price.to_string().parse::<f64>().unwrap_or(0.0),
273 price_precision,
274 );
275 let commission_value =
278 compute_commission(Decimal::ZERO, mo.matched_amount, mo.price, liquidity_side);
279
280 FillReport {
281 account_id,
282 instrument_id,
283 venue_order_id,
284 trade_id: fill_trade_id,
285 order_side,
286 last_qty,
287 last_px,
288 commission: Money::new(commission_value, currency),
289 liquidity_side,
290 avg_px: None,
291 report_id: UUID4::new(),
292 ts_event,
293 ts_init,
294 client_order_id: None,
295 venue_position_id: None,
296 }
297}
298
299#[must_use]
305pub fn instrument_taker_fee(instrument: &InstrumentAny) -> Decimal {
306 match instrument {
307 InstrumentAny::BinaryOption(bo) => bo.taker_fee,
308 _ => Decimal::ZERO,
309 }
310}
311
312#[must_use]
317pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> f64 {
318 match instrument {
319 InstrumentAny::BinaryOption(bo) => bo
320 .info
321 .as_ref()
322 .and_then(|info| info.get("fee_schedule"))
323 .and_then(|fs| fs.get("exponent"))
324 .and_then(serde_json::Value::as_f64)
325 .unwrap_or(1.0),
326 _ => 1.0,
327 }
328}
329
330pub fn adjust_market_buy_amount(
353 amount: Decimal,
354 user_pusd_balance: Decimal,
355 price: Decimal,
356 fee_rate: Decimal,
357 fee_exponent: f64,
358 builder_taker_fee_rate: Decimal,
359) -> anyhow::Result<Decimal> {
360 if price <= Decimal::ZERO || price >= Decimal::ONE {
361 anyhow::bail!(
362 "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
363 );
364 }
365
366 let base = price * (Decimal::ONE - price);
367 let base_f64: f64 = base.try_into().unwrap_or(0.0);
368 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
369 let platform_fee_rate = fee_rate * curve;
370
371 let platform_fee = amount / price * platform_fee_rate;
372 let total_cost = amount + platform_fee + amount * builder_taker_fee_rate;
373
374 let raw = if user_pusd_balance <= total_cost {
375 let divisor = Decimal::ONE + platform_fee_rate / price + builder_taker_fee_rate;
376 user_pusd_balance / divisor
377 } else {
378 amount
379 };
380
381 let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
382 if adjusted.is_zero() {
383 anyhow::bail!(
384 "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
385 fee-adjusted amount truncated to zero"
386 );
387 }
388 Ok(adjusted)
389}
390
391pub fn compute_commission(
407 fee_rate: Decimal,
408 size: Decimal,
409 price: Decimal,
410 liquidity_side: LiquiditySide,
411) -> f64 {
412 if liquidity_side != LiquiditySide::Taker || fee_rate.is_zero() {
413 return 0.0;
414 }
415
416 let commission = size * fee_rate * price * (Decimal::ONE - price);
417 let rounded = commission.round_dp(5);
418 rounded.to_string().parse().unwrap_or(0.0)
419}
420
421const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
423
424pub fn parse_balance_allowance(
430 balance_raw: Decimal,
431 currency: Currency,
432) -> anyhow::Result<AccountBalance> {
433 let balance_pusd = balance_raw / USDC_SCALE;
434 AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
435 .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
436}
437
438#[derive(Debug)]
440pub struct MarketPriceResult {
441 pub crossing_price: Decimal,
443 pub expected_base_qty: Decimal,
445}
446
447pub fn calculate_market_price(
462 book_levels: &[ClobBookLevel],
463 amount: Decimal,
464 side: PolymarketOrderSide,
465) -> anyhow::Result<MarketPriceResult> {
466 if book_levels.is_empty() {
467 anyhow::bail!("Empty order book: no liquidity available for market order");
468 }
469
470 let mut parsed_levels: Vec<(Decimal, Decimal)> = book_levels
473 .iter()
474 .map(|l| {
475 let price = Decimal::from_str_exact(&l.price).unwrap_or(Decimal::ZERO);
476 let size = Decimal::from_str_exact(&l.size).unwrap_or(Decimal::ZERO);
477 (price, size)
478 })
479 .filter(|(p, s)| !p.is_zero() && !s.is_zero())
480 .collect();
481
482 if parsed_levels.is_empty() {
483 anyhow::bail!("Empty order book: no valid price levels for market order");
484 }
485
486 match side {
487 PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
488 PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
489 }
490
491 let mut remaining = amount;
492 let mut last_price = Decimal::ZERO;
493 let mut total_base_qty = Decimal::ZERO;
494
495 for &(price, size) in &parsed_levels {
496 last_price = price;
497
498 match side {
499 PolymarketOrderSide::Buy => {
500 let level_usdc = size * price;
501 let consumed_usdc = level_usdc.min(remaining);
502 let shares_at_level = consumed_usdc / price;
503 total_base_qty += shares_at_level;
504 remaining -= consumed_usdc;
505 }
506 PolymarketOrderSide::Sell => {
507 let consumed_shares = size.min(remaining);
508 total_base_qty += consumed_shares;
509 remaining -= consumed_shares;
510 }
511 }
512
513 if remaining <= Decimal::ZERO {
514 return Ok(MarketPriceResult {
515 crossing_price: last_price,
516 expected_base_qty: total_base_qty,
517 });
518 }
519 }
520
521 Ok(MarketPriceResult {
523 crossing_price: last_price,
524 expected_base_qty: total_base_qty,
525 })
526}
527
528pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
533 if let Ok(n) = ts_str.parse::<u64>() {
534 return if n > 1_000_000_000_000 {
535 Some(UnixNanos::from(n * NANOSECONDS_IN_MILLISECOND))
536 } else {
537 Some(UnixNanos::from(n * NANOSECONDS_IN_SECOND))
538 };
539 }
540 let dt = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
541 Some(UnixNanos::from(dt.timestamp_nanos_opt()? as u64))
542}
543
544#[cfg(test)]
545mod tests {
546 use rstest::rstest;
547 use rust_decimal_macros::dec;
548 use ustr::Ustr;
549
550 use super::*;
551 use crate::common::enums::{
552 PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
553 };
554
555 #[rstest]
556 #[case(dec!(20_000_000), 20.0)] #[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) {
562 let currency = Currency::pUSD();
563 let balance = parse_balance_allowance(raw, currency).unwrap();
564 let total_f64: f64 = balance.total.as_decimal().to_string().parse().unwrap();
565 assert!(
566 (total_f64 - expected).abs() < 1e-8,
567 "expected {expected}, was {total_f64}"
568 );
569 assert_eq!(balance.free, balance.total);
570 }
571
572 #[rstest]
576 #[case::crypto_p50("0.072", "0.50", 1.8)]
577 #[case::crypto_p01("0.072", "0.01", 0.07128)]
578 #[case::crypto_p05("0.072", "0.05", 0.342)]
579 #[case::crypto_p10("0.072", "0.10", 0.648)]
580 #[case::crypto_p30("0.072", "0.30", 1.512)]
581 #[case::crypto_p70("0.072", "0.70", 1.512)]
582 #[case::crypto_p90("0.072", "0.90", 0.648)]
583 #[case::crypto_p99("0.072", "0.99", 0.07128)]
584 #[case::sports_p50("0.03", "0.50", 0.75)]
585 #[case::sports_p30("0.03", "0.30", 0.63)]
586 #[case::sports_p70("0.03", "0.70", 0.63)]
587 #[case::politics_p50("0.04", "0.50", 1.0)]
588 #[case::politics_p30("0.04", "0.30", 0.84)]
589 #[case::economics_p50("0.05", "0.50", 1.25)]
590 #[case::economics_p30("0.05", "0.30", 1.05)]
591 #[case::geopolitics_p50("0", "0.50", 0.0)]
592 fn test_compute_commission_docs_table(
593 #[case] fee_rate: &str,
594 #[case] price: &str,
595 #[case] expected: f64,
596 ) {
597 let commission = compute_commission(
598 Decimal::from_str_exact(fee_rate).unwrap(),
599 dec!(100),
600 Decimal::from_str_exact(price).unwrap(),
601 LiquiditySide::Taker,
602 );
603 assert!(
604 (commission - expected).abs() < 1e-10,
605 "at p={price}, fee_rate={fee_rate}: expected {expected}, was {commission}"
606 );
607 }
608
609 #[rstest]
610 fn test_compute_commission_issue_3860_strategy_buy() {
611 let commission = compute_commission(
615 dec!(0.072),
616 Decimal::from_str_exact("15.463900").unwrap(),
617 dec!(0.97),
618 LiquiditySide::Taker,
619 );
620 assert!(
621 (commission - 0.03240).abs() < 1e-5,
622 "expected 0.03240, was {commission}"
623 );
624 }
625
626 #[rstest]
627 fn test_compute_commission_issue_3860_reconciliation_sell() {
628 let commission = compute_commission(
633 dec!(0.072),
634 Decimal::from_str_exact("0.033400").unwrap(),
635 dec!(0.98),
636 LiquiditySide::Taker,
637 );
638 assert!(
639 (commission - 0.00005).abs() < 1e-5,
640 "expected 0.00005, was {commission}"
641 );
642 }
643
644 #[rstest]
645 fn test_compute_commission_maker_is_zero() {
646 let commission = compute_commission(
647 Decimal::from_str_exact("0.072").unwrap(),
648 dec!(100),
649 Decimal::from_str_exact("0.50").unwrap(),
650 LiquiditySide::Maker,
651 );
652 assert_eq!(commission, 0.0);
653 }
654
655 #[rstest]
666 fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
667 let adjusted =
671 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), 1.0, dec!(0))
672 .unwrap();
673 assert_eq!(adjusted, dec!(10.000000));
674 }
675
676 #[rstest]
677 fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
678 let adjusted =
682 adjust_market_buy_amount(dec!(10), dec!(10.2), dec!(0.5), dec!(0.04), 1.0, dec!(0))
683 .unwrap();
684 assert_eq!(adjusted, dec!(10.000000));
686 }
687
688 #[rstest]
689 fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
690 let adjusted =
694 adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), 1.0, dec!(0))
695 .unwrap();
696 assert_eq!(adjusted, dec!(5.000000));
697 }
698
699 #[rstest]
700 fn test_adjust_market_buy_amount_with_builder_fee() {
701 let adjusted =
707 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 1.0, dec!(0.001))
708 .unwrap();
709 assert_eq!(adjusted, dec!(9.794319));
710 }
711
712 #[rstest]
713 fn test_adjust_market_buy_amount_crypto_fee_rate() {
714 let adjusted =
720 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.072), 1.0, dec!(0))
721 .unwrap();
722 assert_eq!(adjusted, dec!(96.525096));
724 }
725
726 #[rstest]
727 fn test_adjust_market_buy_amount_extreme_low_price() {
728 let adjusted =
735 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.001), dec!(0.04), 1.0, dec!(0))
736 .unwrap();
737 let expected = dec!(9.615755);
740 assert!(
741 (adjusted - expected).abs() < dec!(0.00001),
742 "expected ~{expected}, was {adjusted}",
743 );
744 }
745
746 #[rstest]
747 fn test_adjust_market_buy_amount_integer_exponent_two() {
748 let adjusted =
755 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 2.0, dec!(0))
756 .unwrap();
757 assert!(
758 (adjusted - dec!(9.950248)).abs() < dec!(0.00001),
759 "expected ~9.950248, was {adjusted}",
760 );
761 }
762
763 #[rstest]
764 fn test_adjust_market_buy_amount_fractional_exponent() {
765 let adjusted =
772 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 0.5, dec!(0))
773 .unwrap();
774 assert!(
775 (adjusted - dec!(9.615384)).abs() < dec!(0.00001),
776 "expected ~9.615384, was {adjusted}",
777 );
778 }
779
780 #[rstest]
781 fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
782 let adjusted =
784 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), 1.0, dec!(0)).unwrap();
785 assert_eq!(adjusted, dec!(10.000000));
786 }
787
788 #[rstest]
789 fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
790 let adjusted =
792 adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), 1.0, dec!(0))
793 .unwrap();
794 assert_eq!(adjusted, dec!(7.500000));
795 }
796
797 #[rstest]
798 fn test_adjust_market_buy_amount_balance_too_small_errors() {
799 let err = adjust_market_buy_amount(
803 dec!(10),
804 dec!(0.0000001),
805 dec!(0.5),
806 dec!(0.04),
807 1.0,
808 dec!(0),
809 )
810 .unwrap_err();
811 assert!(err.to_string().contains("too small"));
812 }
813
814 #[rstest]
815 #[case::zero_price(dec!(0))]
816 #[case::one_price(dec!(1))]
817 #[case::negative_price(dec!(-0.1))]
818 #[case::above_one_price(dec!(1.5))]
819 fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
820 let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), 1.0, dec!(0))
821 .unwrap_err();
822 assert!(
823 err.to_string().contains("invalid market-buy price"),
824 "expected price-domain error, was {err}",
825 );
826 }
827
828 #[rstest]
829 fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
830 let adjusted = adjust_market_buy_amount(
833 dec!(10),
834 dec!(9.123456789),
835 dec!(0.5),
836 dec!(0.04),
837 1.0,
838 dec!(0),
839 )
840 .unwrap();
841 assert!(adjusted.scale() <= 6);
843 let expected = dec!(8.944565);
845 assert!(
846 (adjusted - expected).abs() < dec!(0.000001),
847 "expected ~{expected}, was {adjusted}",
848 );
849 }
850
851 fn calc_platform_fee_sdk(
859 amount: Decimal,
860 price: Decimal,
861 rate: Decimal,
862 exponent: u32,
863 ) -> Decimal {
864 let base = price * (Decimal::ONE - price);
865 let base_f64 = f64::try_from(base).unwrap_or(0.0);
866 let rate_factor = rate
867 * Decimal::try_from(base_f64.powi(i32::try_from(exponent).unwrap_or(0)))
868 .unwrap_or(Decimal::ZERO);
869 (amount / price) * rate_factor
870 }
871
872 fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
874 amount * rate
875 }
876
877 fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
878 let diff = (actual - expected).abs();
879 assert!(
880 diff <= tol,
881 "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
882 );
883 }
884
885 #[rstest]
886 fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
887 let result =
889 adjust_market_buy_amount(dec!(100), dec!(1000), dec!(0.5), dec!(0.02), 1.0, dec!(0))
890 .unwrap();
891 assert_eq!(result, dec!(100));
892 }
893
894 #[rstest]
895 fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
896 let result =
898 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.02), 1.0, dec!(0))
899 .unwrap();
900 assert!(result < dec!(100));
901 assert!(result > dec!(0));
902 }
903
904 #[rstest]
905 fn test_sdk_adjust_market_buy_with_builder_fee() {
906 let result =
908 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0), 1.0, dec!(0.005))
909 .unwrap();
910 let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
912 assert_eq!(result, expected);
913 }
914
915 #[rstest]
916 fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
917 let err = adjust_market_buy_amount(
919 dec!(100),
920 dec!(0.0000001),
921 dec!(0.5),
922 dec!(0.02),
923 1.0,
924 dec!(0.005),
925 )
926 .unwrap_err();
927 assert!(err.to_string().contains("truncated to zero"));
928 }
929
930 #[rstest]
931 fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
932 let amount = dec!(50);
935 let price = dec!(0.5);
936 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
937 let balance = amount + fee + dec!(1);
938 let result =
939 adjust_market_buy_amount(amount, balance, price, dec!(0.25), 2.0, dec!(0)).unwrap();
940 assert_eq!(result, amount);
941 }
942
943 #[rstest]
944 fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
945 let amount = dec!(50);
949 let price = dec!(0.5);
950 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
951 let total_cost = amount + fee;
952 let result =
953 adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), 2.0, dec!(0)).unwrap();
954 close_to(result, amount, dec!(0.000001));
955 }
956
957 #[rstest]
958 fn test_sdk_adjust_buy_conserves_notional_platform_only() {
959 let amount = dec!(50);
962 let price = dec!(0.5);
963 let adjusted =
964 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, dec!(0)).unwrap();
965 let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
966 close_to(adjusted + fee, amount, dec!(0.000001));
967 assert!(adjusted < amount);
968 }
969
970 #[rstest]
971 fn test_sdk_adjust_buy_conserves_notional_builder_only() {
972 let amount = dec!(50);
974 let price = dec!(0.5);
975 let builder_rate = dec!(0.01);
976 let adjusted =
977 adjust_market_buy_amount(amount, amount, price, dec!(0), 0.0, builder_rate).unwrap();
978 let fee = calc_builder_fee_sdk(adjusted, builder_rate);
979 close_to(adjusted + fee, amount, dec!(0.000001));
980 }
981
982 #[rstest]
983 fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
984 let amount = dec!(50);
986 let price = dec!(0.5);
987 let builder_rate = dec!(0.01);
988 let adjusted =
989 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
990 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
991 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
992 close_to(adjusted + platform + builder, amount, dec!(0.000001));
993 }
994
995 #[rstest]
996 fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
997 let amount = dec!(30);
999 let price = dec!(0.3);
1000 let builder_rate = dec!(0.02);
1001 let adjusted =
1002 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1003 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1004 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1005 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1006 }
1007
1008 #[rstest]
1009 fn test_parse_timestamp_ms() {
1010 let ts = parse_timestamp("1703875200000").unwrap();
1011 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1012 }
1013
1014 #[rstest]
1015 fn test_parse_timestamp_secs() {
1016 let ts = parse_timestamp("1703875200").unwrap();
1017 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1018 }
1019
1020 #[rstest]
1021 fn test_parse_timestamp_rfc3339() {
1022 let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1023 assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1024 }
1025
1026 #[rstest]
1027 fn test_parse_liquidity_side_maker() {
1028 assert_eq!(
1029 parse_liquidity_side(PolymarketLiquiditySide::Maker),
1030 LiquiditySide::Maker
1031 );
1032 }
1033
1034 #[rstest]
1035 fn test_parse_liquidity_side_taker() {
1036 assert_eq!(
1037 parse_liquidity_side(PolymarketLiquiditySide::Taker),
1038 LiquiditySide::Taker
1039 );
1040 }
1041
1042 #[rstest]
1043 fn test_parse_order_status_report_from_fixture() {
1044 let path = "test_data/http_open_order.json";
1045 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1046 let order: PolymarketOpenOrder =
1047 serde_json::from_str(&content).expect("Failed to parse test data");
1048
1049 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1050 let account_id = AccountId::from("POLYMARKET-001");
1051
1052 let report = parse_order_status_report(
1053 &order,
1054 instrument_id,
1055 account_id,
1056 None,
1057 4,
1058 6,
1059 UnixNanos::from(1_000_000_000u64),
1060 );
1061
1062 assert_eq!(report.account_id, account_id);
1063 assert_eq!(report.instrument_id, instrument_id);
1064 assert_eq!(report.order_side, OrderSide::Buy);
1065 assert_eq!(report.order_type, OrderType::Limit);
1066 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1067 assert_eq!(report.order_status, OrderStatus::Accepted);
1068 assert!(report.price.is_some());
1069 assert_eq!(
1070 report.ts_accepted,
1071 UnixNanos::from(1_703_875_200_000_000_000u64)
1072 );
1073 assert_eq!(
1074 report.ts_last,
1075 UnixNanos::from(1_703_875_200_000_000_000u64)
1076 );
1077 assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1078 assert_eq!(report.expire_time, None);
1080 }
1081
1082 #[rstest]
1083 #[case::null(None, None)]
1084 #[case::zero_string(Some("0"), None)]
1085 #[case::empty_string(Some(""), None)]
1086 #[case::garbage(Some("not-a-number"), None)]
1087 #[case::positive_seconds(
1088 Some("1735689600"),
1089 Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1090 )]
1091 fn test_parse_order_status_report_expiration(
1092 #[case] raw: Option<&str>,
1093 #[case] expected: Option<UnixNanos>,
1094 ) {
1095 let order = PolymarketOpenOrder {
1096 associate_trades: None,
1097 id: "0xid".to_string(),
1098 status: PolymarketOrderStatus::Live,
1099 market: Ustr::from("0xm"),
1100 original_size: dec!(100),
1101 outcome: PolymarketOutcome::yes(),
1102 maker_address: "0xmaker".to_string(),
1103 owner: "owner".to_string(),
1104 price: dec!(0.5),
1105 side: PolymarketOrderSide::Buy,
1106 size_matched: dec!(0),
1107 asset_id: Ustr::from("token"),
1108 expiration: raw.map(|s| s.to_string()),
1109 order_type: PolymarketOrderType::GTD,
1110 created_at: 1_703_875_200,
1111 };
1112
1113 let report = parse_order_status_report(
1114 &order,
1115 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1116 AccountId::from("POLYMARKET-001"),
1117 None,
1118 4,
1119 6,
1120 UnixNanos::from(1_000_000_000u64),
1121 );
1122
1123 assert_eq!(report.expire_time, expected);
1124 }
1125
1126 #[rstest]
1127 fn test_parse_fill_report_from_fixture() {
1128 let path = "test_data/http_trade_report.json";
1129 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1130 let trade: PolymarketTradeReport =
1131 serde_json::from_str(&content).expect("Failed to parse test data");
1132
1133 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1134 let account_id = AccountId::from("POLYMARKET-001");
1135 let currency = Currency::pUSD();
1136
1137 let report = parse_fill_report(
1138 &trade,
1139 instrument_id,
1140 account_id,
1141 None,
1142 4,
1143 6,
1144 currency,
1145 Decimal::ZERO,
1146 UnixNanos::from(1_000_000_000u64),
1147 );
1148
1149 assert_eq!(report.account_id, account_id);
1150 assert_eq!(report.instrument_id, instrument_id);
1151 assert_eq!(report.order_side, OrderSide::Buy);
1152 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1153 assert_eq!(report.commission.as_f64(), 0.0);
1154 }
1155
1156 #[rstest]
1157 fn test_parse_fill_report_forwards_taker_fee_rate() {
1158 let path = "test_data/http_trade_report.json";
1159 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1160 let trade: PolymarketTradeReport =
1161 serde_json::from_str(&content).expect("Failed to parse test data");
1162
1163 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1164 let account_id = AccountId::from("POLYMARKET-001");
1165 let currency = Currency::pUSD();
1166
1167 let report = parse_fill_report(
1169 &trade,
1170 instrument_id,
1171 account_id,
1172 None,
1173 4,
1174 6,
1175 currency,
1176 dec!(0.03),
1177 UnixNanos::from(1_000_000_000u64),
1178 );
1179
1180 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1181 assert!((report.commission.as_f64() - 0.1875).abs() < 1e-10);
1182 }
1183
1184 #[rstest]
1185 fn test_instrument_taker_fee_reads_binary_option() {
1186 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
1187
1188 let path = "test_data/gamma_market_sports_market_money_line.json";
1189 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1190 let market = serde_json::from_str(&content).expect("Failed to parse test data");
1191 let defs = parse_gamma_market(&market).unwrap();
1192 let instrument =
1193 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1194
1195 assert_eq!(instrument_taker_fee(&instrument), dec!(0.03));
1196 }
1197
1198 #[rstest]
1199 #[case(
1200 PolymarketLiquiditySide::Taker,
1201 PolymarketOrderSide::Buy,
1202 "token_a",
1203 "token_b",
1204 OrderSide::Buy
1205 )]
1206 #[case(
1207 PolymarketLiquiditySide::Taker,
1208 PolymarketOrderSide::Sell,
1209 "token_a",
1210 "token_b",
1211 OrderSide::Sell
1212 )]
1213 #[case(
1214 PolymarketLiquiditySide::Maker,
1215 PolymarketOrderSide::Buy,
1216 "token_a",
1217 "token_b",
1218 OrderSide::Buy
1219 )]
1220 #[case(
1221 PolymarketLiquiditySide::Maker,
1222 PolymarketOrderSide::Buy,
1223 "token_a",
1224 "token_a",
1225 OrderSide::Sell
1226 )]
1227 #[case(
1228 PolymarketLiquiditySide::Maker,
1229 PolymarketOrderSide::Sell,
1230 "token_a",
1231 "token_a",
1232 OrderSide::Buy
1233 )]
1234 fn test_determine_order_side(
1235 #[case] trader_side: PolymarketLiquiditySide,
1236 #[case] trade_side: PolymarketOrderSide,
1237 #[case] taker_asset: &str,
1238 #[case] maker_asset: &str,
1239 #[case] expected: OrderSide,
1240 ) {
1241 let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
1242 assert_eq!(result, expected);
1243 }
1244
1245 #[rstest]
1246 fn test_make_composite_trade_id_basic() {
1247 let trade_id = "trade-abc123";
1248 let venue_order_id = "order-xyz789";
1249 let result = make_composite_trade_id(trade_id, venue_order_id);
1250 assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
1251 }
1252
1253 #[rstest]
1254 fn test_make_composite_trade_id_truncates_long_ids() {
1255 let trade_id = "a]".repeat(30);
1256 let venue_order_id = "b".repeat(20);
1257 let result = make_composite_trade_id(&trade_id, &venue_order_id);
1258 assert!(result.as_str().len() <= 36);
1259 }
1260
1261 #[rstest]
1262 fn test_make_composite_trade_id_short_venue_id() {
1263 let trade_id = "t123";
1264 let venue_order_id = "ab";
1265 let result = make_composite_trade_id(trade_id, venue_order_id);
1266 assert_eq!(result.as_str(), "t123-ab");
1267 }
1268
1269 #[rstest]
1270 fn test_make_composite_trade_id_uniqueness() {
1271 let id_a = make_composite_trade_id("same-trade", "order-aaa");
1272 let id_b = make_composite_trade_id("same-trade", "order-bbb");
1273 assert_ne!(id_a, id_b);
1274 }
1275
1276 #[rstest]
1279 fn test_calculate_market_price_buy_single_level() {
1280 let levels = vec![ClobBookLevel {
1281 price: "0.55".to_string(),
1282 size: "200.0".to_string(),
1283 }];
1284 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1285 assert_eq!(result.crossing_price, dec!(0.55));
1286 assert!(result.expected_base_qty > dec!(90));
1288 }
1289
1290 #[rstest]
1291 fn test_calculate_market_price_buy_walks_multiple_levels() {
1292 let levels = vec![
1294 ClobBookLevel {
1295 price: "0.55".to_string(),
1296 size: "100.0".to_string(),
1297 },
1298 ClobBookLevel {
1299 price: "0.50".to_string(),
1300 size: "10.0".to_string(),
1301 },
1302 ClobBookLevel {
1303 price: "0.60".to_string(),
1304 size: "200.0".to_string(),
1305 },
1306 ];
1307 let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
1310 assert_eq!(result.crossing_price, dec!(0.55));
1311 let expected = dec!(10) + dec!(15) / dec!(0.55);
1312 assert_eq!(result.expected_base_qty, expected);
1313 }
1314
1315 #[rstest]
1316 fn test_calculate_market_price_buy_small_order_uses_best_ask() {
1317 let levels = vec![
1319 ClobBookLevel {
1320 price: "0.50".to_string(),
1321 size: "50.0".to_string(),
1322 },
1323 ClobBookLevel {
1324 price: "0.999".to_string(),
1325 size: "100.0".to_string(),
1326 },
1327 ClobBookLevel {
1328 price: "0.20".to_string(),
1329 size: "72.0".to_string(),
1330 },
1331 ];
1332 let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
1335 assert_eq!(result.crossing_price, dec!(0.20));
1336 assert_eq!(result.expected_base_qty, dec!(25)); }
1338
1339 #[rstest]
1340 fn test_calculate_market_price_sell_walks_levels() {
1341 let levels = vec![
1343 ClobBookLevel {
1344 price: "0.48".to_string(),
1345 size: "100.0".to_string(),
1346 },
1347 ClobBookLevel {
1348 price: "0.50".to_string(),
1349 size: "50.0".to_string(),
1350 },
1351 ];
1352 let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
1355 assert_eq!(result.crossing_price, dec!(0.48));
1356 assert_eq!(result.expected_base_qty, dec!(80));
1357 }
1358
1359 #[rstest]
1360 fn test_calculate_market_price_empty_book() {
1361 let levels: Vec<ClobBookLevel> = vec![];
1362 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1363 assert!(result.is_err());
1364 }
1365
1366 #[rstest]
1367 fn test_calculate_market_price_all_zero_levels_returns_error() {
1368 let levels = vec![
1369 ClobBookLevel {
1370 price: "0".to_string(),
1371 size: "100.0".to_string(),
1372 },
1373 ClobBookLevel {
1374 price: "0.50".to_string(),
1375 size: "0".to_string(),
1376 },
1377 ];
1378 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1379 assert!(result.is_err());
1380 }
1381
1382 #[rstest]
1383 fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
1384 let levels = vec![ClobBookLevel {
1385 price: "0.55".to_string(),
1386 size: "10.0".to_string(),
1387 }];
1388 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1390 assert_eq!(result.crossing_price, dec!(0.55));
1391 assert_eq!(result.expected_base_qty, dec!(10)); }
1393
1394 #[rstest]
1395 fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
1396 let levels_ascending = vec![
1397 ClobBookLevel {
1398 price: "0.20".to_string(),
1399 size: "72.0".to_string(),
1400 },
1401 ClobBookLevel {
1402 price: "0.50".to_string(),
1403 size: "50.0".to_string(),
1404 },
1405 ClobBookLevel {
1406 price: "0.999".to_string(),
1407 size: "100.0".to_string(),
1408 },
1409 ];
1410 let levels_descending = vec![
1411 ClobBookLevel {
1412 price: "0.999".to_string(),
1413 size: "100.0".to_string(),
1414 },
1415 ClobBookLevel {
1416 price: "0.50".to_string(),
1417 size: "50.0".to_string(),
1418 },
1419 ClobBookLevel {
1420 price: "0.20".to_string(),
1421 size: "72.0".to_string(),
1422 },
1423 ];
1424 let levels_shuffled = vec![
1425 ClobBookLevel {
1426 price: "0.50".to_string(),
1427 size: "50.0".to_string(),
1428 },
1429 ClobBookLevel {
1430 price: "0.20".to_string(),
1431 size: "72.0".to_string(),
1432 },
1433 ClobBookLevel {
1434 price: "0.999".to_string(),
1435 size: "100.0".to_string(),
1436 },
1437 ];
1438
1439 let r1 =
1440 calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1441 let r2 =
1442 calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1443 let r3 =
1444 calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
1445
1446 assert_eq!(r1.crossing_price, r2.crossing_price);
1447 assert_eq!(r2.crossing_price, r3.crossing_price);
1448 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1449 assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
1450 }
1451
1452 #[rstest]
1453 fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
1454 let levels_a = vec![
1455 ClobBookLevel {
1456 price: "0.48".to_string(),
1457 size: "100.0".to_string(),
1458 },
1459 ClobBookLevel {
1460 price: "0.50".to_string(),
1461 size: "50.0".to_string(),
1462 },
1463 ];
1464 let levels_b = vec![
1465 ClobBookLevel {
1466 price: "0.50".to_string(),
1467 size: "50.0".to_string(),
1468 },
1469 ClobBookLevel {
1470 price: "0.48".to_string(),
1471 size: "100.0".to_string(),
1472 },
1473 ];
1474
1475 let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
1476 let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
1477
1478 assert_eq!(r1.crossing_price, r2.crossing_price);
1479 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1480 }
1481
1482 mod adjust_market_buy_amount_property_tests {
1483 use proptest::prelude::*;
1484 use rstest::rstest;
1485
1486 use super::*;
1487
1488 fn decimal_at_usdc_scale(micros: u64) -> Decimal {
1492 Decimal::new(micros as i64, USDC_DECIMALS)
1493 }
1494
1495 fn decimal_from_bps(bps: u32) -> Decimal {
1497 Decimal::new(i64::from(bps), 4)
1498 }
1499
1500 fn compute_total_cost(
1503 amount: Decimal,
1504 price: Decimal,
1505 fee_rate: Decimal,
1506 fee_exponent: f64,
1507 builder: Decimal,
1508 ) -> Decimal {
1509 let base = price * (Decimal::ONE - price);
1510 let base_f64: f64 = base.try_into().unwrap_or(0.0);
1511 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
1512 let platform_fee_rate = fee_rate * curve;
1513 let platform_fee = amount / price * platform_fee_rate;
1514 amount + platform_fee + amount * builder
1515 }
1516
1517 proptest! {
1518 #[rstest]
1521 fn prop_adjust_market_buy_amount_is_deterministic(
1522 amount_micros in 1u64..=1_000_000_000_000u64,
1523 balance_micros in 1u64..=1_000_000_000_000u64,
1524 price_milli in 1u32..=999u32,
1525 fee_rate_bps in 0u32..=1_000u32,
1526 fee_exponent in 1.0f64..=3.0f64,
1527 builder_bps in 0u32..=500u32,
1528 ) {
1529 let amount = decimal_at_usdc_scale(amount_micros);
1530 let balance = decimal_at_usdc_scale(balance_micros);
1531 let price = Decimal::new(i64::from(price_milli), 3);
1532 let fee_rate = decimal_from_bps(fee_rate_bps);
1533 let builder = decimal_from_bps(builder_bps);
1534
1535 let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
1536 let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
1537 prop_assert_eq!(r1.is_ok(), r2.is_ok());
1538 if let (Ok(a), Ok(b)) = (r1, r2) {
1539 prop_assert_eq!(a, b);
1540 }
1541 }
1542
1543 #[rstest]
1548 fn prop_adjust_market_buy_amount_non_binding_returns_amount(
1549 amount_micros in 1u64..=1_000_000_000u64,
1550 price_milli in 1u32..=999u32,
1551 fee_rate_bps in 0u32..=1_000u32,
1552 fee_exponent in 1.0f64..=3.0f64,
1553 builder_bps in 0u32..=500u32,
1554 ) {
1555 let amount = decimal_at_usdc_scale(amount_micros);
1556 let price = Decimal::new(i64::from(price_milli), 3);
1557 let fee_rate = decimal_from_bps(fee_rate_bps);
1558 let builder = decimal_from_bps(builder_bps);
1559
1560 let total_cost =
1564 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1565 let balance = total_cost * Decimal::from(10);
1566
1567 let adjusted = adjust_market_buy_amount(
1568 amount, balance, price, fee_rate, fee_exponent, builder,
1569 )
1570 .expect("non-binding balance must yield Ok");
1571 prop_assert_eq!(
1572 adjusted, amount,
1573 "non-binding branch must return the input amount unchanged",
1574 );
1575 }
1576
1577 #[rstest]
1582 fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
1583 amount_micros in 1_000u64..=1_000_000_000u64,
1584 price_milli in 10u32..=990u32,
1585 fee_rate_bps in 0u32..=1_000u32,
1586 fee_exponent in 1.0f64..=3.0f64,
1587 builder_bps in 0u32..=500u32,
1588 fraction_thousandths in 100u32..=900u32,
1589 ) {
1590 let amount = decimal_at_usdc_scale(amount_micros);
1591 let price = Decimal::new(i64::from(price_milli), 3);
1592 let fee_rate = decimal_from_bps(fee_rate_bps);
1593 let builder = decimal_from_bps(builder_bps);
1594
1595 let total_cost =
1598 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1599 let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
1600 let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
1601 if balance.is_zero() {
1602 return Ok(()); }
1604
1605 let adjusted = adjust_market_buy_amount(
1606 amount, balance, price, fee_rate, fee_exponent, builder,
1607 )
1608 .expect("non-zero balance fraction must yield Ok in binding branch");
1609
1610 prop_assert!(
1611 adjusted < amount,
1612 "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
1613 );
1614 prop_assert!(
1615 adjusted > Decimal::ZERO,
1616 "adjusted must be strictly positive",
1617 );
1618 prop_assert_eq!(
1619 adjusted,
1620 adjusted.trunc_with_scale(USDC_DECIMALS),
1621 "adjusted must be at USDC_DECIMALS scale",
1622 );
1623 let recomputed_cost =
1624 compute_total_cost(adjusted, price, fee_rate, fee_exponent, builder);
1625 prop_assert!(
1626 recomputed_cost <= balance,
1627 "total_cost {recomputed_cost} must fit balance {balance}",
1628 );
1629 }
1630
1631 #[rstest]
1635 fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
1636 amount_pico in 1_000_000u64..=1_000_000_000_000u64,
1637 price_milli in 1u32..=999u32,
1638 fee_rate_bps in 0u32..=1_000u32,
1639 fee_exponent in 1.0f64..=3.0f64,
1640 builder_bps in 0u32..=500u32,
1641 ) {
1642 let amount = Decimal::new(amount_pico as i64, 9);
1645 let price = Decimal::new(i64::from(price_milli), 3);
1646 let fee_rate = decimal_from_bps(fee_rate_bps);
1647 let builder = decimal_from_bps(builder_bps);
1648
1649 let total_cost =
1651 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
1652 let balance = total_cost * Decimal::from(10);
1653
1654 if let Ok(adjusted) = adjust_market_buy_amount(
1655 amount, balance, price, fee_rate, fee_exponent, builder,
1656 ) {
1657 prop_assert_eq!(
1658 adjusted,
1659 adjusted.trunc_with_scale(USDC_DECIMALS),
1660 "result must be at USDC_DECIMALS scale",
1661 );
1662 prop_assert!(
1663 adjusted <= amount,
1664 "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
1665 );
1666 }
1667 }
1668 }
1669 }
1670}