Skip to main content

nautilus_polymarket/websocket/
messages.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket message types for the Polymarket CLOB API.
17
18use serde::{Deserialize, Serialize};
19use ustr::Ustr;
20
21use crate::common::{
22    enums::{
23        PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus,
24        PolymarketOrderType, PolymarketOutcome, PolymarketTradeStatus,
25    },
26    models::PolymarketMakerOrder,
27};
28
29/// A user order status update from the WebSocket user channel.
30///
31/// References: <https://docs.polymarket.com/developers/CLOB/websocket/user-channel#order-message>
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct PolymarketUserOrder {
34    pub asset_id: Ustr,
35    pub associate_trades: Option<Vec<String>>,
36    pub created_at: String,
37    pub expiration: Option<String>,
38    pub id: String,
39    pub maker_address: Ustr,
40    pub market: Ustr,
41    pub order_owner: Ustr,
42    pub order_type: PolymarketOrderType,
43    pub original_size: String,
44    pub outcome: PolymarketOutcome,
45    pub owner: Ustr,
46    pub price: String,
47    pub side: PolymarketOrderSide,
48    pub size_matched: String,
49    pub status: PolymarketOrderStatus,
50    pub timestamp: String,
51    #[serde(rename = "type")]
52    pub event_type: PolymarketEventType,
53}
54
55/// A user trade update from the WebSocket user channel.
56///
57/// References: <https://docs.polymarket.com/developers/CLOB/websocket/user-channel>
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct PolymarketUserTrade {
60    pub asset_id: Ustr,
61    pub bucket_index: u64,
62    pub fee_rate_bps: String,
63    pub id: String,
64    pub last_update: String,
65    pub maker_address: Ustr,
66    pub maker_orders: Vec<PolymarketMakerOrder>,
67    pub market: Ustr,
68    pub match_time: String,
69    pub outcome: PolymarketOutcome,
70    pub owner: Ustr,
71    pub price: String,
72    pub side: PolymarketOrderSide,
73    pub size: String,
74    pub status: PolymarketTradeStatus,
75    pub taker_order_id: String,
76    pub timestamp: String,
77    pub trade_owner: Ustr,
78    pub trader_side: PolymarketLiquiditySide,
79    #[serde(rename = "type")]
80    pub event_type: PolymarketEventType,
81}
82
83/// A single price level in an order book snapshot.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PolymarketBookLevel {
86    pub price: String,
87    pub size: String,
88}
89
90/// An order book snapshot from the WebSocket market channel.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct PolymarketBookSnapshot {
93    pub market: Ustr,
94    pub asset_id: Ustr,
95    pub bids: Vec<PolymarketBookLevel>,
96    pub asks: Vec<PolymarketBookLevel>,
97    pub timestamp: String,
98}
99
100/// A single price change entry within a quotes message.
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PolymarketQuote {
103    pub asset_id: Ustr,
104    pub price: String,
105    pub side: PolymarketOrderSide,
106    pub size: String,
107    pub hash: String,
108    #[serde(default)]
109    pub best_bid: Option<String>,
110    #[serde(default)]
111    pub best_ask: Option<String>,
112}
113
114/// A price change (quotes) message from the WebSocket market channel.
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct PolymarketQuotes {
117    pub market: Ustr,
118    pub price_changes: Vec<PolymarketQuote>,
119    pub timestamp: String,
120}
121
122/// A last trade price message from the WebSocket market channel.
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct PolymarketTrade {
125    pub market: Ustr,
126    pub asset_id: Ustr,
127    pub fee_rate_bps: String,
128    pub price: String,
129    pub side: PolymarketOrderSide,
130    pub size: String,
131    pub timestamp: String,
132}
133
134/// A tick size change notification from the WebSocket market channel.
135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
136pub struct PolymarketTickSizeChange {
137    pub market: Ustr,
138    pub asset_id: Ustr,
139    pub new_tick_size: String,
140    pub old_tick_size: String,
141    pub timestamp: String,
142}
143
144/// Event metadata embedded in a new market notification.
145#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
146pub struct PolymarketNewMarketEvent {
147    pub id: String,
148    pub ticker: String,
149    pub slug: String,
150    pub title: String,
151    pub description: String,
152}
153
154/// A new market notification from the WebSocket market channel.
155///
156/// Only received when `subscribe_new_markets` is enabled.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct PolymarketNewMarket {
159    pub id: String,
160    pub question: String,
161    pub market: Ustr,
162    pub slug: String,
163    pub description: String,
164    pub assets_ids: Vec<String>,
165    pub outcomes: Vec<String>,
166    pub timestamp: String,
167    pub tags: Vec<String>,
168    pub condition_id: String,
169    pub active: bool,
170    pub clob_token_ids: Vec<String>,
171    #[serde(default)]
172    pub order_price_min_tick_size: Option<String>,
173    #[serde(default)]
174    pub group_item_title: Option<String>,
175    #[serde(default)]
176    pub event_message: Option<PolymarketNewMarketEvent>,
177}
178
179/// A market resolved notification from the WebSocket market channel.
180///
181/// Only received when `subscribe_new_markets` is enabled.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct PolymarketMarketResolved {
184    pub id: String,
185    pub market: Ustr,
186    pub assets_ids: Vec<String>,
187    pub winning_asset_id: String,
188    pub winning_outcome: String,
189    pub timestamp: String,
190    pub tags: Vec<String>,
191}
192
193/// A best bid/ask notification from the WebSocket market channel.
194///
195/// Only received when `subscribe_new_markets` is enabled.
196/// Data is already covered by existing PriceChange/Book handlers.
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
198pub struct PolymarketBestBidAsk {
199    pub market: Ustr,
200    pub asset_id: Ustr,
201    pub best_bid: String,
202    pub best_ask: String,
203    pub spread: String,
204    pub timestamp: String,
205}
206
207/// An envelope for tagged WebSocket market channel messages.
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(tag = "event_type")]
210pub enum MarketWsMessage {
211    #[serde(rename = "book")]
212    Book(PolymarketBookSnapshot),
213    #[serde(rename = "price_change")]
214    PriceChange(PolymarketQuotes),
215    #[serde(rename = "last_trade_price")]
216    LastTradePrice(PolymarketTrade),
217    #[serde(rename = "tick_size_change")]
218    TickSizeChange(PolymarketTickSizeChange),
219    #[serde(rename = "new_market")]
220    NewMarket(Box<PolymarketNewMarket>),
221    #[serde(rename = "market_resolved")]
222    MarketResolved(PolymarketMarketResolved),
223    #[serde(rename = "best_bid_ask")]
224    BestBidAsk(PolymarketBestBidAsk),
225}
226
227/// An envelope for tagged WebSocket user channel messages.
228#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(tag = "event_type")]
230pub enum UserWsMessage {
231    #[serde(rename = "order")]
232    Order(PolymarketUserOrder),
233    #[serde(rename = "trade")]
234    Trade(PolymarketUserTrade),
235}
236
237/// Output message type from the Polymarket WebSocket handler.
238#[derive(Debug)]
239pub enum PolymarketWsMessage {
240    Market(MarketWsMessage),
241    User(UserWsMessage),
242    /// Emitted when the underlying WebSocket reconnects.
243    Reconnected,
244}
245
246/// Auth payload embedded in user-channel subscribe messages.
247#[derive(Debug, Serialize)]
248pub struct PolymarketWsAuth {
249    #[serde(rename = "apiKey")]
250    pub api_key: String,
251    pub secret: String,
252    pub passphrase: String,
253}
254
255/// Initial market-channel subscribe request sent for a fresh WebSocket session.
256///
257/// Wire format: `{"assets_ids": [...], "type": "market"}`
258/// When `custom_feature_enabled` is true, enables new market and market resolved events.
259#[derive(Debug, Serialize)]
260pub struct MarketInitialSubscribeRequest {
261    pub assets_ids: Vec<String>,
262    #[serde(rename = "type")]
263    pub msg_type: &'static str,
264    #[serde(skip_serializing_if = "std::ops::Not::not")]
265    pub custom_feature_enabled: bool,
266}
267
268/// Incremental market-channel subscribe request sent after the initial session subscribe.
269///
270/// Wire format: `{"assets_ids": [...], "operation": "subscribe"}`
271/// When `custom_feature_enabled` is true, enables new market and market resolved events.
272#[derive(Debug, Serialize)]
273pub struct MarketSubscribeRequest {
274    pub assets_ids: Vec<String>,
275    pub operation: &'static str,
276    #[serde(skip_serializing_if = "std::ops::Not::not")]
277    pub custom_feature_enabled: bool,
278}
279
280/// Market-channel dynamic unsubscribe request sent during an active session.
281///
282/// Wire format: `{"assets_ids": [...], "operation": "unsubscribe"}`
283#[derive(Debug, Serialize)]
284pub struct MarketUnsubscribeRequest {
285    pub assets_ids: Vec<String>,
286    pub operation: &'static str,
287}
288
289/// User-channel subscribe request sent on connect.
290///
291/// Wire format: `{"auth": {...}, "markets": [], "assets_ids": [], "type": "user"}`
292#[derive(Debug, Serialize)]
293pub struct UserSubscribeRequest {
294    pub auth: PolymarketWsAuth,
295    pub markets: Vec<String>,
296    pub assets_ids: Vec<String>,
297    #[serde(rename = "type")]
298    pub msg_type: &'static str,
299}
300
301#[cfg(test)]
302mod tests {
303    use rstest::rstest;
304
305    use super::*;
306    use crate::common::enums::{
307        PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus,
308        PolymarketOrderType, PolymarketOutcome, PolymarketTradeStatus,
309    };
310
311    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
312        let path = format!("test_data/{filename}");
313        let content = std::fs::read_to_string(path).expect("Failed to read test data");
314        serde_json::from_str(&content).expect("Failed to parse test data")
315    }
316
317    #[rstest]
318    fn test_book_snapshot() {
319        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
320
321        assert_eq!(
322            snap.asset_id.as_str(),
323            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
324        );
325        assert_eq!(snap.bids.len(), 3);
326        assert_eq!(snap.asks.len(), 3);
327        assert_eq!(snap.bids[0].price, "0.48");
328        assert_eq!(snap.bids[0].size, "500.0");
329        assert_eq!(snap.asks[0].price, "0.53");
330        assert_eq!(snap.timestamp, "1703875200000");
331    }
332
333    #[rstest]
334    fn test_book_snapshot_roundtrip() {
335        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
336        let json = serde_json::to_string(&snap).unwrap();
337        let snap2: PolymarketBookSnapshot = serde_json::from_str(&json).unwrap();
338        assert_eq!(snap, snap2);
339    }
340
341    #[rstest]
342    fn test_quotes() {
343        let quotes: PolymarketQuotes = load("ws_quotes.json");
344
345        assert_eq!(quotes.price_changes.len(), 2);
346        assert_eq!(quotes.price_changes[0].side, PolymarketOrderSide::Buy);
347        assert_eq!(quotes.price_changes[0].price, "0.51");
348        assert_eq!(quotes.price_changes[0].best_bid.as_deref(), Some("0.51"));
349        assert_eq!(quotes.price_changes[0].best_ask.as_deref(), Some("0.52"));
350        assert_eq!(quotes.price_changes[1].side, PolymarketOrderSide::Sell);
351        assert_eq!(quotes.timestamp, "1703875201000");
352    }
353
354    #[rstest]
355    fn test_last_trade() {
356        let trade: PolymarketTrade = load("ws_last_trade.json");
357
358        assert_eq!(trade.price, "0.51");
359        assert_eq!(trade.size, "25.0");
360        assert_eq!(trade.side, PolymarketOrderSide::Buy);
361        assert_eq!(trade.fee_rate_bps, "0");
362        assert_eq!(trade.timestamp, "1703875202000");
363    }
364
365    #[rstest]
366    fn test_tick_size_change() {
367        let msg: PolymarketTickSizeChange = load("ws_tick_size_change.json");
368
369        assert_eq!(msg.new_tick_size, "0.01");
370        assert_eq!(msg.old_tick_size, "0.1");
371        assert_eq!(msg.timestamp, "1703875210000");
372    }
373
374    #[rstest]
375    fn test_user_order_placement() {
376        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
377
378        assert_eq!(order.event_type, PolymarketEventType::Placement);
379        assert_eq!(order.status, PolymarketOrderStatus::Live);
380        assert_eq!(order.side, PolymarketOrderSide::Buy);
381        assert_eq!(order.order_type, PolymarketOrderType::GTC);
382        assert_eq!(order.outcome, PolymarketOutcome::yes());
383        assert_eq!(order.original_size, "100.0");
384        assert_eq!(order.size_matched, "0.0");
385        assert!(order.associate_trades.is_none());
386        assert!(order.expiration.is_none());
387    }
388
389    #[rstest]
390    fn test_user_order_update() {
391        let order: PolymarketUserOrder = load("ws_user_order_update.json");
392
393        assert_eq!(order.event_type, PolymarketEventType::Update);
394        assert_eq!(order.size_matched, "25.0");
395        assert_eq!(
396            order.associate_trades.as_deref(),
397            Some(&["trade-0xabcdef1234".to_string()][..])
398        );
399    }
400
401    #[rstest]
402    fn test_user_order_cancellation() {
403        let order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
404
405        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
406        assert_eq!(order.status, PolymarketOrderStatus::Canceled);
407        assert_eq!(order.size_matched, "0.0");
408    }
409
410    #[rstest]
411    fn test_user_trade() {
412        let trade: PolymarketUserTrade = load("ws_user_trade.json");
413
414        assert_eq!(trade.event_type, PolymarketEventType::Trade);
415        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
416        assert_eq!(trade.side, PolymarketOrderSide::Buy);
417        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
418        assert_eq!(trade.price, "0.5");
419        assert_eq!(trade.size, "25.0");
420        assert_eq!(trade.fee_rate_bps, "0");
421        assert_eq!(trade.bucket_index, 1);
422        assert_eq!(trade.maker_orders.len(), 1);
423        assert_eq!(
424            trade.taker_order_id,
425            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
426        );
427    }
428
429    #[rstest]
430    fn test_market_ws_message_book() {
431        let msg: MarketWsMessage = load("ws_market_book_msg.json");
432
433        assert!(matches!(msg, MarketWsMessage::Book(_)));
434        if let MarketWsMessage::Book(snap) = msg {
435            assert_eq!(snap.bids.len(), 2);
436            assert_eq!(snap.asks.len(), 2);
437            assert_eq!(snap.timestamp, "1703875200000");
438        }
439    }
440
441    #[rstest]
442    fn test_market_ws_message_price_change() {
443        let msg: MarketWsMessage = load("ws_market_price_change_msg.json");
444
445        assert!(matches!(msg, MarketWsMessage::PriceChange(_)));
446        if let MarketWsMessage::PriceChange(quotes) = msg {
447            assert_eq!(quotes.price_changes.len(), 1);
448        }
449    }
450
451    #[rstest]
452    fn test_market_ws_message_last_trade_price() {
453        let msg: MarketWsMessage = load("ws_market_last_trade_msg.json");
454
455        assert!(matches!(msg, MarketWsMessage::LastTradePrice(_)));
456        if let MarketWsMessage::LastTradePrice(trade) = msg {
457            assert_eq!(trade.price, "0.51");
458        }
459    }
460
461    #[rstest]
462    fn test_market_ws_message_tick_size_change() {
463        let msg: MarketWsMessage = load("ws_market_tick_size_msg.json");
464
465        assert!(matches!(msg, MarketWsMessage::TickSizeChange(_)));
466        if let MarketWsMessage::TickSizeChange(change) = msg {
467            assert_eq!(change.new_tick_size, "0.01");
468            assert_eq!(change.old_tick_size, "0.1");
469        }
470    }
471
472    #[rstest]
473    fn test_user_ws_message_order() {
474        let msg: UserWsMessage = load("ws_user_order_msg.json");
475
476        assert!(matches!(msg, UserWsMessage::Order(_)));
477        if let UserWsMessage::Order(order) = msg {
478            assert_eq!(order.event_type, PolymarketEventType::Placement);
479            assert_eq!(order.side, PolymarketOrderSide::Buy);
480        }
481    }
482
483    #[rstest]
484    fn test_user_ws_message_trade() {
485        let msg: UserWsMessage = load("ws_user_trade_msg.json");
486
487        assert!(matches!(msg, UserWsMessage::Trade(_)));
488        if let UserWsMessage::Trade(trade) = msg {
489            assert_eq!(trade.event_type, PolymarketEventType::Trade);
490            assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
491        }
492    }
493
494    #[rstest]
495    fn test_market_ws_message_new_market() {
496        let msg: MarketWsMessage = load("ws_market_new_market_msg.json");
497
498        assert!(matches!(msg, MarketWsMessage::NewMarket(_)));
499        if let MarketWsMessage::NewMarket(nm) = msg {
500            assert_eq!(nm.id, "1031769");
501            assert_eq!(nm.slug, "nvda-above-240-on-january-30-2026");
502            assert_eq!(
503                nm.condition_id,
504                "0x311d0c4b6671ab54af4970c06fcf58662516f5168997bdda209ec3db5aa6b0c1"
505            );
506            assert!(nm.active);
507            assert_eq!(nm.outcomes.len(), 2);
508            assert_eq!(nm.clob_token_ids.len(), 2);
509            assert_eq!(nm.order_price_min_tick_size.as_deref(), Some("0.01"));
510
511            let event = nm
512                .event_message
513                .as_ref()
514                .expect("event_message should be parsed");
515            assert_eq!(event.id, "125819");
516            assert_eq!(event.ticker, "nvda-above-in-january-2026");
517            assert_eq!(event.slug, "nvda-above-in-january-2026");
518            assert_eq!(
519                event.title,
520                "Will NVIDIA (NVDA) close above ___ end of January?"
521            );
522        }
523    }
524
525    #[rstest]
526    fn test_market_ws_message_resolved() {
527        let msg: MarketWsMessage = load("ws_market_resolved_msg.json");
528
529        assert!(matches!(msg, MarketWsMessage::MarketResolved(_)));
530        if let MarketWsMessage::MarketResolved(mr) = msg {
531            assert_eq!(mr.id, "1031769");
532            assert_eq!(mr.winning_outcome, "Yes");
533            assert_eq!(mr.assets_ids.len(), 2);
534            assert_eq!(
535                mr.winning_asset_id,
536                "76043073756653678226373981964075571318267289248134717369284518995922789326425"
537            );
538        }
539    }
540
541    #[rstest]
542    fn test_market_ws_message_best_bid_ask() {
543        let msg: MarketWsMessage = load("ws_market_best_bid_ask_msg.json");
544
545        assert!(matches!(msg, MarketWsMessage::BestBidAsk(_)));
546        if let MarketWsMessage::BestBidAsk(bba) = msg {
547            assert_eq!(bba.best_bid, "0.73");
548            assert_eq!(bba.best_ask, "0.77");
549            assert_eq!(bba.spread, "0.04");
550        }
551    }
552}