feat(ws): extend TradeMessage with full trade execution fields

TradeMessage was severely under-specified compared to the Polymarket WS
API. Add missing fields needed by the engine: taker_order_id,
maker_orders, trader_side, fee_rate_bps, outcome, owner, trade_owner,
transaction_hash. Replace untyped status/msg_type strings with proper
TradeMessageStatus and TradeMessageType enums.
This commit is contained in:
Andrey Kuznetsov
2026-02-13 01:39:26 +00:00
parent 0280d537ae
commit 4d6ba2ab5c
4 changed files with 225 additions and 4 deletions
+9 -1
View File
@@ -623,11 +623,19 @@ impl PolyfillDemo {
side: Side::BUY,
size: dec!(50.0),
price: dec!(0.75),
status: Some("MATCHED".to_string()),
status: TradeMessageStatus::Matched,
msg_type: None,
last_update: None,
matchtime: None,
timestamp: None,
outcome: None,
owner: None,
trade_owner: None,
taker_order_id: None,
maker_orders: vec![],
fee_rate_bps: None,
transaction_hash: None,
trader_side: None,
}),
];
+149
View File
@@ -663,4 +663,153 @@ mod tests {
let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_trade_message_full_payload() {
use crate::types::{StreamMessage, TradeMessageStatus, TradeMessageType, TraderSide};
let json = r#"{
"event_type": "trade",
"id": "trade-001",
"market": "0xabc123",
"asset_id": "asset-xyz",
"side": "BUY",
"size": "100.5",
"price": "0.65",
"status": "MATCHED",
"type": "TRADE",
"last_update": "1700000000",
"match_time": "1700000001",
"timestamp": "1700000002",
"outcome": "Yes",
"owner": "owner-key-123",
"trade_owner": "trader-key-456",
"taker_order_id": "taker-order-789",
"maker_orders": [
{
"order_id": "maker-order-1",
"owner": "maker-owner-1",
"matched_amount": "50.25",
"price": "0.65",
"asset_id": "asset-xyz",
"outcome": "Yes"
}
],
"fee_rate_bps": "2.5",
"transaction_hash": "0xdeadbeef",
"trader_side": "TAKER"
}"#;
let msgs = parse_stream_messages(json).unwrap();
assert_eq!(msgs.len(), 1);
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade variant");
};
assert_eq!(trade.id, "trade-001");
assert_eq!(trade.market, "0xabc123");
assert_eq!(trade.asset_id, "asset-xyz");
assert_eq!(trade.side, Side::BUY);
assert_eq!(trade.size, Decimal::from_str("100.5").unwrap());
assert_eq!(trade.price, Decimal::from_str("0.65").unwrap());
assert_eq!(trade.status, TradeMessageStatus::Matched);
assert_eq!(trade.msg_type, Some(TradeMessageType::Trade));
assert_eq!(trade.last_update, Some(1700000000));
assert_eq!(trade.matchtime, Some(1700000001));
assert_eq!(trade.timestamp, Some(1700000002));
assert_eq!(trade.outcome.as_deref(), Some("Yes"));
assert_eq!(trade.owner.as_deref(), Some("owner-key-123"));
assert_eq!(trade.trade_owner.as_deref(), Some("trader-key-456"));
assert_eq!(trade.taker_order_id.as_deref(), Some("taker-order-789"));
assert_eq!(trade.maker_orders.len(), 1);
assert_eq!(trade.maker_orders[0].order_id, "maker-order-1");
assert_eq!(trade.fee_rate_bps, Some(Decimal::from_str("2.5").unwrap()));
assert_eq!(trade.transaction_hash.as_deref(), Some("0xdeadbeef"));
assert_eq!(trade.trader_side, Some(TraderSide::Taker));
}
#[test]
fn test_trade_message_minimal_payload() {
use crate::types::StreamMessage;
// Only mandatory fields — all new optional fields absent.
let json = r#"{
"event_type": "trade",
"id": "trade-minimal",
"market": "0xdef",
"asset_id": "asset-min",
"side": "SELL",
"size": "10",
"price": "0.50"
}"#;
let msgs = parse_stream_messages(json).unwrap();
assert_eq!(msgs.len(), 1);
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade variant");
};
assert_eq!(trade.id, "trade-minimal");
assert_eq!(trade.side, Side::SELL);
// All optional fields should be None / empty / default.
assert!(trade.msg_type.is_none());
assert!(trade.outcome.is_none());
assert!(trade.owner.is_none());
assert!(trade.trade_owner.is_none());
assert!(trade.taker_order_id.is_none());
assert!(trade.maker_orders.is_empty());
assert!(trade.fee_rate_bps.is_none());
assert!(trade.transaction_hash.is_none());
assert!(trade.trader_side.is_none());
}
#[test]
fn test_trade_message_status_lifecycle() {
use crate::types::{StreamMessage, TradeMessageStatus};
for (status_str, expected) in [
("MATCHED", TradeMessageStatus::Matched),
("matched", TradeMessageStatus::Matched),
("Matched", TradeMessageStatus::Matched),
("MINED", TradeMessageStatus::Mined),
("mined", TradeMessageStatus::Mined),
("CONFIRMED", TradeMessageStatus::Confirmed),
("confirmed", TradeMessageStatus::Confirmed),
] {
let json = format!(
r#"{{
"event_type": "trade",
"id": "t1", "market": "m", "asset_id": "a",
"side": "BUY", "size": "1", "price": "0.5",
"status": "{status_str}"
}}"#
);
let msgs = parse_stream_messages(&json).unwrap();
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade");
};
assert_eq!(trade.status, expected, "failed for status_str={status_str}");
}
}
#[test]
fn test_trade_message_null_maker_orders() {
use crate::types::StreamMessage;
// API sometimes sends `null` instead of `[]`.
let json = r#"{
"event_type": "trade",
"id": "t1", "market": "m", "asset_id": "a",
"side": "BUY", "size": "1", "price": "0.5",
"maker_orders": null
}"#;
let msgs = parse_stream_messages(json).unwrap();
let StreamMessage::Trade(trade) = &msgs[0] else {
panic!("expected Trade");
};
assert!(trade.maker_orders.is_empty());
}
}
+3
View File
@@ -147,6 +147,9 @@ pub use crate::types::{
TickSizeResponse,
Token,
TokenPrice,
TradeMessage,
TradeMessageStatus,
TradeMessageType,
TradeParams,
TradeResponse,
TraderSide,
+64 -3
View File
@@ -206,6 +206,36 @@ impl Default for TraderSide {
}
}
/// Trade lifecycle status (Matched → Mined → Confirmed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeMessageStatus {
#[serde(alias = "matched", alias = "MATCHED")]
Matched,
#[serde(alias = "mined", alias = "MINED")]
Mined,
#[serde(alias = "confirmed", alias = "CONFIRMED")]
Confirmed,
/// Forward-compatible catch-all for unknown statuses.
#[serde(untagged)]
Unknown(String),
}
impl Default for TradeMessageStatus {
fn default() -> Self {
Self::Unknown("UNKNOWN".to_string())
}
}
/// Trade message type discriminator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeMessageType {
#[serde(alias = "trade", alias = "TRADE")]
Trade,
/// Forward-compatible catch-all.
#[serde(untagged)]
Unknown(String),
}
impl Side {
pub fn as_str(&self) -> &'static str {
match self {
@@ -908,7 +938,7 @@ pub struct EventMessage {
pub description: String,
}
/// User trade execution message.
/// User trade execution message (authenticated WebSocket channel).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradeMessage {
pub id: String,
@@ -917,10 +947,11 @@ pub struct TradeMessage {
pub side: Side,
pub size: Decimal,
pub price: Decimal,
/// Trade lifecycle status (Matched → Mined → Confirmed).
#[serde(default)]
pub status: Option<String>,
pub status: TradeMessageStatus,
#[serde(rename = "type", default)]
pub msg_type: Option<String>,
pub msg_type: Option<TradeMessageType>,
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
@@ -937,6 +968,36 @@ pub struct TradeMessage {
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
)]
pub timestamp: Option<u64>,
/// Outcome (e.g. "Yes" / "No").
#[serde(default)]
pub outcome: Option<String>,
/// API key of the event owner.
#[serde(default)]
pub owner: Option<String>,
/// API key of the trade owner.
#[serde(default)]
pub trade_owner: Option<String>,
/// Taker order ID.
#[serde(default)]
pub taker_order_id: Option<String>,
/// Maker order details.
#[serde(
default,
deserialize_with = "crate::decode::deserializers::vec_from_null"
)]
pub maker_orders: Vec<MakerOrder>,
/// Fee rate in basis points.
#[serde(
default,
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
)]
pub fee_rate_bps: Option<Decimal>,
/// On-chain transaction hash.
#[serde(default)]
pub transaction_hash: Option<String>,
/// Whether user was maker or taker.
#[serde(default)]
pub trader_side: Option<TraderSide>,
}
/// User order update message.