use nautilus_core::{
UUID4, UnixNanos,
datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
};
use nautilus_model::{
enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
reports::{FillReport, OrderStatusReport},
types::{AccountBalance, Currency, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use crate::{
common::{
enums::{
PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide,
PolymarketOrderStatus,
},
models::PolymarketMakerOrder,
},
http::models::{ClobBookLevel, PolymarketOpenOrder, PolymarketTradeReport},
};
pub const fn parse_liquidity_side(side: PolymarketLiquiditySide) -> LiquiditySide {
match side {
PolymarketLiquiditySide::Maker => LiquiditySide::Maker,
PolymarketLiquiditySide::Taker => LiquiditySide::Taker,
}
}
pub fn resolve_order_status(
status: PolymarketOrderStatus,
event_type: PolymarketEventType,
) -> OrderStatus {
if status == PolymarketOrderStatus::Invalid && event_type == PolymarketEventType::Cancellation {
OrderStatus::Canceled
} else {
OrderStatus::from(status)
}
}
pub fn determine_order_side(
trader_side: PolymarketLiquiditySide,
trade_side: PolymarketOrderSide,
taker_asset_id: &str,
maker_asset_id: &str,
) -> OrderSide {
let order_side = OrderSide::from(trade_side);
if trader_side == PolymarketLiquiditySide::Taker {
return order_side;
}
let is_cross_asset = maker_asset_id != taker_asset_id;
if is_cross_asset {
order_side
} else {
match order_side {
OrderSide::Buy => OrderSide::Sell,
OrderSide::Sell => OrderSide::Buy,
other => other,
}
}
}
pub fn make_composite_trade_id(trade_id: &str, venue_order_id: &str) -> TradeId {
let prefix_len = trade_id.len().min(27);
let suffix_len = venue_order_id.len().min(8);
let suffix_start = venue_order_id.len().saturating_sub(suffix_len);
TradeId::from(
format!(
"{}-{}",
&trade_id[..prefix_len],
&venue_order_id[suffix_start..]
)
.as_str(),
)
}
pub fn parse_order_status_report(
order: &PolymarketOpenOrder,
instrument_id: InstrumentId,
account_id: AccountId,
client_order_id: Option<ClientOrderId>,
price_precision: u8,
size_precision: u8,
ts_init: UnixNanos,
) -> OrderStatusReport {
let venue_order_id = VenueOrderId::from(order.id.as_str());
let order_side = OrderSide::from(order.side);
let time_in_force = TimeInForce::from(order.order_type);
let order_status = OrderStatus::from(order.status);
let quantity = Quantity::new(
order.original_size.to_string().parse().unwrap_or(0.0),
size_precision,
);
let filled_qty = Quantity::new(
order.size_matched.to_string().parse().unwrap_or(0.0),
size_precision,
);
let price = Price::new(
order.price.to_string().parse().unwrap_or(0.0),
price_precision,
);
let ts_accepted = UnixNanos::from(order.created_at * NANOSECONDS_IN_SECOND);
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
client_order_id,
venue_order_id,
order_side,
OrderType::Limit,
time_in_force,
order_status,
quantity,
filled_qty,
ts_accepted,
ts_accepted, ts_init,
None, );
report.price = Some(price);
report
}
#[allow(clippy::too_many_arguments)]
pub fn parse_fill_report(
trade: &PolymarketTradeReport,
instrument_id: InstrumentId,
account_id: AccountId,
client_order_id: Option<ClientOrderId>,
price_precision: u8,
size_precision: u8,
currency: Currency,
ts_init: UnixNanos,
) -> FillReport {
let venue_order_id = VenueOrderId::from(trade.taker_order_id.as_str());
let trade_id = TradeId::from(trade.id.as_str());
let order_side = OrderSide::from(trade.side);
let last_qty = Quantity::new(
trade.size.to_string().parse().unwrap_or(0.0),
size_precision,
);
let last_px = Price::new(
trade.price.to_string().parse().unwrap_or(0.0),
price_precision,
);
let liquidity_side = parse_liquidity_side(trade.trader_side);
let commission_value = compute_commission(trade.fee_rate_bps, trade.size, trade.price);
let commission = Money::new(commission_value, currency);
let ts_event = parse_timestamp(&trade.match_time).unwrap_or(ts_init);
FillReport {
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission,
liquidity_side,
report_id: UUID4::new(),
ts_event,
ts_init,
client_order_id,
venue_position_id: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_maker_fill_report(
mo: &PolymarketMakerOrder,
trade_id: &str,
trader_side: PolymarketLiquiditySide,
trade_side: PolymarketOrderSide,
taker_asset_id: &str,
account_id: AccountId,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
currency: Currency,
liquidity_side: LiquiditySide,
ts_event: UnixNanos,
ts_init: UnixNanos,
) -> FillReport {
let venue_order_id = VenueOrderId::from(mo.order_id.as_str());
let fill_trade_id = make_composite_trade_id(trade_id, &mo.order_id);
let order_side = determine_order_side(
trader_side,
trade_side,
taker_asset_id,
mo.asset_id.as_str(),
);
let last_qty = Quantity::new(
mo.matched_amount.to_string().parse::<f64>().unwrap_or(0.0),
size_precision,
);
let last_px = Price::new(
mo.price.to_string().parse::<f64>().unwrap_or(0.0),
price_precision,
);
let commission_value = compute_commission(mo.fee_rate_bps, mo.matched_amount, mo.price);
FillReport {
account_id,
instrument_id,
venue_order_id,
trade_id: fill_trade_id,
order_side,
last_qty,
last_px,
commission: Money::new(commission_value, currency),
liquidity_side,
report_id: UUID4::new(),
ts_event,
ts_init,
client_order_id: None,
venue_position_id: None,
}
}
pub fn compute_commission(fee_rate_bps: Decimal, size: Decimal, price: Decimal) -> f64 {
if fee_rate_bps.is_zero() {
return 0.0;
}
let bps = Decimal::new(10_000, 0);
let fee_rate = fee_rate_bps / bps;
let commission = size * fee_rate * price * (Decimal::ONE - price);
let rounded = commission.round_dp(5);
rounded.to_string().parse().unwrap_or(0.0)
}
const USDC_SCALE: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0);
pub fn parse_balance_allowance(
balance_raw: Decimal,
currency: Currency,
) -> anyhow::Result<AccountBalance> {
let balance_usdc = balance_raw / USDC_SCALE;
let total = Money::from_decimal(balance_usdc, currency)
.map_err(|e| anyhow::anyhow!("Failed to convert balance: {e}"))?;
let locked = Money::new(0.0, currency);
let free = total;
Ok(AccountBalance::new(total, locked, free))
}
#[derive(Debug)]
pub struct MarketPriceResult {
pub crossing_price: Decimal,
pub expected_base_qty: Decimal,
}
pub fn calculate_market_price(
book_levels: &[ClobBookLevel],
amount: Decimal,
side: PolymarketOrderSide,
) -> anyhow::Result<MarketPriceResult> {
if book_levels.is_empty() {
anyhow::bail!("Empty order book: no liquidity available for market order");
}
let mut parsed_levels: Vec<(Decimal, Decimal)> = book_levels
.iter()
.map(|l| {
let price = Decimal::from_str_exact(&l.price).unwrap_or(Decimal::ZERO);
let size = Decimal::from_str_exact(&l.size).unwrap_or(Decimal::ZERO);
(price, size)
})
.filter(|(p, s)| !p.is_zero() && !s.is_zero())
.collect();
if parsed_levels.is_empty() {
anyhow::bail!("Empty order book: no valid price levels for market order");
}
match side {
PolymarketOrderSide::Buy => parsed_levels.sort_by_key(|a| a.0),
PolymarketOrderSide::Sell => parsed_levels.sort_by(|a, b| b.0.cmp(&a.0)),
}
let mut remaining = amount;
let mut last_price = Decimal::ZERO;
let mut total_base_qty = Decimal::ZERO;
for &(price, size) in &parsed_levels {
last_price = price;
match side {
PolymarketOrderSide::Buy => {
let level_usdc = size * price;
let consumed_usdc = level_usdc.min(remaining);
let shares_at_level = consumed_usdc / price;
total_base_qty += shares_at_level;
remaining -= consumed_usdc;
}
PolymarketOrderSide::Sell => {
let consumed_shares = size.min(remaining);
total_base_qty += consumed_shares;
remaining -= consumed_shares;
}
}
if remaining <= Decimal::ZERO {
return Ok(MarketPriceResult {
crossing_price: last_price,
expected_base_qty: total_base_qty,
});
}
}
Ok(MarketPriceResult {
crossing_price: last_price,
expected_base_qty: total_base_qty,
})
}
pub fn parse_timestamp(ts_str: &str) -> Option<UnixNanos> {
if let Ok(n) = ts_str.parse::<u64>() {
return if n > 1_000_000_000_000 {
Some(UnixNanos::from(n * NANOSECONDS_IN_MILLISECOND))
} else {
Some(UnixNanos::from(n * NANOSECONDS_IN_SECOND))
};
}
let dt = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
Some(UnixNanos::from(dt.timestamp_nanos_opt()? as u64))
}
#[cfg(test)]
mod tests {
use nautilus_model::enums::CurrencyType;
use rstest::rstest;
use rust_decimal_macros::dec;
use super::*;
use crate::common::enums::PolymarketOrderSide;
#[rstest]
#[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) {
let currency = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let balance = parse_balance_allowance(raw, currency).unwrap();
let total_f64: f64 = balance.total.as_decimal().to_string().parse().unwrap();
assert!(
(total_f64 - expected).abs() < 1e-8,
"expected {expected}, was {total_f64}"
);
assert_eq!(balance.free, balance.total);
}
#[rstest]
#[case::crypto_p50(720, "0.50", 1.8)]
#[case::crypto_p01(720, "0.01", 0.07128)]
#[case::crypto_p05(720, "0.05", 0.342)]
#[case::crypto_p10(720, "0.10", 0.648)]
#[case::crypto_p30(720, "0.30", 1.512)]
#[case::crypto_p70(720, "0.70", 1.512)]
#[case::crypto_p90(720, "0.90", 0.648)]
#[case::crypto_p99(720, "0.99", 0.07128)]
#[case::sports_p50(300, "0.50", 0.75)]
#[case::sports_p30(300, "0.30", 0.63)]
#[case::sports_p70(300, "0.70", 0.63)]
#[case::politics_p50(400, "0.50", 1.0)]
#[case::politics_p30(400, "0.30", 0.84)]
#[case::economics_p50(500, "0.50", 1.25)]
#[case::economics_p30(500, "0.30", 1.05)]
#[case::geopolitics_p50(0, "0.50", 0.0)]
fn test_compute_commission_docs_table(
#[case] fee_rate_bps: i64,
#[case] price: &str,
#[case] expected: f64,
) {
let commission = compute_commission(
Decimal::new(fee_rate_bps, 0),
dec!(100),
Decimal::from_str_exact(price).unwrap(),
);
assert!(
(commission - expected).abs() < 1e-10,
"at p={price}, fee_rate_bps={fee_rate_bps}: expected {expected}, was {commission}"
);
}
#[rstest]
fn test_parse_timestamp_ms() {
let ts = parse_timestamp("1703875200000").unwrap();
assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
}
#[rstest]
fn test_parse_timestamp_secs() {
let ts = parse_timestamp("1703875200").unwrap();
assert_eq!(ts, UnixNanos::from(1_703_875_200_000_000_000u64));
}
#[rstest]
fn test_parse_timestamp_rfc3339() {
let ts = parse_timestamp("2024-01-01T00:00:00Z").unwrap();
assert_eq!(ts, UnixNanos::from(1_704_067_200_000_000_000u64));
}
#[rstest]
fn test_parse_liquidity_side_maker() {
assert_eq!(
parse_liquidity_side(PolymarketLiquiditySide::Maker),
LiquiditySide::Maker
);
}
#[rstest]
fn test_parse_liquidity_side_taker() {
assert_eq!(
parse_liquidity_side(PolymarketLiquiditySide::Taker),
LiquiditySide::Taker
);
}
#[rstest]
fn test_parse_order_status_report_from_fixture() {
let path = "test_data/http_open_order.json";
let content = std::fs::read_to_string(path).expect("Failed to read test data");
let order: PolymarketOpenOrder =
serde_json::from_str(&content).expect("Failed to parse test data");
let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
let account_id = AccountId::from("POLYMARKET-001");
let report = parse_order_status_report(
&order,
instrument_id,
account_id,
None,
4,
6,
UnixNanos::from(1_000_000_000u64),
);
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.order_side, OrderSide::Buy);
assert_eq!(report.order_type, OrderType::Limit);
assert_eq!(report.time_in_force, TimeInForce::Gtc);
assert_eq!(report.order_status, OrderStatus::Accepted);
assert!(report.price.is_some());
assert_eq!(
report.ts_accepted,
UnixNanos::from(1_703_875_200_000_000_000u64)
);
assert_eq!(
report.ts_last,
UnixNanos::from(1_703_875_200_000_000_000u64)
);
assert_eq!(report.ts_init, UnixNanos::from(1_000_000_000u64));
}
#[rstest]
fn test_parse_fill_report_from_fixture() {
let path = "test_data/http_trade_report.json";
let content = std::fs::read_to_string(path).expect("Failed to read test data");
let trade: PolymarketTradeReport =
serde_json::from_str(&content).expect("Failed to parse test data");
let instrument_id = InstrumentId::from("TEST-TOKEN.POLYMARKET");
let account_id = AccountId::from("POLYMARKET-001");
let currency = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
let report = parse_fill_report(
&trade,
instrument_id,
account_id,
None,
4,
6,
currency,
UnixNanos::from(1_000_000_000u64),
);
assert_eq!(report.account_id, account_id);
assert_eq!(report.instrument_id, instrument_id);
assert_eq!(report.order_side, OrderSide::Buy);
assert_eq!(report.liquidity_side, LiquiditySide::Taker);
}
#[rstest]
#[case(
PolymarketLiquiditySide::Taker,
PolymarketOrderSide::Buy,
"token_a",
"token_b",
OrderSide::Buy
)]
#[case(
PolymarketLiquiditySide::Taker,
PolymarketOrderSide::Sell,
"token_a",
"token_b",
OrderSide::Sell
)]
#[case(
PolymarketLiquiditySide::Maker,
PolymarketOrderSide::Buy,
"token_a",
"token_b",
OrderSide::Buy
)]
#[case(
PolymarketLiquiditySide::Maker,
PolymarketOrderSide::Buy,
"token_a",
"token_a",
OrderSide::Sell
)]
#[case(
PolymarketLiquiditySide::Maker,
PolymarketOrderSide::Sell,
"token_a",
"token_a",
OrderSide::Buy
)]
fn test_determine_order_side(
#[case] trader_side: PolymarketLiquiditySide,
#[case] trade_side: PolymarketOrderSide,
#[case] taker_asset: &str,
#[case] maker_asset: &str,
#[case] expected: OrderSide,
) {
let result = determine_order_side(trader_side, trade_side, taker_asset, maker_asset);
assert_eq!(result, expected);
}
#[rstest]
fn test_make_composite_trade_id_basic() {
let trade_id = "trade-abc123";
let venue_order_id = "order-xyz789";
let result = make_composite_trade_id(trade_id, venue_order_id);
assert_eq!(result.as_str(), "trade-abc123-r-xyz789");
}
#[rstest]
fn test_make_composite_trade_id_truncates_long_ids() {
let trade_id = "a]".repeat(30);
let venue_order_id = "b".repeat(20);
let result = make_composite_trade_id(&trade_id, &venue_order_id);
assert!(result.as_str().len() <= 36);
}
#[rstest]
fn test_make_composite_trade_id_short_venue_id() {
let trade_id = "t123";
let venue_order_id = "ab";
let result = make_composite_trade_id(trade_id, venue_order_id);
assert_eq!(result.as_str(), "t123-ab");
}
#[rstest]
fn test_make_composite_trade_id_uniqueness() {
let id_a = make_composite_trade_id("same-trade", "order-aaa");
let id_b = make_composite_trade_id("same-trade", "order-bbb");
assert_ne!(id_a, id_b);
}
#[rstest]
fn test_calculate_market_price_buy_single_level() {
let levels = vec![ClobBookLevel {
price: "0.55".to_string(),
size: "200.0".to_string(),
}];
let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
assert_eq!(result.crossing_price, dec!(0.55));
assert!(result.expected_base_qty > dec!(90));
}
#[rstest]
fn test_calculate_market_price_buy_walks_multiple_levels() {
let levels = vec![
ClobBookLevel {
price: "0.55".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "10.0".to_string(),
},
ClobBookLevel {
price: "0.60".to_string(),
size: "200.0".to_string(),
},
];
let result = calculate_market_price(&levels, dec!(20), PolymarketOrderSide::Buy).unwrap();
assert_eq!(result.crossing_price, dec!(0.55));
let expected = dec!(10) + dec!(15) / dec!(0.55);
assert_eq!(result.expected_base_qty, expected);
}
#[rstest]
fn test_calculate_market_price_buy_small_order_uses_best_ask() {
let levels = vec![
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
ClobBookLevel {
price: "0.999".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.20".to_string(),
size: "72.0".to_string(),
},
];
let result = calculate_market_price(&levels, dec!(5), PolymarketOrderSide::Buy).unwrap();
assert_eq!(result.crossing_price, dec!(0.20));
assert_eq!(result.expected_base_qty, dec!(25)); }
#[rstest]
fn test_calculate_market_price_sell_walks_levels() {
let levels = vec![
ClobBookLevel {
price: "0.48".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
];
let result = calculate_market_price(&levels, dec!(80), PolymarketOrderSide::Sell).unwrap();
assert_eq!(result.crossing_price, dec!(0.48));
assert_eq!(result.expected_base_qty, dec!(80));
}
#[rstest]
fn test_calculate_market_price_empty_book() {
let levels: Vec<ClobBookLevel> = vec![];
let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
assert!(result.is_err());
}
#[rstest]
fn test_calculate_market_price_all_zero_levels_returns_error() {
let levels = vec![
ClobBookLevel {
price: "0".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "0".to_string(),
},
];
let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy);
assert!(result.is_err());
}
#[rstest]
fn test_calculate_market_price_insufficient_liquidity_returns_worst() {
let levels = vec![ClobBookLevel {
price: "0.55".to_string(),
size: "10.0".to_string(),
}];
let result = calculate_market_price(&levels, dec!(50), PolymarketOrderSide::Buy).unwrap();
assert_eq!(result.crossing_price, dec!(0.55));
assert_eq!(result.expected_base_qty, dec!(10)); }
#[rstest]
fn test_calculate_market_price_buy_order_independent_of_input_ordering() {
let levels_ascending = vec![
ClobBookLevel {
price: "0.20".to_string(),
size: "72.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
ClobBookLevel {
price: "0.999".to_string(),
size: "100.0".to_string(),
},
];
let levels_descending = vec![
ClobBookLevel {
price: "0.999".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
ClobBookLevel {
price: "0.20".to_string(),
size: "72.0".to_string(),
},
];
let levels_shuffled = vec![
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
ClobBookLevel {
price: "0.20".to_string(),
size: "72.0".to_string(),
},
ClobBookLevel {
price: "0.999".to_string(),
size: "100.0".to_string(),
},
];
let r1 =
calculate_market_price(&levels_ascending, dec!(20), PolymarketOrderSide::Buy).unwrap();
let r2 =
calculate_market_price(&levels_descending, dec!(20), PolymarketOrderSide::Buy).unwrap();
let r3 =
calculate_market_price(&levels_shuffled, dec!(20), PolymarketOrderSide::Buy).unwrap();
assert_eq!(r1.crossing_price, r2.crossing_price);
assert_eq!(r2.crossing_price, r3.crossing_price);
assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
assert_eq!(r2.expected_base_qty, r3.expected_base_qty);
}
#[rstest]
fn test_calculate_market_price_sell_order_independent_of_input_ordering() {
let levels_a = vec![
ClobBookLevel {
price: "0.48".to_string(),
size: "100.0".to_string(),
},
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
];
let levels_b = vec![
ClobBookLevel {
price: "0.50".to_string(),
size: "50.0".to_string(),
},
ClobBookLevel {
price: "0.48".to_string(),
size: "100.0".to_string(),
},
];
let r1 = calculate_market_price(&levels_a, dec!(80), PolymarketOrderSide::Sell).unwrap();
let r2 = calculate_market_price(&levels_b, dec!(80), PolymarketOrderSide::Sell).unwrap();
assert_eq!(r1.crossing_price, r2.crossing_price);
assert_eq!(r1.expected_base_qty, r2.expected_base_qty);
}
}