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