1use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::{
21 UUID4, UnixNanos,
22 datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
23};
24use nautilus_model::{
25 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
26 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
27 instruments::InstrumentAny,
28 reports::{FillReport, OrderStatusReport},
29 types::{AccountBalance, Currency, Money, Price, Quantity},
30};
31use rust_decimal::Decimal;
32
33use crate::{
34 common::{
35 consts::{DUST_SNAP_THRESHOLD_DEC, USDC_DECIMALS},
36 enums::{
37 PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
38 PolymarketOrderStatus,
39 },
40 models::PolymarketMakerOrder,
41 },
42 http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
43};
44
45pub const fn parse_liquidity_side(side: PolymarketLiquiditySide) -> LiquiditySide {
47 match side {
48 PolymarketLiquiditySide::Maker => LiquiditySide::Maker,
49 PolymarketLiquiditySide::Taker => LiquiditySide::Taker,
50 }
51}
52
53pub fn resolve_order_status(
58 status: PolymarketOrderStatus,
59 event_type: PolymarketEventType,
60) -> OrderStatus {
61 if status == PolymarketOrderStatus::Invalid && event_type == PolymarketEventType::Cancellation {
62 OrderStatus::Canceled
63 } else {
64 OrderStatus::from(status)
65 }
66}
67
68pub fn determine_order_side(
75 trader_side: PolymarketLiquiditySide,
76 trade_side: PolymarketOrderSide,
77 taker_asset_id: &str,
78 maker_asset_id: &str,
79) -> OrderSide {
80 let order_side = OrderSide::from(trade_side);
81
82 if trader_side == PolymarketLiquiditySide::Taker {
83 return order_side;
84 }
85
86 let is_cross_asset = maker_asset_id != taker_asset_id;
87
88 if is_cross_asset {
89 order_side
90 } else {
91 match order_side {
92 OrderSide::Buy => OrderSide::Sell,
93 OrderSide::Sell => OrderSide::Buy,
94 }
95 }
96}
97
98pub fn make_composite_trade_id(trade_id: &str, venue_order_id: &str) -> TradeId {
106 TradeId::from(composite_trade_id_value(trade_id, venue_order_id).as_str())
107}
108
109pub(super) fn composite_trade_id_value(trade_id: &str, venue_order_id: &str) -> String {
110 let prefix_len = trade_id.len().min(27);
111 let suffix_len = venue_order_id.len().min(8);
112 let suffix_start = venue_order_id.len().saturating_sub(suffix_len);
113 format!(
114 "{}-{}",
115 &trade_id[..prefix_len],
116 &venue_order_id[suffix_start..]
117 )
118}
119
120pub fn parse_order_status_report(
122 order: &PolymarketOpenOrder,
123 instrument_id: InstrumentId,
124 account_id: AccountId,
125 client_order_id: Option<ClientOrderId>,
126 price_precision: u8,
127 size_precision: u8,
128 ts_init: UnixNanos,
129) -> OrderStatusReport {
130 let expire_time = order
131 .expiration
132 .as_deref()
133 .and_then(parse_expiration_nanos)
134 .map(UnixNanos::from);
135 parse_validated_order_status_report(
136 order,
137 OrderReportParseContext {
138 instrument_id,
139 account_id,
140 client_order_id,
141 venue_order_id: VenueOrderId::from(order.id.as_str()),
142 price_precision,
143 size_precision,
144 ts_accepted: UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND),
145 expire_time,
146 ts_init,
147 },
148 )
149}
150
151#[derive(Clone, Copy, Debug)]
152pub(super) struct OrderReportParseContext {
153 pub instrument_id: InstrumentId,
154 pub account_id: AccountId,
155 pub client_order_id: Option<ClientOrderId>,
156 pub venue_order_id: VenueOrderId,
157 pub price_precision: u8,
158 pub size_precision: u8,
159 pub ts_accepted: UnixNanos,
160 pub expire_time: Option<UnixNanos>,
161 pub ts_init: UnixNanos,
162}
163
164pub(super) fn parse_validated_order_status_report(
165 order: &PolymarketOpenOrder,
166 ctx: OrderReportParseContext,
167) -> OrderStatusReport {
168 let order_side = OrderSide::from(order.side);
169 let time_in_force = TimeInForce::from(order.order_type);
170 let quantity = Quantity::from_decimal_dp(order.original_size, ctx.size_precision)
171 .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
172 let raw_filled_qty = Quantity::from_decimal_dp(order.size_matched, ctx.size_precision)
173 .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
174 let order_status = if order.status == PolymarketOrderStatus::Matched {
176 recovered_terminal_order_status(time_in_force, quantity, raw_filled_qty)
177 } else {
178 OrderStatus::from(order.status)
179 };
180 let filled_qty = snap_filled_qty_to_quantity(quantity, raw_filled_qty, order_status);
181 let price = Price::from_decimal_dp(order.price, ctx.price_precision)
182 .unwrap_or_else(|_| Price::zero(ctx.price_precision));
183
184 let mut report = OrderStatusReport::new(
185 ctx.account_id,
186 ctx.instrument_id,
187 ctx.client_order_id,
188 ctx.venue_order_id,
189 order_side.into(),
190 OrderType::Limit,
191 time_in_force,
192 order_status,
193 quantity,
194 filled_qty,
195 ctx.ts_accepted,
196 ctx.ts_accepted, ctx.ts_init,
198 None, );
200 report.price = Some(price);
201 report.expire_time = ctx.expire_time;
202 report
203}
204
205pub(super) fn parse_expiration_nanos(value: &str) -> Option<u64> {
210 let secs: u64 = value.parse().ok()?;
211 if secs == 0 {
212 return None;
213 }
214 secs.checked_mul(NANOSECONDS_IN_SECOND)
215}
216
217#[expect(clippy::too_many_arguments)]
232pub fn parse_fill_report(
233 trade: &PolymarketTradeReport,
234 instrument_id: InstrumentId,
235 account_id: AccountId,
236 client_order_id: Option<ClientOrderId>,
237 price_precision: u8,
238 size_precision: u8,
239 currency: Currency,
240 taker_fee_rate: Decimal,
241 fee_exponent: f64,
242 ts_init: UnixNanos,
243) -> anyhow::Result<FillReport> {
244 parse_validated_fill_report(
245 trade,
246 TakerFillParseContext {
247 instrument_id,
248 account_id,
249 client_order_id,
250 venue_order_id: VenueOrderId::from(trade.taker_order_id.as_str()),
251 trade_id: TradeId::from(trade.id.as_str()),
252 price_precision,
253 size_precision,
254 currency,
255 taker_fee_rate,
256 fee_exponent,
257 ts_event: parse_timestamp(&trade.match_time).unwrap_or(ts_init),
258 ts_init,
259 },
260 )
261}
262
263#[derive(Clone, Copy, Debug)]
264pub(super) struct TakerFillParseContext {
265 pub instrument_id: InstrumentId,
266 pub account_id: AccountId,
267 pub client_order_id: Option<ClientOrderId>,
268 pub venue_order_id: VenueOrderId,
269 pub trade_id: TradeId,
270 pub price_precision: u8,
271 pub size_precision: u8,
272 pub currency: Currency,
273 pub taker_fee_rate: Decimal,
274 pub fee_exponent: f64,
275 pub ts_event: UnixNanos,
276 pub ts_init: UnixNanos,
277}
278
279pub(super) fn parse_validated_fill_report(
280 trade: &PolymarketTradeReport,
281 ctx: TakerFillParseContext,
282) -> anyhow::Result<FillReport> {
283 let order_side = OrderSide::from(trade.side);
284 let last_qty = Quantity::from_decimal_dp(trade.size, ctx.size_precision)
285 .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
286 let last_px = Price::from_decimal_dp(trade.price, ctx.price_precision)
287 .unwrap_or_else(|_| Price::zero(ctx.price_precision));
288 let liquidity_side = parse_liquidity_side(trade.trader_side);
289
290 let commission_value = compute_commission(
291 ctx.taker_fee_rate,
292 ctx.fee_exponent,
293 trade.size,
294 trade.price,
295 liquidity_side,
296 );
297 let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
298 format!(
299 "failed to represent commission {commission_value} for {} as Money",
300 ctx.instrument_id
301 )
302 })?;
303
304 Ok(FillReport {
305 account_id: ctx.account_id,
306 instrument_id: ctx.instrument_id,
307 venue_order_id: ctx.venue_order_id,
308 trade_id: ctx.trade_id,
309 order_side,
310 last_qty,
311 last_px,
312 commission,
313 liquidity_side,
314 avg_px: None,
315 report_id: UUID4::new(),
316 ts_event: ctx.ts_event,
317 ts_init: ctx.ts_init,
318 client_order_id: ctx.client_order_id,
319 venue_position_id: None,
320 })
321}
322
323#[expect(clippy::too_many_arguments)]
338pub fn build_maker_fill_report(
339 mo: &PolymarketMakerOrder,
340 trade_id: &str,
341 trader_side: PolymarketLiquiditySide,
342 trade_side: PolymarketOrderSide,
343 taker_asset_id: &str,
344 account_id: AccountId,
345 instrument_id: InstrumentId,
346 price_precision: u8,
347 size_precision: u8,
348 currency: Currency,
349 liquidity_side: LiquiditySide,
350 ts_event: UnixNanos,
351 ts_init: UnixNanos,
352) -> anyhow::Result<FillReport> {
353 parse_validated_maker_fill_report(
354 mo,
355 trader_side,
356 trade_side,
357 taker_asset_id,
358 MakerFillParseContext {
359 account_id,
360 instrument_id,
361 venue_order_id: VenueOrderId::from(mo.order_id.as_str()),
362 trade_id: make_composite_trade_id(trade_id, &mo.order_id),
363 price_precision,
364 size_precision,
365 currency,
366 liquidity_side,
367 ts_event,
368 ts_init,
369 },
370 )
371}
372
373#[derive(Clone, Copy, Debug)]
374pub(super) struct MakerFillParseContext {
375 pub account_id: AccountId,
376 pub instrument_id: InstrumentId,
377 pub venue_order_id: VenueOrderId,
378 pub trade_id: TradeId,
379 pub price_precision: u8,
380 pub size_precision: u8,
381 pub currency: Currency,
382 pub liquidity_side: LiquiditySide,
383 pub ts_event: UnixNanos,
384 pub ts_init: UnixNanos,
385}
386
387pub(super) fn parse_validated_maker_fill_report(
388 mo: &PolymarketMakerOrder,
389 trader_side: PolymarketLiquiditySide,
390 trade_side: PolymarketOrderSide,
391 taker_asset_id: &str,
392 ctx: MakerFillParseContext,
393) -> anyhow::Result<FillReport> {
394 let order_side = determine_order_side(
395 trader_side,
396 trade_side,
397 taker_asset_id,
398 mo.asset_id.as_str(),
399 );
400 let last_qty = Quantity::from_decimal_dp(mo.matched_amount, ctx.size_precision)
401 .unwrap_or_else(|_| Quantity::zero(ctx.size_precision));
402 let last_px = Price::from_decimal_dp(mo.price, ctx.price_precision)
403 .unwrap_or_else(|_| Price::zero(ctx.price_precision));
404 let commission_value = compute_commission(
405 Decimal::ZERO,
406 1.0,
407 mo.matched_amount,
408 mo.price,
409 ctx.liquidity_side,
410 );
411 let commission = Money::from_decimal(commission_value, ctx.currency).with_context(|| {
412 format!(
413 "failed to represent commission {commission_value} for {} as Money",
414 ctx.instrument_id
415 )
416 })?;
417
418 Ok(FillReport {
419 account_id: ctx.account_id,
420 instrument_id: ctx.instrument_id,
421 venue_order_id: ctx.venue_order_id,
422 trade_id: ctx.trade_id,
423 order_side,
424 last_qty,
425 last_px,
426 commission,
427 liquidity_side: ctx.liquidity_side,
428 avg_px: None,
429 report_id: UUID4::new(),
430 ts_event: ctx.ts_event,
431 ts_init: ctx.ts_init,
432 client_order_id: None,
433 venue_position_id: None,
434 })
435}
436
437#[must_use]
443pub fn instrument_taker_fee(instrument: &InstrumentAny) -> Decimal {
444 match instrument {
445 InstrumentAny::BinaryOption(bo) => bo.taker_fee,
446 _ => Decimal::ZERO,
447 }
448}
449
450#[must_use]
455pub fn instrument_fee_exponent(instrument: &InstrumentAny) -> f64 {
456 match instrument {
457 InstrumentAny::BinaryOption(bo) => bo
458 .info
459 .as_ref()
460 .and_then(|info| info.get("fee_schedule"))
461 .and_then(|fs| fs.get("exponent"))
462 .and_then(serde_json::Value::as_f64)
463 .unwrap_or(1.0),
464 _ => 1.0,
465 }
466}
467
468pub fn adjust_market_buy_amount(
491 amount: Decimal,
492 user_pusd_balance: Decimal,
493 price: Decimal,
494 fee_rate: Decimal,
495 fee_exponent: f64,
496 builder_taker_fee_rate: Decimal,
497) -> anyhow::Result<Decimal> {
498 if price <= Decimal::ZERO || price >= Decimal::ONE {
499 anyhow::bail!(
500 "invalid market-buy price {price}: must satisfy 0 < price < 1 for fee adjustment",
501 );
502 }
503
504 let platform_fee_rate = fee_curve_rate(fee_rate, price, fee_exponent);
505
506 let platform_fee = amount / price * platform_fee_rate;
507 let total_cost = amount + platform_fee + amount * builder_taker_fee_rate;
508
509 let raw = if user_pusd_balance <= total_cost {
510 let divisor = Decimal::ONE + platform_fee_rate / price + builder_taker_fee_rate;
511 user_pusd_balance / divisor
512 } else {
513 amount
514 };
515
516 let adjusted = raw.trunc_with_scale(USDC_DECIMALS);
517 if adjusted.is_zero() {
518 anyhow::bail!(
519 "user_pusd_balance {user_pusd_balance} too small to cover fees at price {price}; \
520 fee-adjusted amount truncated to zero"
521 );
522 }
523 Ok(adjusted)
524}
525
526pub fn compute_commission(
540 fee_rate: Decimal,
541 fee_exponent: f64,
542 size: Decimal,
543 price: Decimal,
544 liquidity_side: LiquiditySide,
545) -> Decimal {
546 if liquidity_side != LiquiditySide::Taker || fee_rate.is_zero() {
547 return Decimal::ZERO;
548 }
549
550 let commission = size * fee_curve_rate(fee_rate, price, fee_exponent);
551 commission.round_dp(5)
552}
553
554fn fee_curve_rate(fee_rate: Decimal, price: Decimal, fee_exponent: f64) -> Decimal {
555 let base = price * (Decimal::ONE - price);
556 let base_f64: f64 = base.try_into().unwrap_or(0.0);
557 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
558 fee_rate * curve
559}
560
561pub(crate) fn sum_filled_quantity(fills: &[FillReport]) -> Decimal {
563 fills.iter().map(|f| f.last_qty.as_decimal()).sum()
564}
565
566pub(crate) fn weighted_average_price(
569 fills: &[FillReport],
570 total_filled: Decimal,
571) -> Option<Decimal> {
572 if total_filled.is_zero() {
573 return None;
574 }
575 let weighted: Decimal = fills
576 .iter()
577 .map(|f| f.last_qty.as_decimal() * f.last_px.as_decimal())
578 .sum();
579 Some(weighted / total_filled)
580}
581
582pub(crate) fn recovered_terminal_order_status(
587 time_in_force: TimeInForce,
588 quantity: Quantity,
589 filled_qty: Quantity,
590) -> OrderStatus {
591 if time_in_force == TimeInForce::Ioc && filled_qty < quantity {
592 return OrderStatus::Canceled;
593 }
594
595 let dust_diff = (quantity.as_decimal() - filled_qty.as_decimal()).abs();
596 if filled_qty >= quantity || dust_diff < DUST_SNAP_THRESHOLD_DEC {
597 OrderStatus::Filled
598 } else {
599 OrderStatus::Canceled
600 }
601}
602
603pub(crate) fn snap_filled_qty_to_quantity(
611 quantity: Quantity,
612 filled_qty: Quantity,
613 order_status: OrderStatus,
614) -> Quantity {
615 if order_status != OrderStatus::Filled {
616 return filled_qty;
617 }
618 let diff = quantity.as_decimal() - filled_qty.as_decimal();
619 if !diff.is_zero() && diff.abs() < DUST_SNAP_THRESHOLD_DEC {
620 quantity
621 } else {
622 filled_qty
623 }
624}
625
626const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
628
629pub fn parse_balance_allowance(
635 balance_raw: Decimal,
636 currency: Currency,
637) -> anyhow::Result<AccountBalance> {
638 let balance_pusd = balance_raw / USDC_SCALE;
639 AccountBalance::from_total_and_locked(balance_pusd, Decimal::ZERO, currency)
640 .map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))
641}
642
643#[derive(Debug)]
645pub struct MarketPriceResult {
646 pub crossing_price: Decimal,
648 pub expected_base_qty: Decimal,
650}
651
652pub fn calculate_market_price(
667 book_levels: &[ClobBookLevel],
668 amount: Decimal,
669 side: PolymarketOrderSide,
670) -> anyhow::Result<MarketPriceResult> {
671 if book_levels.is_empty() {
672 anyhow::bail!("Empty order book: no liquidity available for market order");
673 }
674
675 let mut parsed_levels: Vec<(Decimal, Decimal)> = book_levels
678 .iter()
679 .map(|l| {
680 let price = Decimal::from_str_exact(&l.price).unwrap_or(Decimal::ZERO);
681 let size = Decimal::from_str_exact(&l.size).unwrap_or(Decimal::ZERO);
682 (price, size)
683 })
684 .filter(|(p, s)| !p.is_zero() && !s.is_zero())
685 .collect();
686
687 if parsed_levels.is_empty() {
688 anyhow::bail!("Empty order book: no valid price levels for market order");
689 }
690
691 match side {
692 PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
693 PolymarketOrderSide::Sell => parsed_levels.sort_by_key(|b| std::cmp::Reverse(b.0)),
694 }
695
696 let mut remaining = amount;
697 let mut last_price = Decimal::ZERO;
698 let mut total_base_qty = Decimal::ZERO;
699
700 for &(price, size) in &parsed_levels {
701 last_price = price;
702
703 match side {
704 PolymarketOrderSide::Buy => {
705 let level_usdc = size * price;
706 let consumed_usdc = level_usdc.min(remaining);
707 let shares_at_level = consumed_usdc / price;
708 total_base_qty += shares_at_level;
709 remaining -= consumed_usdc;
710 }
711 PolymarketOrderSide::Sell => {
712 let consumed_shares = size.min(remaining);
713 total_base_qty += consumed_shares;
714 remaining -= consumed_shares;
715 }
716 }
717
718 if remaining <= Decimal::ZERO {
719 return Ok(MarketPriceResult {
720 crossing_price: last_price,
721 expected_base_qty: total_base_qty,
722 });
723 }
724 }
725
726 Ok(MarketPriceResult {
729 crossing_price: last_price,
730 expected_base_qty: total_base_qty,
731 })
732}
733
734pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
739 if let Ok(n) = ts_str.parse::<u64>() {
740 return if n > 1_000_000_000_000 {
741 n.checked_mul(NANOSECONDS_IN_MILLISECOND)
742 .map(UnixNanos::from)
743 } else {
744 n.checked_mul(NANOSECONDS_IN_SECOND).map(UnixNanos::from)
745 };
746 }
747 let dt = ts_str.parse::<Timestamp>().ok()?;
748 Some(UnixNanos::from(u64::try_from(dt.as_nanosecond()).ok()?))
749}
750
751#[cfg(test)]
752mod tests {
753 use nautilus_execution::models::fee::{FeeModel, ProbabilityPriceFeeModel};
754 use nautilus_model::{
755 enums::{OrderSide, OrderType},
756 instruments::{Instrument, InstrumentAny, stubs::binary_option},
757 orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
758 };
759 use rstest::rstest;
760 use rust_decimal_macros::dec;
761 use ustr::Ustr;
762
763 use super::*;
764 use crate::common::enums::{
765 PolymarketOrderSide, PolymarketOrderStatus, PolymarketOrderType, PolymarketOutcome,
766 };
767
768 #[rstest]
772 #[case::filled_underfill_dust(100.000000, 99.995000, OrderStatus::Filled, 100.000000)]
774 #[case::filled_overfill_dust(714.285710, 714.285714, OrderStatus::Filled, 714.285710)]
776 #[case::filled_underfill_at_band(100.000000, 99.990000, OrderStatus::Filled, 99.990000)]
778 #[case::filled_underfill_above_band(100.000000, 99.000000, OrderStatus::Filled, 99.000000)]
780 #[case::filled_exact(100.000000, 100.000000, OrderStatus::Filled, 100.000000)]
782 #[case::accepted_underfill_dust(100.000000, 99.995000, OrderStatus::Accepted, 99.995000)]
784 #[case::canceled_underfill_dust(100.000000, 99.995000, OrderStatus::Canceled, 99.995000)]
786 fn test_snap_filled_qty_to_quantity(
787 #[case] quantity: f64,
788 #[case] filled: f64,
789 #[case] status: OrderStatus,
790 #[case] expected: f64,
791 ) {
792 let snapped = snap_filled_qty_to_quantity(
793 Quantity::new(quantity, 6),
794 Quantity::new(filled, 6),
795 status,
796 );
797 assert_eq!(snapped, Quantity::new(expected, 6));
798 }
799
800 fn make_test_fill(qty: f64, px: f64) -> FillReport {
801 FillReport::new(
802 AccountId::from("POLY-001"),
803 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
804 VenueOrderId::from("0xabc"),
805 TradeId::from("trade-1"),
806 OrderSide::Buy,
807 Quantity::new(qty, 4),
808 Price::new(px, 4),
809 Money::zero(Currency::pUSD()),
810 LiquiditySide::Taker,
811 None,
812 None,
813 UnixNanos::default(),
814 UnixNanos::default(),
815 None,
816 )
817 }
818
819 fn binary_option_fill_order(
820 instrument: &InstrumentAny,
821 liquidity_side: LiquiditySide,
822 price: &str,
823 ) -> OrderAny {
824 let limit_order = OrderTestBuilder::new(OrderType::Limit)
825 .instrument_id(instrument.id())
826 .side(OrderSide::Buy)
827 .price(Price::from(price))
828 .quantity(Quantity::from("100.00"))
829 .build();
830
831 TestOrderStubs::make_filled_order(&limit_order, instrument, liquidity_side)
832 }
833
834 #[rstest]
835 fn test_sum_filled_quantity_empty() {
836 assert_eq!(sum_filled_quantity(&[]), Decimal::ZERO);
837 }
838
839 #[rstest]
840 fn test_sum_filled_quantity_multiple() {
841 let fills = vec![
842 make_test_fill(2.5, 0.50),
843 make_test_fill(1.0, 0.60),
844 make_test_fill(3.0, 0.55),
845 ];
846 assert_eq!(sum_filled_quantity(&fills), dec!(6.5));
847 }
848
849 #[rstest]
850 fn test_weighted_average_price_zero_total_returns_none() {
851 assert!(weighted_average_price(&[], Decimal::ZERO).is_none());
852 }
853
854 #[rstest]
855 fn test_weighted_average_price_single_fill() {
856 let fills = vec![make_test_fill(10.0, 0.5)];
857 let total = sum_filled_quantity(&fills);
858 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.5)));
859 }
860
861 #[rstest]
862 fn test_weighted_average_price_weighted_by_quantity() {
863 let fills = vec![make_test_fill(2.0, 0.40), make_test_fill(8.0, 0.60)];
865 let total = sum_filled_quantity(&fills);
866 assert_eq!(weighted_average_price(&fills, total), Some(dec!(0.56)));
867 }
868
869 #[rstest]
870 #[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) {
876 let currency = Currency::pUSD();
877 let balance = parse_balance_allowance(raw, currency).unwrap();
878 let total_f64: f64 = balance.total.as_decimal().to_string().parse().unwrap();
879 assert!(
880 (total_f64 - expected).abs() < 1e-8,
881 "expected {expected}, was {total_f64}"
882 );
883 assert_eq!(balance.free, balance.total);
884 }
885
886 #[rstest]
887 #[case::crypto_p50("0.07", "0.50", dec!(1.75))]
888 #[case::crypto_p01("0.07", "0.01", dec!(0.0693))]
889 #[case::crypto_p05("0.07", "0.05", dec!(0.3325))]
890 #[case::crypto_p10("0.07", "0.10", dec!(0.63))]
891 #[case::crypto_p30("0.07", "0.30", dec!(1.47))]
892 #[case::crypto_p70("0.07", "0.70", dec!(1.47))]
893 #[case::crypto_p90("0.07", "0.90", dec!(0.63))]
894 #[case::crypto_p99("0.07", "0.99", dec!(0.0693))]
895 #[case::sports_p50("0.05", "0.50", dec!(1.25))]
896 #[case::sports_p30("0.05", "0.30", dec!(1.05))]
897 #[case::sports_p70("0.05", "0.70", dec!(1.05))]
898 #[case::politics_p50("0.04", "0.50", dec!(1.0))]
899 #[case::politics_p30("0.04", "0.30", dec!(0.84))]
900 #[case::economics_p50("0.05", "0.50", dec!(1.25))]
901 #[case::economics_p30("0.05", "0.30", dec!(1.05))]
902 #[case::geopolitics_p50("0", "0.50", dec!(0.0))]
903 fn test_compute_commission_docs_table(
904 #[case] fee_rate: &str,
905 #[case] price: &str,
906 #[case] expected: Decimal,
907 ) {
908 let commission = compute_commission(
909 Decimal::from_str_exact(fee_rate).unwrap(),
910 1.0,
911 dec!(100),
912 Decimal::from_str_exact(price).unwrap(),
913 LiquiditySide::Taker,
914 );
915 assert_eq!(commission, expected);
916 }
917
918 #[rstest]
919 fn test_compute_commission_issue_3860_strategy_buy() {
920 let commission = compute_commission(
924 dec!(0.072),
925 1.0,
926 Decimal::from_str_exact("15.463900").unwrap(),
927 dec!(0.97),
928 LiquiditySide::Taker,
929 );
930 assert_eq!(commission, dec!(0.03240));
931 }
932
933 #[rstest]
934 fn test_compute_commission_issue_3860_reconciliation_sell() {
935 let commission = compute_commission(
940 dec!(0.072),
941 1.0,
942 Decimal::from_str_exact("0.033400").unwrap(),
943 dec!(0.98),
944 LiquiditySide::Taker,
945 );
946 assert_eq!(commission, dec!(0.00005));
947 }
948
949 #[rstest]
950 fn test_compute_commission_maker_is_zero() {
951 let commission = compute_commission(
952 Decimal::from_str_exact("0.072").unwrap(),
953 1.0,
954 dec!(100),
955 Decimal::from_str_exact("0.50").unwrap(),
956 LiquiditySide::Maker,
957 );
958 assert_eq!(commission, dec!(0));
959 }
960
961 #[rstest]
962 fn test_compute_commission_uses_fee_exponent() {
963 let commission =
964 compute_commission(dec!(0.04), 2.0, dec!(10), dec!(0.5), LiquiditySide::Taker);
965 assert_eq!(commission, dec!(0.025));
966 }
967
968 #[rstest]
969 #[case::crypto_taker("0.07", "0.970", LiquiditySide::Taker)]
970 #[case::sports_taker("0.05", "0.500", LiquiditySide::Taker)]
971 #[case::politics_taker("0.04", "0.300", LiquiditySide::Taker)]
972 #[case::maker_zero("0.03", "0.500", LiquiditySide::Maker)]
973 fn test_probability_price_fee_model_matches_polymarket_commission(
974 #[case] taker_fee: &str,
975 #[case] price: &str,
976 #[case] liquidity_side: LiquiditySide,
977 ) {
978 let mut binary = binary_option();
979 binary.maker_fee = Decimal::ZERO;
980 binary.taker_fee = Decimal::from_str_exact(taker_fee).unwrap();
981 let instrument = InstrumentAny::BinaryOption(binary);
982 let order = binary_option_fill_order(&instrument, liquidity_side, price);
983 let fee_model = ProbabilityPriceFeeModel;
984
985 let commission = fee_model
986 .get_commission(
987 &order,
988 Quantity::from("100.00"),
989 Price::from(price),
990 &instrument,
991 )
992 .unwrap();
993
994 let expected = compute_commission(
995 Decimal::from_str_exact(taker_fee).unwrap(),
996 1.0,
997 dec!(100),
998 Decimal::from_str_exact(price).unwrap(),
999 liquidity_side,
1000 );
1001
1002 assert_eq!(commission.as_decimal(), expected);
1003 }
1004
1005 #[rstest]
1016 fn test_adjust_market_buy_amount_balance_covers_returns_unchanged() {
1017 let adjusted =
1021 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1022 .unwrap();
1023 assert_eq!(adjusted, dec!(10.000000));
1024 }
1025
1026 #[rstest]
1027 fn test_adjust_market_buy_amount_balance_equals_total_cost_at_boundary() {
1028 let adjusted =
1032 adjust_market_buy_amount(dec!(10), dec!(10.2), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1033 .unwrap();
1034 assert_eq!(adjusted, dec!(10.000000));
1036 }
1037
1038 #[rstest]
1039 fn test_adjust_market_buy_amount_balance_below_total_cost_shrinks() {
1040 let adjusted =
1044 adjust_market_buy_amount(dec!(10), dec!(5.1), dec!(0.5), dec!(0.04), 1.0, dec!(0))
1045 .unwrap();
1046 assert_eq!(adjusted, dec!(5.000000));
1047 }
1048
1049 #[rstest]
1050 fn test_adjust_market_buy_amount_with_builder_fee() {
1051 let adjusted =
1057 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 1.0, dec!(0.001))
1058 .unwrap();
1059 assert_eq!(adjusted, dec!(9.794319));
1060 }
1061
1062 #[rstest]
1063 fn test_adjust_market_buy_amount_crypto_fee_rate() {
1064 let adjusted =
1070 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.07), 1.0, dec!(0))
1071 .unwrap();
1072 assert_eq!(adjusted, dec!(96.618357));
1074 }
1075
1076 #[rstest]
1077 fn test_adjust_market_buy_amount_extreme_low_price() {
1078 let adjusted =
1085 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.001), dec!(0.04), 1.0, dec!(0))
1086 .unwrap();
1087 let expected = dec!(9.615755);
1090 assert!(
1091 (adjusted - expected).abs() < dec!(0.00001),
1092 "expected ~{expected}, was {adjusted}",
1093 );
1094 }
1095
1096 #[rstest]
1097 fn test_adjust_market_buy_amount_integer_exponent_two() {
1098 let adjusted =
1105 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 2.0, dec!(0))
1106 .unwrap();
1107 assert!(
1108 (adjusted - dec!(9.950248)).abs() < dec!(0.00001),
1109 "expected ~9.950248, was {adjusted}",
1110 );
1111 }
1112
1113 #[rstest]
1114 fn test_adjust_market_buy_amount_fractional_exponent() {
1115 let adjusted =
1122 adjust_market_buy_amount(dec!(10), dec!(10), dec!(0.5), dec!(0.04), 0.5, dec!(0))
1123 .unwrap();
1124 assert!(
1125 (adjusted - dec!(9.615384)).abs() < dec!(0.00001),
1126 "expected ~9.615384, was {adjusted}",
1127 );
1128 }
1129
1130 #[rstest]
1131 fn test_adjust_market_buy_amount_zero_fee_rate_returns_unchanged() {
1132 let adjusted =
1134 adjust_market_buy_amount(dec!(10), dec!(20), dec!(0.5), dec!(0), 1.0, dec!(0)).unwrap();
1135 assert_eq!(adjusted, dec!(10.000000));
1136 }
1137
1138 #[rstest]
1139 fn test_adjust_market_buy_amount_zero_fee_rate_balance_below_principal() {
1140 let adjusted =
1142 adjust_market_buy_amount(dec!(10), dec!(7.5), dec!(0.5), dec!(0), 1.0, dec!(0))
1143 .unwrap();
1144 assert_eq!(adjusted, dec!(7.500000));
1145 }
1146
1147 #[rstest]
1148 fn test_adjust_market_buy_amount_balance_too_small_errors() {
1149 let err = adjust_market_buy_amount(
1153 dec!(10),
1154 dec!(0.0000001),
1155 dec!(0.5),
1156 dec!(0.04),
1157 1.0,
1158 dec!(0),
1159 )
1160 .unwrap_err();
1161 assert!(err.to_string().contains("too small"));
1162 }
1163
1164 #[rstest]
1165 #[case::zero_price(dec!(0))]
1166 #[case::one_price(dec!(1))]
1167 #[case::negative_price(dec!(-0.1))]
1168 #[case::above_one_price(dec!(1.5))]
1169 fn test_adjust_market_buy_amount_rejects_invalid_price(#[case] price: Decimal) {
1170 let err = adjust_market_buy_amount(dec!(10), dec!(20), price, dec!(0.04), 1.0, dec!(0))
1171 .unwrap_err();
1172 assert!(
1173 err.to_string().contains("invalid market-buy price"),
1174 "expected price-domain error, was {err}",
1175 );
1176 }
1177
1178 #[rstest]
1179 fn test_adjust_market_buy_amount_truncates_to_six_decimals() {
1180 let adjusted = adjust_market_buy_amount(
1183 dec!(10),
1184 dec!(9.123456789),
1185 dec!(0.5),
1186 dec!(0.04),
1187 1.0,
1188 dec!(0),
1189 )
1190 .unwrap();
1191 assert!(adjusted.scale() <= 6);
1193 let expected = dec!(8.944565);
1195 assert!(
1196 (adjusted - expected).abs() < dec!(0.000001),
1197 "expected ~{expected}, was {adjusted}",
1198 );
1199 }
1200
1201 fn calc_platform_fee_sdk(
1209 amount: Decimal,
1210 price: Decimal,
1211 rate: Decimal,
1212 exponent: u32,
1213 ) -> Decimal {
1214 let base = price * (Decimal::ONE - price);
1215 let base_f64 = f64::try_from(base).unwrap_or(0.0);
1216 let rate_factor = rate
1217 * Decimal::try_from(base_f64.powi(i32::try_from(exponent).unwrap_or(0)))
1218 .unwrap_or(Decimal::ZERO);
1219 (amount / price) * rate_factor
1220 }
1221
1222 fn calc_builder_fee_sdk(amount: Decimal, rate: Decimal) -> Decimal {
1224 amount * rate
1225 }
1226
1227 fn close_to(actual: Decimal, expected: Decimal, tol: Decimal) {
1228 let diff = (actual - expected).abs();
1229 assert!(
1230 diff <= tol,
1231 "|{actual} - {expected}| = {diff} exceeds tolerance {tol}"
1232 );
1233 }
1234
1235 #[rstest]
1236 fn test_sdk_adjust_market_buy_no_adjustment_when_balance_sufficient() {
1237 let result =
1239 adjust_market_buy_amount(dec!(100), dec!(1000), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1240 .unwrap();
1241 assert_eq!(result, dec!(100));
1242 }
1243
1244 #[rstest]
1245 fn test_sdk_adjust_market_buy_adjusts_when_balance_insufficient() {
1246 let result =
1248 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0.02), 1.0, dec!(0))
1249 .unwrap();
1250 assert!(result < dec!(100));
1251 assert!(result > dec!(0));
1252 }
1253
1254 #[rstest]
1255 fn test_sdk_adjust_market_buy_with_builder_fee() {
1256 let result =
1258 adjust_market_buy_amount(dec!(100), dec!(100), dec!(0.5), dec!(0), 1.0, dec!(0.005))
1259 .unwrap();
1260 let expected = (dec!(100) / dec!(1.005)).trunc_with_scale(USDC_DECIMALS);
1262 assert_eq!(result, expected);
1263 }
1264
1265 #[rstest]
1266 fn test_sdk_adjust_market_buy_errors_when_balance_truncates_to_zero() {
1267 let err = adjust_market_buy_amount(
1269 dec!(100),
1270 dec!(0.0000001),
1271 dec!(0.5),
1272 dec!(0.02),
1273 1.0,
1274 dec!(0.005),
1275 )
1276 .unwrap_err();
1277 assert!(err.to_string().contains("truncated to zero"));
1278 }
1279
1280 #[rstest]
1281 fn test_sdk_adjust_buy_balance_strictly_greater_returns_amount_unchanged() {
1282 let amount = dec!(50);
1285 let price = dec!(0.5);
1286 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1287 let balance = amount + fee + dec!(1);
1288 let result =
1289 adjust_market_buy_amount(amount, balance, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1290 assert_eq!(result, amount);
1291 }
1292
1293 #[rstest]
1294 fn test_sdk_adjust_buy_balance_equal_to_total_cost_matches_divide_path() {
1295 let amount = dec!(50);
1299 let price = dec!(0.5);
1300 let fee = calc_platform_fee_sdk(amount, price, dec!(0.25), 2);
1301 let total_cost = amount + fee;
1302 let result =
1303 adjust_market_buy_amount(amount, total_cost, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1304 close_to(result, amount, dec!(0.000001));
1305 }
1306
1307 #[rstest]
1308 fn test_sdk_adjust_buy_conserves_notional_platform_only() {
1309 let amount = dec!(50);
1312 let price = dec!(0.5);
1313 let adjusted =
1314 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, dec!(0)).unwrap();
1315 let fee = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1316 close_to(adjusted + fee, amount, dec!(0.000001));
1317 assert!(adjusted < amount);
1318 }
1319
1320 #[rstest]
1321 fn test_sdk_adjust_buy_conserves_notional_builder_only() {
1322 let amount = dec!(50);
1324 let price = dec!(0.5);
1325 let builder_rate = dec!(0.01);
1326 let adjusted =
1327 adjust_market_buy_amount(amount, amount, price, dec!(0), 0.0, builder_rate).unwrap();
1328 let fee = calc_builder_fee_sdk(adjusted, builder_rate);
1329 close_to(adjusted + fee, amount, dec!(0.000001));
1330 }
1331
1332 #[rstest]
1333 fn test_sdk_adjust_buy_conserves_notional_platform_and_builder() {
1334 let amount = dec!(50);
1336 let price = dec!(0.5);
1337 let builder_rate = dec!(0.01);
1338 let adjusted =
1339 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1340 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1341 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1342 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1343 }
1344
1345 #[rstest]
1346 fn test_sdk_adjust_buy_conserves_notional_at_price_0_3() {
1347 let amount = dec!(30);
1349 let price = dec!(0.3);
1350 let builder_rate = dec!(0.02);
1351 let adjusted =
1352 adjust_market_buy_amount(amount, amount, price, dec!(0.25), 2.0, builder_rate).unwrap();
1353 let platform = calc_platform_fee_sdk(adjusted, price, dec!(0.25), 2);
1354 let builder = calc_builder_fee_sdk(adjusted, builder_rate);
1355 close_to(adjusted + platform + builder, amount, dec!(0.000001));
1356 }
1357
1358 #[rstest]
1359 fn test_parse_timestamp_ms() {
1360 let ts = parse_timestamp("1703875200000").unwrap();
1361 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1362 }
1363
1364 #[rstest]
1365 fn test_parse_timestamp_secs() {
1366 let ts = parse_timestamp("1703875200").unwrap();
1367 assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
1368 }
1369
1370 #[rstest]
1371 fn test_parse_timestamp_rfc3339() {
1372 let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
1373 assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
1374 }
1375
1376 #[rstest]
1377 fn test_parse_liquidity_side_maker() {
1378 assert_eq!(
1379 parse_liquidity_side(PolymarketLiquiditySide::Maker),
1380 LiquiditySide::Maker
1381 );
1382 }
1383
1384 #[rstest]
1385 fn test_parse_liquidity_side_taker() {
1386 assert_eq!(
1387 parse_liquidity_side(PolymarketLiquiditySide::Taker),
1388 LiquiditySide::Taker
1389 );
1390 }
1391
1392 #[rstest]
1393 fn test_parse_order_status_report_from_fixture() {
1394 let path = "test_data/http_open_order.json";
1395 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1396 let order: PolymarketOpenOrder =
1397 serde_json::from_str(&content).expect("Failed to parse test data");
1398
1399 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1400 let account_id = AccountId::from("POLYMARKET-001");
1401
1402 let report = parse_order_status_report(
1403 &order,
1404 instrument_id,
1405 account_id,
1406 None,
1407 4,
1408 6,
1409 UnixNanos::from(1_000_000_000u64),
1410 );
1411
1412 assert_eq!(report.account_id, account_id);
1413 assert_eq!(report.instrument_id, instrument_id);
1414 assert_eq!(report.order_side, Some(OrderSide::Buy));
1415 assert_eq!(report.order_type, OrderType::Limit);
1416 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1417 assert_eq!(report.order_status, OrderStatus::Accepted);
1418 assert!(report.price.is_some());
1419 assert_eq!(
1420 report.ts_accepted,
1421 UnixNanos::from(1_703_875_200_000_000_000u64)
1422 );
1423 assert_eq!(
1424 report.ts_last,
1425 UnixNanos::from(1_703_875_200_000_000_000u64)
1426 );
1427 assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
1428 assert_eq!(report.expire_time, None);
1430 }
1431
1432 #[rstest]
1435 #[case::matched_underfill_dust(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.995000), 100.000000)]
1437 #[case::matched_overfill_dust(PolymarketOrderStatus::Matched, dec!(714.285710), dec!(714.285714), 714.285710)]
1439 #[case::live_underfill_dust(PolymarketOrderStatus::Live, dec!(100.000000), dec!(99.995000), 99.995000)]
1441 #[case::matched_real_partial(PolymarketOrderStatus::Matched, dec!(100.000000), dec!(99.000000), 99.000000)]
1443 fn test_parse_order_status_report_snaps_dust_filled_qty(
1444 #[case] status: PolymarketOrderStatus,
1445 #[case] original_size: Decimal,
1446 #[case] size_matched: Decimal,
1447 #[case] expected_filled: f64,
1448 ) {
1449 let order = PolymarketOpenOrder {
1450 associate_trades: None,
1451 id: "0xid".to_string(),
1452 status,
1453 market: Ustr::from("0xm"),
1454 original_size,
1455 outcome: PolymarketOutcome::yes(),
1456 maker_address: "0xmaker".to_string(),
1457 owner: "owner".to_string(),
1458 price: dec!(0.5),
1459 side: PolymarketOrderSide::Buy,
1460 size_matched,
1461 asset_id: Ustr::from("token"),
1462 expiration: None,
1463 order_type: PolymarketOrderType::GTC,
1464 created_at: 1_703_875_200,
1465 };
1466
1467 let report = parse_order_status_report(
1468 &order,
1469 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1470 AccountId::from("POLYMARKET-001"),
1471 None,
1472 3,
1473 6,
1474 UnixNanos::from(1_000_000_000u64),
1475 );
1476
1477 assert_eq!(report.filled_qty, Quantity::new(expected_filled, 6));
1478 assert_eq!(
1479 report.quantity,
1480 Quantity::new(original_size.try_into().unwrap_or(0.0), 6)
1481 );
1482 }
1483
1484 #[rstest]
1487 #[case::gtc_real_partial(PolymarketOrderType::GTC, dec!(10), dec!(7), OrderStatus::Canceled)]
1489 #[case::gtc_dust_underfill(PolymarketOrderType::GTC, dec!(100), dec!(99.997714), OrderStatus::Filled)]
1491 #[case::gtc_exact(PolymarketOrderType::GTC, dec!(10), dec!(10), OrderStatus::Filled)]
1493 #[case::fak_partial(PolymarketOrderType::FAK, dec!(30), dec!(20), OrderStatus::Canceled)]
1495 fn test_parse_order_status_report_matched_resolves_terminal_status(
1496 #[case] order_type: PolymarketOrderType,
1497 #[case] original_size: Decimal,
1498 #[case] size_matched: Decimal,
1499 #[case] expected_status: OrderStatus,
1500 ) {
1501 let order = PolymarketOpenOrder {
1502 associate_trades: None,
1503 id: "0xterminal".to_string(),
1504 status: PolymarketOrderStatus::Matched,
1505 market: Ustr::from("0xmarket"),
1506 original_size,
1507 outcome: PolymarketOutcome::yes(),
1508 maker_address: "0xmaker".to_string(),
1509 owner: "owner".to_string(),
1510 price: dec!(0.5),
1511 side: PolymarketOrderSide::Buy,
1512 size_matched,
1513 asset_id: Ustr::from("token"),
1514 expiration: None,
1515 order_type,
1516 created_at: 1_784_118_677,
1517 };
1518
1519 let report = parse_order_status_report(
1520 &order,
1521 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1522 AccountId::from("POLYMARKET-001"),
1523 None,
1524 3,
1525 6,
1526 UnixNanos::from(1_000_000_000u64),
1527 );
1528
1529 assert_eq!(report.order_status, expected_status);
1530 if report.order_status == OrderStatus::Filled {
1531 assert!(
1532 report.filled_qty >= report.quantity,
1533 "a Filled report must not carry filled_qty < quantity, was filled_qty={} quantity={}",
1534 report.filled_qty,
1535 report.quantity
1536 );
1537 }
1538 }
1539
1540 #[rstest]
1541 fn test_parse_order_status_report_maps_partial_fak_match_to_canceled() {
1542 let order = PolymarketOpenOrder {
1543 associate_trades: Some(vec!["trade-partial-fak".to_string()]),
1544 id: "0xpartial-fak".to_string(),
1545 status: PolymarketOrderStatus::Matched,
1546 market: Ustr::from("0xmarket"),
1547 original_size: dec!(30),
1548 outcome: PolymarketOutcome::yes(),
1549 maker_address: "0xmaker".to_string(),
1550 owner: "owner".to_string(),
1551 price: dec!(0.093),
1552 side: PolymarketOrderSide::Buy,
1553 size_matched: dec!(20),
1554 asset_id: Ustr::from("token"),
1555 expiration: Some("0".to_string()),
1556 order_type: PolymarketOrderType::FAK,
1557 created_at: 1_784_118_677,
1558 };
1559
1560 let report = parse_order_status_report(
1561 &order,
1562 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1563 AccountId::from("POLYMARKET-001"),
1564 None,
1565 3,
1566 6,
1567 UnixNanos::from(1_000_000_000u64),
1568 );
1569
1570 assert_eq!(report.order_status, OrderStatus::Canceled);
1571 assert_eq!(report.time_in_force, TimeInForce::Ioc);
1572 assert_eq!(report.quantity, Quantity::from("30.000000"));
1573 assert_eq!(report.filled_qty, Quantity::from("20.000000"));
1574 }
1575
1576 #[rstest]
1577 #[case::null(None, None)]
1578 #[case::zero_string(Some("0"), None)]
1579 #[case::empty_string(Some(""), None)]
1580 #[case::garbage(Some("not-a-number"), None)]
1581 #[case::positive_seconds(
1582 Some("1735689600"),
1583 Some(UnixNanos::from(1_735_689_600_000_000_000u64))
1584 )]
1585 fn test_parse_order_status_report_expiration(
1586 #[case] raw: Option<&str>,
1587 #[case] expected: Option<UnixNanos>,
1588 ) {
1589 let order = PolymarketOpenOrder {
1590 associate_trades: None,
1591 id: "0xid".to_string(),
1592 status: PolymarketOrderStatus::Live,
1593 market: Ustr::from("0xm"),
1594 original_size: dec!(100),
1595 outcome: PolymarketOutcome::yes(),
1596 maker_address: "0xmaker".to_string(),
1597 owner: "owner".to_string(),
1598 price: dec!(0.5),
1599 side: PolymarketOrderSide::Buy,
1600 size_matched: dec!(0),
1601 asset_id: Ustr::from("token"),
1602 expiration: raw.map(|s| s.to_string()),
1603 order_type: PolymarketOrderType::GTD,
1604 created_at: 1_703_875_200,
1605 };
1606
1607 let report = parse_order_status_report(
1608 &order,
1609 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1610 AccountId::from("POLYMARKET-001"),
1611 None,
1612 4,
1613 6,
1614 UnixNanos::from(1_000_000_000u64),
1615 );
1616
1617 assert_eq!(report.expire_time, expected);
1618 }
1619
1620 #[rstest]
1621 fn test_parse_fill_report_errors_when_commission_is_unrepresentable() {
1622 let path = "test_data/http_trade_report.json";
1623 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1624 let trade: PolymarketTradeReport =
1625 serde_json::from_str(&content).expect("Failed to parse test data");
1626
1627 let result = parse_fill_report(
1628 &trade,
1629 InstrumentId::from("TEST-TOKEN.POLYMARKET"),
1630 AccountId::from("POLYMARKET-001"),
1631 None,
1632 4,
1633 6,
1634 Currency::pUSD(),
1635 Decimal::from_i128_with_scale(100_000_000_000_000_000_000_000_000i128, 0),
1638 1.0,
1639 UnixNanos::from(1_000_000_000u64),
1640 );
1641
1642 assert!(
1643 result.is_err(),
1644 "an unrepresentable commission must surface as an error rather than panicking"
1645 );
1646 }
1647
1648 #[rstest]
1649 fn test_parse_fill_report_from_fixture() {
1650 let path = "test_data/http_trade_report.json";
1651 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1652 let trade: PolymarketTradeReport =
1653 serde_json::from_str(&content).expect("Failed to parse test data");
1654
1655 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1656 let account_id = AccountId::from("POLYMARKET-001");
1657 let currency = Currency::pUSD();
1658
1659 let report = parse_fill_report(
1660 &trade,
1661 instrument_id,
1662 account_id,
1663 None,
1664 4,
1665 6,
1666 currency,
1667 Decimal::ZERO,
1668 1.0,
1669 UnixNanos::from(1_000_000_000u64),
1670 )
1671 .expect("fixture commission is representable");
1672
1673 assert_eq!(report.account_id, account_id);
1674 assert_eq!(report.instrument_id, instrument_id);
1675 assert_eq!(report.order_side, OrderSide::Buy);
1676 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1677 assert_eq!(report.commission.as_decimal(), dec!(0.0));
1678 }
1679
1680 #[rstest]
1681 fn test_parse_fill_report_forwards_fee_schedule() {
1682 let path = "test_data/http_trade_report.json";
1683 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1684 let trade: PolymarketTradeReport =
1685 serde_json::from_str(&content).expect("Failed to parse test data");
1686
1687 let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
1688 let account_id = AccountId::from("POLYMARKET-001");
1689 let currency = Currency::pUSD();
1690
1691 let report = parse_fill_report(
1693 &trade,
1694 instrument_id,
1695 account_id,
1696 None,
1697 4,
1698 6,
1699 currency,
1700 dec!(0.03),
1701 2.0,
1702 UnixNanos::from(1_000_000_000u64),
1703 )
1704 .expect("fixture commission is representable");
1705
1706 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1707 assert_eq!(report.commission.as_decimal(), dec!(0.04688));
1708 }
1709
1710 #[rstest]
1711 fn test_instrument_taker_fee_reads_binary_option() {
1712 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
1713
1714 let path = "test_data/gamma_market_sports_market_money_line.json";
1715 let content = std::fs::read_to_string(path).expect("Failed to read test data");
1716 let market = serde_json::from_str(&content).expect("Failed to parse test data");
1717 let defs = parse_gamma_market(&market).unwrap();
1718 let instrument =
1719 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1720
1721 assert_eq!(instrument_taker_fee(&instrument), dec!(0.03));
1722 assert_eq!(instrument_fee_exponent(&instrument), 1.0);
1723 }
1724
1725 #[rstest]
1726 #[case(
1727 PolymarketLiquiditySide::Taker,
1728 PolymarketOrderSide::Buy,
1729 "token_a",
1730 "token_b",
1731 OrderSide::Buy
1732 )]
1733 #[case(
1734 PolymarketLiquiditySide::Taker,
1735 PolymarketOrderSide::Sell,
1736 "token_a",
1737 "token_b",
1738 OrderSide::Sell
1739 )]
1740 #[case(
1741 PolymarketLiquiditySide::Maker,
1742 PolymarketOrderSide::Buy,
1743 "token_a",
1744 "token_b",
1745 OrderSide::Buy
1746 )]
1747 #[case(
1748 PolymarketLiquiditySide::Maker,
1749 PolymarketOrderSide::Buy,
1750 "token_a",
1751 "token_a",
1752 OrderSide::Sell
1753 )]
1754 #[case(
1755 PolymarketLiquiditySide::Maker,
1756 PolymarketOrderSide::Sell,
1757 "token_a",
1758 "token_a",
1759 OrderSide::Buy
1760 )]
1761 fn test_determine_order_side(
1762 #[case] trader_side: PolymarketLiquiditySide,
1763 #[case] trade_side: PolymarketOrderSide,
1764 #[case] taker_asset: &str,
1765 #[case] maker_asset: &str,
1766 #[case] expected: OrderSide,
1767 ) {
1768 let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
1769 assert_eq!(result, expected);
1770 }
1771
1772 #[rstest]
1773 fn test_make_composite_trade_id_basic() {
1774 let trade_id = "trade-abc123";
1775 let venue_order_id = "order-xyz789";
1776 let result = make_composite_trade_id(trade_id, venue_order_id);
1777 assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
1778 }
1779
1780 #[rstest]
1781 fn test_make_composite_trade_id_truncates_long_ids() {
1782 let trade_id = "a]".repeat(30);
1783 let venue_order_id = "b".repeat(20);
1784 let result = make_composite_trade_id(&trade_id, &venue_order_id);
1785 assert!(result.as_str().len() <= 36);
1786 }
1787
1788 #[rstest]
1789 fn test_make_composite_trade_id_short_venue_id() {
1790 let trade_id = "t123";
1791 let venue_order_id = "ab";
1792 let result = make_composite_trade_id(trade_id, venue_order_id);
1793 assert_eq!(result.as_str(), "t123-ab");
1794 }
1795
1796 #[rstest]
1797 fn test_make_composite_trade_id_uniqueness() {
1798 let id_a = make_composite_trade_id("same-trade", "order-aaa");
1799 let id_b = make_composite_trade_id("same-trade", "order-bbb");
1800 assert_ne!(id_a, id_b);
1801 }
1802
1803 #[rstest]
1806 fn test_calculate_market_price_buy_single_level() {
1807 let levels = vec![ClobBookLevel {
1808 price: "0.55".to_string(),
1809 size: "200.0".to_string(),
1810 }];
1811 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1812 assert_eq!(result.crossing_price, dec!(0.55));
1813 assert!(result.expected_base_qty > dec!(90));
1815 }
1816
1817 #[rstest]
1818 fn test_calculate_market_price_buy_walks_multiple_levels() {
1819 let levels = vec![
1821 ClobBookLevel {
1822 price: "0.55".to_string(),
1823 size: "100.0".to_string(),
1824 },
1825 ClobBookLevel {
1826 price: "0.50".to_string(),
1827 size: "10.0".to_string(),
1828 },
1829 ClobBookLevel {
1830 price: "0.60".to_string(),
1831 size: "200.0".to_string(),
1832 },
1833 ];
1834 let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
1837 assert_eq!(result.crossing_price, dec!(0.55));
1838 let expected = dec!(10) + dec!(15) / dec!(0.55);
1839 assert_eq!(result.expected_base_qty, expected);
1840 }
1841
1842 #[rstest]
1843 fn test_calculate_market_price_buy_small_order_uses_best_ask() {
1844 let levels = vec![
1846 ClobBookLevel {
1847 price: "0.50".to_string(),
1848 size: "50.0".to_string(),
1849 },
1850 ClobBookLevel {
1851 price: "0.999".to_string(),
1852 size: "100.0".to_string(),
1853 },
1854 ClobBookLevel {
1855 price: "0.20".to_string(),
1856 size: "72.0".to_string(),
1857 },
1858 ];
1859 let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
1862 assert_eq!(result.crossing_price, dec!(0.20));
1863 assert_eq!(result.expected_base_qty, dec!(25)); }
1865
1866 #[rstest]
1867 fn test_calculate_market_price_sell_walks_levels() {
1868 let levels = vec![
1870 ClobBookLevel {
1871 price: "0.48".to_string(),
1872 size: "100.0".to_string(),
1873 },
1874 ClobBookLevel {
1875 price: "0.50".to_string(),
1876 size: "50.0".to_string(),
1877 },
1878 ];
1879 let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
1882 assert_eq!(result.crossing_price, dec!(0.48));
1883 assert_eq!(result.expected_base_qty, dec!(80));
1884 }
1885
1886 #[rstest]
1887 fn test_calculate_market_price_empty_book() {
1888 let levels: Vec<ClobBookLevel> = vec![];
1889 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1890 assert!(result.is_err());
1891 }
1892
1893 #[rstest]
1894 fn test_calculate_market_price_all_zero_levels_returns_error() {
1895 let levels = vec![
1896 ClobBookLevel {
1897 price: "0".to_string(),
1898 size: "100.0".to_string(),
1899 },
1900 ClobBookLevel {
1901 price: "0.50".to_string(),
1902 size: "0".to_string(),
1903 },
1904 ];
1905 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
1906 assert!(result.is_err());
1907 }
1908
1909 #[rstest]
1910 fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
1911 let levels = vec![ClobBookLevel {
1912 price: "0.55".to_string(),
1913 size: "10.0".to_string(),
1914 }];
1915 let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
1917 assert_eq!(result.crossing_price, dec!(0.55));
1918 assert_eq!(result.expected_base_qty, dec!(10)); }
1920
1921 #[rstest]
1922 fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
1923 let levels_ascending = vec![
1924 ClobBookLevel {
1925 price: "0.20".to_string(),
1926 size: "72.0".to_string(),
1927 },
1928 ClobBookLevel {
1929 price: "0.50".to_string(),
1930 size: "50.0".to_string(),
1931 },
1932 ClobBookLevel {
1933 price: "0.999".to_string(),
1934 size: "100.0".to_string(),
1935 },
1936 ];
1937 let levels_descending = vec![
1938 ClobBookLevel {
1939 price: "0.999".to_string(),
1940 size: "100.0".to_string(),
1941 },
1942 ClobBookLevel {
1943 price: "0.50".to_string(),
1944 size: "50.0".to_string(),
1945 },
1946 ClobBookLevel {
1947 price: "0.20".to_string(),
1948 size: "72.0".to_string(),
1949 },
1950 ];
1951 let levels_shuffled = vec![
1952 ClobBookLevel {
1953 price: "0.50".to_string(),
1954 size: "50.0".to_string(),
1955 },
1956 ClobBookLevel {
1957 price: "0.20".to_string(),
1958 size: "72.0".to_string(),
1959 },
1960 ClobBookLevel {
1961 price: "0.999".to_string(),
1962 size: "100.0".to_string(),
1963 },
1964 ];
1965
1966 let r1 =
1967 calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1968 let r2 =
1969 calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
1970 let r3 =
1971 calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
1972
1973 assert_eq!(r1.crossing_price, r2.crossing_price);
1974 assert_eq!(r2.crossing_price, r3.crossing_price);
1975 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
1976 assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
1977 }
1978
1979 #[rstest]
1980 fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
1981 let levels_a = vec![
1982 ClobBookLevel {
1983 price: "0.48".to_string(),
1984 size: "100.0".to_string(),
1985 },
1986 ClobBookLevel {
1987 price: "0.50".to_string(),
1988 size: "50.0".to_string(),
1989 },
1990 ];
1991 let levels_b = vec![
1992 ClobBookLevel {
1993 price: "0.50".to_string(),
1994 size: "50.0".to_string(),
1995 },
1996 ClobBookLevel {
1997 price: "0.48".to_string(),
1998 size: "100.0".to_string(),
1999 },
2000 ];
2001
2002 let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
2003 let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
2004
2005 assert_eq!(r1.crossing_price, r2.crossing_price);
2006 assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
2007 }
2008
2009 mod adjust_market_buy_amount_property_tests {
2010 use proptest::prelude::*;
2011 use rstest::rstest;
2012
2013 use super::*;
2014
2015 fn decimal_at_usdc_scale(micros: u64) -> Decimal {
2019 Decimal::new(micros as i64, USDC_DECIMALS)
2020 }
2021
2022 fn decimal_from_bps(bps: u32) -> Decimal {
2024 Decimal::new(i64::from(bps), 4)
2025 }
2026
2027 fn compute_total_cost(
2030 amount: Decimal,
2031 price: Decimal,
2032 fee_rate: Decimal,
2033 fee_exponent: f64,
2034 builder: Decimal,
2035 ) -> Decimal {
2036 let base = price * (Decimal::ONE - price);
2037 let base_f64: f64 = base.try_into().unwrap_or(0.0);
2038 let curve = Decimal::try_from(base_f64.powf(fee_exponent)).unwrap_or(Decimal::ZERO);
2039 let platform_fee_rate = fee_rate * curve;
2040 let platform_fee = amount / price * platform_fee_rate;
2041 amount + platform_fee + amount * builder
2042 }
2043
2044 proptest! {
2045 #[rstest]
2048 fn prop_adjust_market_buy_amount_is_deterministic(
2049 amount_micros in 1u64..=1_000_000_000_000u64,
2050 balance_micros in 1u64..=1_000_000_000_000u64,
2051 price_milli in 1u32..=999u32,
2052 fee_rate_bps in 0u32..=1_000u32,
2053 fee_exponent in 1.0f64..=3.0f64,
2054 builder_bps in 0u32..=500u32,
2055 ) {
2056 let amount = decimal_at_usdc_scale(amount_micros);
2057 let balance = decimal_at_usdc_scale(balance_micros);
2058 let price = Decimal::new(i64::from(price_milli), 3);
2059 let fee_rate = decimal_from_bps(fee_rate_bps);
2060 let builder = decimal_from_bps(builder_bps);
2061
2062 let r1 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2063 let r2 = adjust_market_buy_amount(amount, balance, price, fee_rate, fee_exponent, builder);
2064 prop_assert_eq!(r1.is_ok(), r2.is_ok());
2065 if let (Ok(a), Ok(b)) = (r1, r2) {
2066 prop_assert_eq!(a, b);
2067 }
2068 }
2069
2070 #[rstest]
2075 fn prop_adjust_market_buy_amount_non_binding_returns_amount(
2076 amount_micros in 1u64..=1_000_000_000u64,
2077 price_milli in 1u32..=999u32,
2078 fee_rate_bps in 0u32..=1_000u32,
2079 fee_exponent in 1.0f64..=3.0f64,
2080 builder_bps in 0u32..=500u32,
2081 ) {
2082 let amount = decimal_at_usdc_scale(amount_micros);
2083 let price = Decimal::new(i64::from(price_milli), 3);
2084 let fee_rate = decimal_from_bps(fee_rate_bps);
2085 let builder = decimal_from_bps(builder_bps);
2086
2087 let total_cost =
2091 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2092 let balance = total_cost * Decimal::from(10);
2093
2094 let adjusted = adjust_market_buy_amount(
2095 amount, balance, price, fee_rate, fee_exponent, builder,
2096 )
2097 .expect("non-binding balance must yield Ok");
2098 prop_assert_eq!(
2099 adjusted, amount,
2100 "non-binding branch must return the input amount unchanged",
2101 );
2102 }
2103
2104 #[rstest]
2109 fn prop_adjust_market_buy_amount_binding_shrinks_into_balance(
2110 amount_micros in 1_000u64..=1_000_000_000u64,
2111 price_milli in 10u32..=990u32,
2112 fee_rate_bps in 0u32..=1_000u32,
2113 fee_exponent in 1.0f64..=3.0f64,
2114 builder_bps in 0u32..=500u32,
2115 fraction_thousandths in 100u32..=900u32,
2116 ) {
2117 let amount = decimal_at_usdc_scale(amount_micros);
2118 let price = Decimal::new(i64::from(price_milli), 3);
2119 let fee_rate = decimal_from_bps(fee_rate_bps);
2120 let builder = decimal_from_bps(builder_bps);
2121
2122 let total_cost =
2125 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2126 let fraction = Decimal::new(i64::from(fraction_thousandths), 3);
2127 let balance = (total_cost * fraction).trunc_with_scale(USDC_DECIMALS);
2128 if balance.is_zero() {
2129 return Ok(()); }
2131
2132 let adjusted = adjust_market_buy_amount(
2133 amount, balance, price, fee_rate, fee_exponent, builder,
2134 )
2135 .expect("non-zero balance fraction must yield Ok in binding branch");
2136
2137 prop_assert!(
2138 adjusted < amount,
2139 "binding branch must strictly shrink (adjusted={adjusted}, amount={amount})",
2140 );
2141 prop_assert!(
2142 adjusted > Decimal::ZERO,
2143 "adjusted must be strictly positive",
2144 );
2145 prop_assert_eq!(
2146 adjusted,
2147 adjusted.trunc_with_scale(USDC_DECIMALS),
2148 "adjusted must be at USDC_DECIMALS scale",
2149 );
2150 let recomputed_cost =
2151 compute_total_cost(adjusted, price, fee_rate, fee_exponent, builder);
2152 prop_assert!(
2153 recomputed_cost <= balance,
2154 "total_cost {recomputed_cost} must fit balance {balance}",
2155 );
2156 }
2157
2158 #[rstest]
2162 fn prop_adjust_market_buy_amount_truncates_subusdc_precision(
2163 amount_pico in 1_000_000u64..=1_000_000_000_000u64,
2164 price_milli in 1u32..=999u32,
2165 fee_rate_bps in 0u32..=1_000u32,
2166 fee_exponent in 1.0f64..=3.0f64,
2167 builder_bps in 0u32..=500u32,
2168 ) {
2169 let amount = Decimal::new(amount_pico as i64, 9);
2172 let price = Decimal::new(i64::from(price_milli), 3);
2173 let fee_rate = decimal_from_bps(fee_rate_bps);
2174 let builder = decimal_from_bps(builder_bps);
2175
2176 let total_cost =
2178 compute_total_cost(amount, price, fee_rate, fee_exponent, builder);
2179 let balance = total_cost * Decimal::from(10);
2180
2181 if let Ok(adjusted) = adjust_market_buy_amount(
2182 amount, balance, price, fee_rate, fee_exponent, builder,
2183 ) {
2184 prop_assert_eq!(
2185 adjusted,
2186 adjusted.trunc_with_scale(USDC_DECIMALS),
2187 "result must be at USDC_DECIMALS scale",
2188 );
2189 prop_assert!(
2190 adjusted <= amount,
2191 "truncation must round DOWN, never up (adjusted={adjusted}, amount={amount})",
2192 );
2193 }
2194 }
2195 }
2196 }
2197}