mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-08-13 12:38:05 +00:00
feat(ws): align auth and event_type parsing
This commit is contained in:
+58
-49
@@ -26,7 +26,7 @@ use polyfill_rs::{
|
||||
types::*,
|
||||
|
||||
// Utility functions
|
||||
utils::{address, crypto, math, rate_limit, retry, time, url},
|
||||
utils::{address, math, rate_limit, retry, time, url},
|
||||
|
||||
// Configuration
|
||||
ClientConfig,
|
||||
@@ -43,7 +43,7 @@ use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Demo showcasing polyfill-rs functionality
|
||||
#[allow(dead_code)]
|
||||
@@ -592,10 +592,9 @@ impl PolyfillDemo {
|
||||
initial_dump: Some(true),
|
||||
custom_feature_enabled: None,
|
||||
auth: Some(WssAuth {
|
||||
address: "0x1234567890123456789012345678901234567890".to_string(),
|
||||
signature: "mock_signature".to_string(),
|
||||
timestamp: time::now_secs(),
|
||||
nonce: crypto::generate_nonce().to_string(),
|
||||
api_key: "test-api-key".to_string(),
|
||||
secret: "test-secret".to_string(),
|
||||
passphrase: "test-passphrase".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -603,33 +602,33 @@ impl PolyfillDemo {
|
||||
|
||||
// Simulate receiving stream messages
|
||||
let messages = vec![
|
||||
StreamMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: "12345".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
StreamMessage::Book(BookUpdate {
|
||||
asset_id: "12345".to_string(),
|
||||
market: "market1".to_string(),
|
||||
timestamp: time::now_millis(),
|
||||
bids: vec![OrderSummary {
|
||||
price: dec!(0.75),
|
||||
size: dec!(100.0),
|
||||
sequence: 1,
|
||||
},
|
||||
},
|
||||
StreamMessage::Trade {
|
||||
data: FillEvent {
|
||||
id: "fill1".to_string(),
|
||||
order_id: "order1".to_string(),
|
||||
token_id: "12345".to_string(),
|
||||
side: Side::BUY,
|
||||
price: dec!(0.75),
|
||||
}],
|
||||
asks: vec![OrderSummary {
|
||||
price: dec!(0.76),
|
||||
size: dec!(50.0),
|
||||
timestamp: chrono::Utc::now(),
|
||||
maker_address: alloy_primitives::Address::ZERO,
|
||||
taker_address: alloy_primitives::Address::ZERO,
|
||||
fee: dec!(0.375),
|
||||
},
|
||||
},
|
||||
}],
|
||||
hash: None,
|
||||
}),
|
||||
StreamMessage::Trade(TradeMessage {
|
||||
id: "fill1".to_string(),
|
||||
market: "market1".to_string(),
|
||||
asset_id: "12345".to_string(),
|
||||
side: Side::BUY,
|
||||
size: dec!(50.0),
|
||||
price: dec!(0.75),
|
||||
status: Some("MATCHED".to_string()),
|
||||
msg_type: None,
|
||||
last_update: None,
|
||||
matchtime: None,
|
||||
timestamp: None,
|
||||
}),
|
||||
];
|
||||
|
||||
for message in messages {
|
||||
@@ -638,31 +637,41 @@ impl PolyfillDemo {
|
||||
|
||||
// Process message based on type
|
||||
match &message {
|
||||
StreamMessage::BookUpdate { data } => {
|
||||
info!(" Processing book update for token: {}", data.token_id);
|
||||
if let Err(e) = self.book_manager.apply_delta(data.clone()) {
|
||||
error!(" Failed to apply book update: {}", e);
|
||||
self.stats.errors += 1;
|
||||
StreamMessage::Book(book) => {
|
||||
info!(" Processing book update for asset: {}", book.asset_id);
|
||||
// This is a demo: apply snapshot levels as deltas.
|
||||
for level in &book.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: book.asset_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
},
|
||||
StreamMessage::Trade { data } => {
|
||||
for level in &book.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: book.asset_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
StreamMessage::Trade(trade) => {
|
||||
info!(
|
||||
" Processing trade: {} {} @ {}",
|
||||
data.side.as_str(),
|
||||
data.size,
|
||||
data.price
|
||||
trade.side.as_str(),
|
||||
trade.size,
|
||||
trade.price
|
||||
);
|
||||
if let Err(e) = self.fill_processor.process_fill(data.clone()) {
|
||||
error!(" Failed to process fill: {}", e);
|
||||
self.stats.errors += 1;
|
||||
}
|
||||
},
|
||||
StreamMessage::Heartbeat { timestamp } => {
|
||||
debug!(" Received heartbeat at: {}", timestamp);
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
info!(" Unhandled message type");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-41
@@ -101,43 +101,86 @@ impl SnipeStrategy {
|
||||
/// Process a market data update
|
||||
pub fn process_update(&mut self, message: StreamMessage) -> Result<()> {
|
||||
match message {
|
||||
StreamMessage::BookUpdate { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_book_update(data)?;
|
||||
StreamMessage::Book(book) => {
|
||||
if book.asset_id == self.token_id {
|
||||
self.process_book_update(book)?;
|
||||
}
|
||||
},
|
||||
StreamMessage::Trade { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_trade(data)?;
|
||||
StreamMessage::Trade(trade) => {
|
||||
if trade.asset_id == self.token_id {
|
||||
self.process_trade(trade)?;
|
||||
}
|
||||
},
|
||||
StreamMessage::Heartbeat { timestamp: _ } => {
|
||||
self.check_stale_quotes()?;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
// Opportunistically check for staleness on any incoming update.
|
||||
self.check_stale_quotes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process order book update
|
||||
fn process_book_update(&mut self, delta: OrderDelta) -> Result<()> {
|
||||
fn process_book_update(&mut self, book: BookUpdate) -> Result<()> {
|
||||
// Ensure book exists
|
||||
self.book_manager.get_or_create_book(&self.token_id)?;
|
||||
|
||||
// Update local order book
|
||||
self.book_manager.apply_delta(delta.clone())?;
|
||||
// Clear the existing book and rebuild from the snapshot.
|
||||
if let Ok(current) = self.book_manager.get_book(&self.token_id) {
|
||||
for level in ¤t.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: Decimal::ZERO,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Get current book state
|
||||
let book = self.book_manager.get_book(&self.token_id)?;
|
||||
for level in ¤t.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: Decimal::ZERO,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update best prices
|
||||
if let Some(best_bid) = book.bids.first() {
|
||||
self.last_best_bid = Some(best_bid.price);
|
||||
let ts = chrono::DateTime::from_timestamp(
|
||||
(book.timestamp / 1000) as i64,
|
||||
((book.timestamp % 1000) * 1_000_000) as u32,
|
||||
)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
for level in &book.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: ts,
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
if let Some(best_ask) = book.asks.first() {
|
||||
self.last_best_ask = Some(best_ask.price);
|
||||
|
||||
for level in &book.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: ts,
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Update best prices directly from the snapshot
|
||||
self.last_best_bid = book.bids.first().map(|l| l.price);
|
||||
self.last_best_ask = book.asks.first().map(|l| l.price);
|
||||
|
||||
self.last_update = time::now_secs();
|
||||
|
||||
// Check for trading opportunities
|
||||
@@ -147,17 +190,17 @@ impl SnipeStrategy {
|
||||
}
|
||||
|
||||
/// Process trade update
|
||||
fn process_trade(&mut self, fill: FillEvent) -> Result<()> {
|
||||
fn process_trade(&mut self, trade: TradeMessage) -> Result<()> {
|
||||
info!(
|
||||
"Trade: {} {} @ {} (size: {})",
|
||||
fill.side.as_str(),
|
||||
fill.token_id,
|
||||
fill.price,
|
||||
fill.size
|
||||
trade.side.as_str(),
|
||||
trade.asset_id,
|
||||
trade.price,
|
||||
trade.size
|
||||
);
|
||||
|
||||
// Update statistics
|
||||
self.stats.total_volume += fill.size;
|
||||
self.stats.total_volume += trade.size;
|
||||
|
||||
// Calculate P&L if this was our trade
|
||||
// (In a real implementation, you'd track your own orders)
|
||||
@@ -327,24 +370,19 @@ impl MockMarketData {
|
||||
let price_change = random_factor * Decimal::from(2) * self.volatility;
|
||||
let new_price = self.base_price * (Decimal::from(1) + price_change);
|
||||
|
||||
// Generate order book update
|
||||
let side = if rand::random::<bool>() {
|
||||
Side::BUY
|
||||
} else {
|
||||
Side::SELL
|
||||
};
|
||||
// Generate a simple orderbook snapshot update
|
||||
let size = Decimal::from(rand::random::<u64>() % 1000 + 100);
|
||||
let bid = new_price - dec!(0.01);
|
||||
let ask = new_price + dec!(0.01);
|
||||
|
||||
StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side,
|
||||
price: new_price,
|
||||
size,
|
||||
sequence: self.sequence,
|
||||
},
|
||||
}
|
||||
StreamMessage::Book(BookUpdate {
|
||||
asset_id: self.token_id.clone(),
|
||||
market: "0xmock".to_string(),
|
||||
timestamp: time::now_millis(),
|
||||
bids: vec![OrderSummary { price: bid, size }],
|
||||
asks: vec![OrderSummary { price: ask, size }],
|
||||
hash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-36
@@ -432,44 +432,61 @@ impl Decoder<Market> for RawMarketResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket message parsing
|
||||
pub fn parse_stream_message(raw: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(raw)?;
|
||||
/// WebSocket message parsing (official `event_type` shape).
|
||||
///
|
||||
/// Polymarket WebSocket servers may send either a single JSON object or a batch array.
|
||||
/// This parser is tolerant:
|
||||
/// - Unknown/unsupported `event_type`s are ignored.
|
||||
/// - Invalid entries inside a batch are skipped (do not fail the whole batch).
|
||||
pub fn parse_stream_messages(raw: &str) -> Result<Vec<StreamMessage>> {
|
||||
parse_stream_messages_bytes(raw.as_bytes())
|
||||
}
|
||||
|
||||
let msg_type = value["type"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PolyfillError::parse("Missing message type".to_string(), None))?;
|
||||
/// See `parse_stream_messages`.
|
||||
pub fn parse_stream_messages_bytes(bytes: &[u8]) -> Result<Vec<StreamMessage>> {
|
||||
let value: Value = serde_json::from_slice(bytes)?;
|
||||
|
||||
match msg_type {
|
||||
"book_update" => {
|
||||
let data = value["data"].clone();
|
||||
let delta: OrderDelta = serde_json::from_value(data)?;
|
||||
Ok(StreamMessage::BookUpdate { data: delta })
|
||||
},
|
||||
"trade" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_trade: RawTradeResponse = serde_json::from_value(data)?;
|
||||
let fill = raw_trade.decode()?;
|
||||
Ok(StreamMessage::Trade { data: fill })
|
||||
},
|
||||
"order_update" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_order: RawOrderResponse = serde_json::from_value(data)?;
|
||||
let order = raw_order.decode()?;
|
||||
Ok(StreamMessage::OrderUpdate { data: order })
|
||||
},
|
||||
"heartbeat" => {
|
||||
let timestamp = value["timestamp"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
},
|
||||
_ => Err(PolyfillError::parse(
|
||||
format!("Unknown message type: {}", msg_type),
|
||||
None,
|
||||
)),
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let event_type = map.get("event_type").and_then(Value::as_str);
|
||||
match event_type {
|
||||
None => Ok(vec![]),
|
||||
Some(_) => {
|
||||
let msg: StreamMessage = serde_json::from_value(Value::Object(map))?;
|
||||
match msg {
|
||||
StreamMessage::Unknown => Ok(vec![]),
|
||||
other => Ok(vec![other]),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => Ok(arr
|
||||
.into_iter()
|
||||
.filter_map(|elem| {
|
||||
let obj = elem.as_object()?;
|
||||
let event_type = obj.get("event_type").and_then(Value::as_str)?;
|
||||
// Skip unknown event types early (forward compatibility).
|
||||
match event_type {
|
||||
"book"
|
||||
| "price_change"
|
||||
| "tick_size_change"
|
||||
| "last_trade_price"
|
||||
| "best_bid_ask"
|
||||
| "new_market"
|
||||
| "market_resolved"
|
||||
| "trade"
|
||||
| "order" => {}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<StreamMessage>(Value::Object(obj.clone())) {
|
||||
Ok(StreamMessage::Unknown) => None,
|
||||
Ok(msg) => Some(msg),
|
||||
Err(_) => None,
|
||||
}
|
||||
})
|
||||
.collect()),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+73
-148
@@ -274,12 +274,12 @@ impl WebSocketStream {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
|
||||
// Parse the message according to Polymarket's format
|
||||
let stream_message = self.parse_polymarket_message(&text)?;
|
||||
|
||||
// Send to internal channel
|
||||
if let Err(e) = self.tx.send(stream_message) {
|
||||
error!("Failed to send message to internal channel: {}", e);
|
||||
// Parse the message according to Polymarket's `event_type` format
|
||||
let stream_messages = crate::decode::parse_stream_messages(&text)?;
|
||||
for stream_message in stream_messages {
|
||||
if let Err(e) = self.tx.send(stream_message) {
|
||||
error!("Failed to send message to internal channel: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self.stats.messages_received += 1;
|
||||
@@ -313,115 +313,10 @@ impl WebSocketStream {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse Polymarket WebSocket message format
|
||||
/// Parse Polymarket WebSocket message(s) in `event_type` format.
|
||||
#[allow(dead_code)]
|
||||
fn parse_polymarket_message(&self, text: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse WebSocket message: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Extract message type
|
||||
let message_type = value.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
PolyfillError::parse("Missing 'type' field in WebSocket message", None)
|
||||
})?;
|
||||
|
||||
match message_type {
|
||||
"book_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse book update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::BookUpdate { data })
|
||||
},
|
||||
"trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::Trade { data })
|
||||
},
|
||||
"order_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse order update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::OrderUpdate { data })
|
||||
},
|
||||
"user_order_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse user order update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::UserOrderUpdate { data })
|
||||
},
|
||||
"user_trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse user trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::UserTrade { data })
|
||||
},
|
||||
"market_book_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse market book update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::MarketBookUpdate { data })
|
||||
},
|
||||
"market_trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse market trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::MarketTrade { data })
|
||||
},
|
||||
"heartbeat" => {
|
||||
let timestamp = value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|ts| chrono::DateTime::from_timestamp(ts as i64, 0).unwrap_or_default())
|
||||
.unwrap_or_else(Utc::now);
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
},
|
||||
_ => {
|
||||
warn!("Unknown message type: {}", message_type);
|
||||
// Return heartbeat as fallback
|
||||
Ok(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
})
|
||||
},
|
||||
}
|
||||
fn parse_polymarket_messages(&self, text: &str) -> Result<Vec<StreamMessage>> {
|
||||
crate::decode::parse_stream_messages(text)
|
||||
}
|
||||
|
||||
/// Reconnect with exponential backoff
|
||||
@@ -476,33 +371,58 @@ impl Stream for WebSocketStream {
|
||||
type Item = Result<StreamMessage>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
// First check internal channel
|
||||
if let Poll::Ready(Some(message)) = self.rx.poll_recv(cx) {
|
||||
return Poll::Ready(Some(Ok(message)));
|
||||
}
|
||||
loop {
|
||||
// First drain any parsed messages
|
||||
if let Poll::Ready(Some(message)) = self.rx.poll_recv(cx) {
|
||||
return Poll::Ready(Some(Ok(message)));
|
||||
}
|
||||
|
||||
let Some(connection) = &mut self.connection else {
|
||||
return Poll::Ready(None);
|
||||
};
|
||||
|
||||
// Then check WebSocket connection
|
||||
if let Some(connection) = &mut self.connection {
|
||||
match connection.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(_message))) => {
|
||||
// Simplified message handling
|
||||
Poll::Ready(Some(Ok(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
})))
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Some(Ok(ws_message))) => match ws_message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
match crate::decode::parse_stream_messages(&text) {
|
||||
Ok(messages) => {
|
||||
for msg in messages {
|
||||
let _ = self.tx.send(msg);
|
||||
}
|
||||
self.stats.messages_received += 1;
|
||||
self.stats.last_message_time = Some(Utc::now());
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
self.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||
info!("WebSocket connection closed by server");
|
||||
self.connection = None;
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Ping(_) => {
|
||||
// Best-effort: tokio-tungstenite/tungstenite may handle pings internally.
|
||||
continue;
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Binary(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Frame(_) => continue,
|
||||
},
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
self.stats.errors += 1;
|
||||
Poll::Ready(Some(Err(e.into())))
|
||||
},
|
||||
return Poll::Ready(Some(Err(e.into())));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
info!("WebSocket stream ended");
|
||||
Poll::Ready(None)
|
||||
},
|
||||
Poll::Pending => Poll::Pending,
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Poll::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,19 +575,19 @@ mod tests {
|
||||
let mut stream = MockStream::new();
|
||||
|
||||
// Add some test messages
|
||||
stream.add_message(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
stream.add_message(StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: "test".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: rust_decimal_macros::dec!(0.5),
|
||||
size: rust_decimal_macros::dec!(100),
|
||||
sequence: 1,
|
||||
},
|
||||
});
|
||||
stream.add_message(StreamMessage::Book(BookUpdate {
|
||||
asset_id: "1".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_890,
|
||||
bids: vec![],
|
||||
asks: vec![],
|
||||
hash: None,
|
||||
}));
|
||||
stream.add_message(StreamMessage::PriceChange(PriceChange {
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_891,
|
||||
price_changes: vec![],
|
||||
}));
|
||||
|
||||
assert!(stream.is_connected());
|
||||
assert_eq!(stream.get_stats().messages_received, 2);
|
||||
@@ -680,9 +600,14 @@ mod tests {
|
||||
manager.add_stream(mock_stream);
|
||||
|
||||
// Test message broadcasting
|
||||
let message = StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
let message = StreamMessage::Book(BookUpdate {
|
||||
asset_id: "1".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_890,
|
||||
bids: vec![],
|
||||
asks: vec![],
|
||||
hash: None,
|
||||
});
|
||||
assert!(manager.broadcast_message(message).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
+243
-31
@@ -659,18 +659,11 @@ impl Default for ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket authentication for Polymarket API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WssAuth {
|
||||
/// User's Ethereum address
|
||||
pub address: String,
|
||||
/// EIP-712 signature
|
||||
pub signature: String,
|
||||
/// Unix timestamp
|
||||
pub timestamp: u64,
|
||||
/// Nonce for replay protection
|
||||
pub nonce: String,
|
||||
}
|
||||
/// WebSocket authentication for Polymarket API user channel.
|
||||
///
|
||||
/// Polymarket's CLOB WebSocket expects the same L2 API credentials used for HTTP calls:
|
||||
/// `{ apiKey, secret, passphrase }`.
|
||||
pub type WssAuth = ApiCredentials;
|
||||
|
||||
/// WebSocket subscription request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -699,28 +692,247 @@ pub struct WssSubscription {
|
||||
pub auth: Option<WssAuth>,
|
||||
}
|
||||
|
||||
/// WebSocket message types for streaming
|
||||
/// WebSocket message types for streaming (official Polymarket `event_type` format).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(tag = "event_type")]
|
||||
pub enum StreamMessage {
|
||||
#[serde(rename = "book_update")]
|
||||
BookUpdate { data: OrderDelta },
|
||||
/// Full or incremental orderbook update
|
||||
#[serde(rename = "book")]
|
||||
Book(BookUpdate),
|
||||
/// Price change notification (single or batched)
|
||||
#[serde(rename = "price_change")]
|
||||
PriceChange(PriceChange),
|
||||
/// Tick size change notification
|
||||
#[serde(rename = "tick_size_change")]
|
||||
TickSizeChange(TickSizeChange),
|
||||
/// Last trade price update
|
||||
#[serde(rename = "last_trade_price")]
|
||||
LastTradePrice(LastTradePrice),
|
||||
/// Best bid/ask update (requires `custom_feature_enabled`)
|
||||
#[serde(rename = "best_bid_ask")]
|
||||
BestBidAsk(BestBidAsk),
|
||||
/// New market created (requires `custom_feature_enabled`)
|
||||
#[serde(rename = "new_market")]
|
||||
NewMarket(NewMarket),
|
||||
/// Market resolved (requires `custom_feature_enabled`)
|
||||
#[serde(rename = "market_resolved")]
|
||||
MarketResolved(MarketResolved),
|
||||
/// User trade execution (authenticated channel)
|
||||
#[serde(rename = "trade")]
|
||||
Trade { data: FillEvent },
|
||||
#[serde(rename = "order_update")]
|
||||
OrderUpdate { data: Order },
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat { timestamp: DateTime<Utc> },
|
||||
/// User channel events
|
||||
#[serde(rename = "user_order_update")]
|
||||
UserOrderUpdate { data: Order },
|
||||
#[serde(rename = "user_trade")]
|
||||
UserTrade { data: FillEvent },
|
||||
/// Market channel events
|
||||
#[serde(rename = "market_book_update")]
|
||||
MarketBookUpdate { data: OrderDelta },
|
||||
#[serde(rename = "market_trade")]
|
||||
MarketTrade { data: FillEvent },
|
||||
Trade(TradeMessage),
|
||||
/// User order update (authenticated channel)
|
||||
#[serde(rename = "order")]
|
||||
Order(OrderMessage),
|
||||
/// Forward-compatible catch-all for new/unknown event types.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Orderbook update message (full snapshot or delta).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BookUpdate {
|
||||
pub asset_id: String,
|
||||
pub market: String,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
#[serde(default, deserialize_with = "crate::decode::deserializers::vec_from_null")]
|
||||
pub bids: Vec<OrderSummary>,
|
||||
#[serde(default, deserialize_with = "crate::decode::deserializers::vec_from_null")]
|
||||
pub asks: Vec<OrderSummary>,
|
||||
#[serde(default)]
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Unified wire format for `price_change` events.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PriceChange {
|
||||
pub market: String,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
#[serde(default, deserialize_with = "crate::decode::deserializers::vec_from_null")]
|
||||
pub price_changes: Vec<PriceChangeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PriceChangeEntry {
|
||||
pub asset_id: String,
|
||||
pub price: Decimal,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub size: Option<Decimal>,
|
||||
pub side: Side,
|
||||
#[serde(default)]
|
||||
pub hash: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub best_bid: Option<Decimal>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub best_ask: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// Tick size change event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TickSizeChange {
|
||||
pub asset_id: String,
|
||||
pub market: String,
|
||||
pub old_tick_size: Decimal,
|
||||
pub new_tick_size: Decimal,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Last trade price update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastTradePrice {
|
||||
pub asset_id: String,
|
||||
pub market: String,
|
||||
pub price: Decimal,
|
||||
#[serde(default)]
|
||||
pub side: Option<Side>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub size: Option<Decimal>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub fee_rate_bps: Option<Decimal>,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Best bid/ask update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BestBidAsk {
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub best_bid: Decimal,
|
||||
pub best_ask: Decimal,
|
||||
pub spread: Decimal,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// New market created event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewMarket {
|
||||
pub id: String,
|
||||
pub question: String,
|
||||
pub market: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "assets_ids", alias = "asset_ids")]
|
||||
pub asset_ids: Vec<String>,
|
||||
#[serde(default, deserialize_with = "crate::decode::deserializers::vec_from_null")]
|
||||
pub outcomes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub event_message: Option<EventMessage>,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Market resolved event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketResolved {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub question: Option<String>,
|
||||
pub market: String,
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "assets_ids", alias = "asset_ids")]
|
||||
pub asset_ids: Vec<String>,
|
||||
#[serde(default, deserialize_with = "crate::decode::deserializers::vec_from_null")]
|
||||
pub outcomes: Vec<String>,
|
||||
pub winning_asset_id: String,
|
||||
pub winning_outcome: String,
|
||||
#[serde(default)]
|
||||
pub event_message: Option<EventMessage>,
|
||||
#[serde(deserialize_with = "crate::decode::deserializers::number_from_string")]
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Event message object for market events.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventMessage {
|
||||
pub id: String,
|
||||
pub ticker: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// User trade execution message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TradeMessage {
|
||||
pub id: String,
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub side: Side,
|
||||
pub size: Decimal,
|
||||
pub price: Decimal,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(rename = "type", default)]
|
||||
pub msg_type: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
|
||||
)]
|
||||
pub last_update: Option<u64>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "match_time",
|
||||
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
|
||||
)]
|
||||
pub matchtime: Option<u64>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
|
||||
)]
|
||||
pub timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
/// User order update message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrderMessage {
|
||||
pub id: String,
|
||||
pub market: String,
|
||||
pub asset_id: String,
|
||||
pub side: Side,
|
||||
pub price: Decimal,
|
||||
#[serde(rename = "type", default)]
|
||||
pub msg_type: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub original_size: Option<Decimal>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_decimal_from_string"
|
||||
)]
|
||||
pub size_matched: Option<Decimal>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::decode::deserializers::optional_number_from_string"
|
||||
)]
|
||||
pub timestamp: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub associate_trades: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// Subscription parameters for streaming
|
||||
|
||||
Reference in New Issue
Block a user