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 nautilus_core::serialization::deserialize_empty_string_as_none;
19use rust_decimal::Decimal;
20use serde::{
21    Deserialize, Deserializer, Serialize, Serializer,
22    de::{
23        DeserializeSeed, MapAccess, Visitor,
24        value::{BorrowedStrDeserializer, MapAccessDeserializer},
25    },
26};
27use serde_json::value::RawValue;
28use ustr::Ustr;
29
30use crate::common::{
31    enums::{
32        PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus,
33        PolymarketOrderType, PolymarketOutcome, PolymarketTradeStatus,
34    },
35    models::PolymarketMakerOrder,
36    parse::{
37        deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
38        serialize_decimal_as_str, serialize_optional_decimal_as_str,
39    },
40};
41
42/// A user-channel order status and its optional venue reason suffix.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct PolymarketUserOrderStatus {
45    pub status: PolymarketOrderStatus,
46    pub reason: Option<String>,
47}
48
49impl PolymarketUserOrderStatus {
50    pub(crate) fn new(status: PolymarketOrderStatus, reason: Option<&str>) -> Self {
51        Self {
52            status,
53            reason: reason
54                .filter(|reason| !reason.trim().is_empty())
55                .map(str::to_string),
56        }
57    }
58}
59
60impl From<PolymarketOrderStatus> for PolymarketUserOrderStatus {
61    fn from(status: PolymarketOrderStatus) -> Self {
62        Self::new(status, None)
63    }
64}
65
66impl<'de> Deserialize<'de> for PolymarketUserOrderStatus {
67    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
68    where
69        D: Deserializer<'de>,
70    {
71        let raw = String::deserialize(deserializer)?;
72
73        PolymarketOrderStatus::parse_wire(&raw)
74            .map(|(status, reason)| Self::new(status, reason))
75            .ok_or_else(|| {
76                serde::de::Error::custom(format!("Unknown PolymarketOrderStatus: {raw}"))
77            })
78    }
79}
80
81impl Serialize for PolymarketUserOrderStatus {
82    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83    where
84        S: Serializer,
85    {
86        match self.reason.as_deref() {
87            Some(reason) => serializer.serialize_str(&format!("{}_{reason}", self.status)),
88            None => self.status.serialize(serializer),
89        }
90    }
91}
92
93/// A user order status update from the WebSocket user channel.
94///
95/// References: <https://docs.polymarket.com/developers/CLOB/websocket/user-channel#order-message>
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct PolymarketUserOrder {
98    pub asset_id: Ustr,
99    pub associate_trades: Option<Vec<String>>,
100    pub created_at: Option<String>,
101    pub expiration: Option<String>,
102    pub id: String,
103    pub maker_address: Option<Ustr>,
104    pub market: Ustr,
105    pub order_owner: Option<Ustr>,
106    pub order_type: Option<PolymarketOrderType>,
107    pub original_size: String,
108    pub outcome: Option<PolymarketOutcome>,
109    pub owner: Ustr,
110    pub price: String,
111    pub side: PolymarketOrderSide,
112    pub size_matched: String,
113    pub status: Option<PolymarketUserOrderStatus>,
114    pub timestamp: String,
115    #[serde(rename = "type")]
116    pub event_type: PolymarketEventType,
117}
118
119/// A user trade update from the WebSocket user channel.
120///
121/// References: <https://docs.polymarket.com/developers/CLOB/websocket/user-channel>
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct PolymarketUserTrade {
124    pub asset_id: Ustr,
125    pub bucket_index: u64,
126    pub fee_rate_bps: String,
127    pub id: String,
128    pub last_update: String,
129    pub maker_address: Ustr,
130    pub maker_orders: Vec<PolymarketMakerOrder>,
131    pub market: Ustr,
132    pub match_time: String,
133    pub outcome: PolymarketOutcome,
134    pub owner: Ustr,
135    pub price: String,
136    pub side: PolymarketOrderSide,
137    pub size: String,
138    pub status: PolymarketTradeStatus,
139    pub taker_order_id: String,
140    pub timestamp: String,
141    pub trade_owner: Ustr,
142    #[serde(
143        default,
144        deserialize_with = "deserialize_empty_string_as_none",
145        skip_serializing_if = "Option::is_none"
146    )]
147    pub transaction_hash: Option<String>,
148    pub trader_side: PolymarketLiquiditySide,
149    #[serde(rename = "type")]
150    pub event_type: PolymarketEventType,
151}
152
153/// A single price level in an order book snapshot.
154#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
155pub struct PolymarketBookLevel {
156    pub price: String,
157    pub size: String,
158}
159
160/// An order book snapshot from the WebSocket market channel.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct PolymarketBookSnapshot {
163    pub market: Ustr,
164    pub asset_id: Ustr,
165    pub bids: Vec<PolymarketBookLevel>,
166    pub asks: Vec<PolymarketBookLevel>,
167    pub timestamp: String,
168    #[serde(default)]
169    pub hash: Option<String>,
170    #[serde(default)]
171    pub min_order_size: Option<String>,
172    #[serde(default)]
173    pub tick_size: Option<String>,
174    #[serde(default)]
175    pub neg_risk: Option<bool>,
176    #[serde(default)]
177    pub last_trade_price: Option<String>,
178}
179
180/// A single price change entry within a quotes message.
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182pub struct PolymarketQuote {
183    pub asset_id: Ustr,
184    pub price: String,
185    pub side: PolymarketOrderSide,
186    pub size: String,
187    pub hash: String,
188    #[serde(default)]
189    pub best_bid: Option<String>,
190    #[serde(default)]
191    pub best_ask: Option<String>,
192}
193
194/// A price change (quotes) message from the WebSocket market channel.
195#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196pub struct PolymarketQuotes {
197    pub market: Ustr,
198    pub price_changes: Vec<PolymarketQuote>,
199    pub timestamp: String,
200}
201
202/// A last trade price message from the WebSocket market channel.
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204pub struct PolymarketTrade {
205    pub market: Ustr,
206    pub asset_id: Ustr,
207    pub fee_rate_bps: String,
208    pub price: String,
209    pub side: PolymarketOrderSide,
210    pub size: String,
211    pub timestamp: String,
212    #[serde(default)]
213    pub transaction_hash: Option<String>,
214}
215
216/// A tick size change notification from the WebSocket market channel.
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
218pub struct PolymarketTickSizeChange {
219    pub market: Ustr,
220    pub asset_id: Ustr,
221    pub new_tick_size: String,
222    pub old_tick_size: String,
223    pub timestamp: String,
224}
225
226/// Event metadata embedded in a new market notification.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub struct PolymarketNewMarketEvent {
229    pub id: String,
230    pub ticker: String,
231    pub slug: String,
232    pub title: String,
233    pub description: String,
234}
235
236/// Fee configuration observed in a new market notification.
237#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
238pub struct PolymarketNewMarketFeeSchedule {
239    #[serde(
240        serialize_with = "serialize_decimal_as_str",
241        deserialize_with = "deserialize_decimal_from_str"
242    )]
243    pub exponent: Decimal,
244    #[serde(
245        serialize_with = "serialize_decimal_as_str",
246        deserialize_with = "deserialize_decimal_from_str"
247    )]
248    pub rate: Decimal,
249    pub taker_only: bool,
250    #[serde(
251        serialize_with = "serialize_decimal_as_str",
252        deserialize_with = "deserialize_decimal_from_str"
253    )]
254    pub rebate_rate: Decimal,
255}
256
257/// A new market notification from the WebSocket market channel.
258///
259/// Only received when `subscribe_new_markets` is enabled.
260#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
261pub struct PolymarketNewMarket {
262    pub id: String,
263    pub question: String,
264    pub market: Ustr,
265    pub slug: String,
266    pub description: String,
267    pub assets_ids: Vec<String>,
268    pub outcomes: Vec<String>,
269    pub timestamp: String,
270    pub tags: Vec<String>,
271    pub condition_id: String,
272    pub active: bool,
273    pub clob_token_ids: Vec<String>,
274    #[serde(default)]
275    pub order_price_min_tick_size: Option<String>,
276    #[serde(default)]
277    pub group_item_title: Option<String>,
278    #[serde(default)]
279    pub event_message: Option<PolymarketNewMarketEvent>,
280    #[serde(default)]
281    pub sports_market_type: Option<String>,
282    #[serde(default)]
283    pub line: Option<String>,
284    #[serde(default)]
285    pub game_start_time: Option<String>,
286    #[serde(
287        default,
288        skip_serializing_if = "Option::is_none",
289        serialize_with = "serialize_optional_decimal_as_str",
290        deserialize_with = "deserialize_optional_decimal_from_str"
291    )]
292    pub taker_base_fee: Option<Decimal>,
293    #[serde(default)]
294    pub fees_enabled: Option<bool>,
295    #[serde(default)]
296    pub fee_schedule: Option<PolymarketNewMarketFeeSchedule>,
297}
298
299/// A market resolved notification from the WebSocket market channel.
300///
301/// Only received when `subscribe_new_markets` is enabled.
302#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
303pub struct PolymarketMarketResolved {
304    pub id: String,
305    pub market: Ustr,
306    pub assets_ids: Vec<String>,
307    pub winning_asset_id: String,
308    pub winning_outcome: String,
309    pub timestamp: String,
310    pub tags: Vec<String>,
311}
312
313/// A best bid/ask notification from the WebSocket market channel.
314///
315/// Only received when `subscribe_new_markets` is enabled.
316/// The data adapter emits these events as quote ticks for active quote subscriptions.
317#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
318pub struct PolymarketBestBidAsk {
319    pub market: Ustr,
320    pub asset_id: Ustr,
321    pub best_bid: String,
322    pub best_ask: String,
323    pub spread: String,
324    pub timestamp: String,
325}
326
327/// An envelope for tagged WebSocket market channel messages.
328#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(tag = "event_type")]
330pub enum MarketWsMessage {
331    #[serde(rename = "book")]
332    Book(PolymarketBookSnapshot),
333    #[serde(rename = "price_change")]
334    PriceChange(PolymarketQuotes),
335    #[serde(rename = "last_trade_price")]
336    LastTradePrice(PolymarketTrade),
337    #[serde(rename = "tick_size_change")]
338    TickSizeChange(PolymarketTickSizeChange),
339    #[serde(rename = "new_market")]
340    NewMarket(Box<PolymarketNewMarket>),
341    #[serde(rename = "market_resolved")]
342    MarketResolved(PolymarketMarketResolved),
343    #[serde(rename = "best_bid_ask")]
344    BestBidAsk(PolymarketBestBidAsk),
345}
346
347struct PayloadMapAccess<A> {
348    inner: A,
349}
350
351impl<A> PayloadMapAccess<A> {
352    const fn new(inner: A) -> Self {
353        Self { inner }
354    }
355}
356
357impl<'de, A> MapAccess<'de> for PayloadMapAccess<A>
358where
359    A: MapAccess<'de>,
360{
361    type Error = A::Error;
362
363    fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
364    where
365        K: DeserializeSeed<'de>,
366    {
367        let Some(key) = self.inner.next_key::<&'de str>()? else {
368            return Ok(None);
369        };
370
371        if key == "event_type" {
372            return Err(serde::de::Error::duplicate_field("event_type"));
373        }
374
375        seed.deserialize(BorrowedStrDeserializer::new(key))
376            .map(Some)
377    }
378
379    fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
380    where
381        V: DeserializeSeed<'de>,
382    {
383        self.inner.next_value_seed(seed)
384    }
385
386    fn size_hint(&self) -> Option<usize> {
387        self.inner.size_hint()
388    }
389}
390
391struct MarketWsMessageVisitor;
392
393impl<'de> Visitor<'de> for MarketWsMessageVisitor {
394    type Value = MarketWsMessage;
395
396    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        formatter.write_str("a Polymarket market-channel message with event_type first")
398    }
399
400    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
401    where
402        A: MapAccess<'de>,
403    {
404        let Some(key) = map.next_key::<&str>()? else {
405            return Err(serde::de::Error::custom("expected event_type field"));
406        };
407
408        if key != "event_type" {
409            return Err(serde::de::Error::custom(
410                "event_type was not the first field",
411            ));
412        }
413
414        let event_type = map.next_value::<&str>()?;
415        let remaining = MapAccessDeserializer::new(PayloadMapAccess::new(map));
416        match event_type {
417            "book" => PolymarketBookSnapshot::deserialize(remaining).map(Self::Value::Book),
418            "price_change" => {
419                PolymarketQuotes::deserialize(remaining).map(Self::Value::PriceChange)
420            }
421            "last_trade_price" => {
422                PolymarketTrade::deserialize(remaining).map(Self::Value::LastTradePrice)
423            }
424            "tick_size_change" => {
425                PolymarketTickSizeChange::deserialize(remaining).map(Self::Value::TickSizeChange)
426            }
427            "new_market" => PolymarketNewMarket::deserialize(remaining)
428                .map(Box::new)
429                .map(Self::Value::NewMarket),
430            "market_resolved" => {
431                PolymarketMarketResolved::deserialize(remaining).map(Self::Value::MarketResolved)
432            }
433            "best_bid_ask" => {
434                PolymarketBestBidAsk::deserialize(remaining).map(Self::Value::BestBidAsk)
435            }
436            other => Err(serde::de::Error::unknown_variant(
437                other,
438                &[
439                    "book",
440                    "price_change",
441                    "last_trade_price",
442                    "tick_size_change",
443                    "new_market",
444                    "market_resolved",
445                    "best_bid_ask",
446                ],
447            )),
448        }
449    }
450}
451
452impl MarketWsMessage {
453    /// Parses a market-channel JSON message.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`serde_json::Error`] when `text` is not a valid market message.
458    pub fn parse(text: &str) -> serde_json::Result<Self> {
459        let mut deserializer = serde_json::Deserializer::from_str(text);
460        serde::Deserializer::deserialize_map(&mut deserializer, MarketWsMessageVisitor)
461            .and_then(|message| {
462                deserializer.end()?;
463                Ok(message)
464            })
465            .or_else(|_| Self::parse_reordered(text))
466            .or_else(|_| serde_json::from_str(text))
467    }
468
469    fn parse_reordered(text: &str) -> serde_json::Result<Self> {
470        let tag = serde_json::from_str::<MarketWsTag>(text)?;
471        match tag.event_type {
472            MarketWsEventTag::Book => serde_json::from_str(text).map(Self::Book),
473            MarketWsEventTag::PriceChange => serde_json::from_str(text).map(Self::PriceChange),
474            MarketWsEventTag::LastTradePrice => {
475                serde_json::from_str(text).map(Self::LastTradePrice)
476            }
477            MarketWsEventTag::TickSizeChange => {
478                serde_json::from_str(text).map(Self::TickSizeChange)
479            }
480            MarketWsEventTag::NewMarket => serde_json::from_str(text)
481                .map(Box::new)
482                .map(Self::NewMarket),
483            MarketWsEventTag::MarketResolved => {
484                serde_json::from_str(text).map(Self::MarketResolved)
485            }
486            MarketWsEventTag::BestBidAsk => serde_json::from_str(text).map(Self::BestBidAsk),
487        }
488    }
489}
490
491#[derive(Deserialize)]
492#[serde(rename_all = "snake_case")]
493enum MarketWsEventTag {
494    Book,
495    PriceChange,
496    LastTradePrice,
497    TickSizeChange,
498    NewMarket,
499    MarketResolved,
500    BestBidAsk,
501}
502
503#[derive(Deserialize)]
504struct MarketWsTag {
505    event_type: MarketWsEventTag,
506}
507
508/// An envelope for tagged WebSocket user channel messages.
509#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(tag = "event_type")]
511pub enum UserWsMessage {
512    #[serde(rename = "order")]
513    Order(PolymarketUserOrder),
514    #[serde(rename = "trade")]
515    Trade(PolymarketUserTrade),
516}
517
518struct UserWsMessageVisitor;
519
520impl<'de> Visitor<'de> for UserWsMessageVisitor {
521    type Value = UserWsMessage;
522
523    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        formatter.write_str("a Polymarket user-channel message with event_type first")
525    }
526
527    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
528    where
529        A: MapAccess<'de>,
530    {
531        let Some(key) = map.next_key::<&str>()? else {
532            return Err(serde::de::Error::custom("expected event_type field"));
533        };
534
535        if key != "event_type" {
536            return Err(serde::de::Error::custom(
537                "event_type was not the first field",
538            ));
539        }
540
541        let event_type = map.next_value::<&str>()?;
542        let remaining = MapAccessDeserializer::new(PayloadMapAccess::new(map));
543        match event_type {
544            "order" => PolymarketUserOrder::deserialize(remaining).map(Self::Value::Order),
545            "trade" => PolymarketUserTrade::deserialize(remaining).map(Self::Value::Trade),
546            other => Err(serde::de::Error::unknown_variant(
547                other,
548                &["order", "trade"],
549            )),
550        }
551    }
552}
553
554impl UserWsMessage {
555    /// Parses a user-channel JSON message.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`serde_json::Error`] when `text` is not a valid user message.
560    pub fn parse(text: &str) -> serde_json::Result<Self> {
561        let mut deserializer = serde_json::Deserializer::from_str(text);
562        serde::Deserializer::deserialize_map(&mut deserializer, UserWsMessageVisitor)
563            .and_then(|message| {
564                deserializer.end()?;
565                Ok(message)
566            })
567            .or_else(|_| Self::parse_reordered(text))
568            .or_else(|_| serde_json::from_str(text))
569    }
570
571    /// Parses a batch of user-channel JSON messages.
572    ///
573    /// Elements carrying an unrecognized `event_type` are skipped so that a single unknown
574    /// message cannot discard the valid `order` and `trade` messages batched alongside it.
575    ///
576    /// # Errors
577    ///
578    /// Returns [`serde_json::Error`] when `text` is not a valid user-message batch.
579    pub fn parse_batch(text: &str) -> serde_json::Result<Vec<Self>> {
580        /// Reads only the tag, to classify an element before deserializing it.
581        #[derive(Deserialize)]
582        struct EventTypeTag {
583            event_type: Option<String>,
584        }
585
586        // Elements stay raw so the derived impl parses each one and rejects a duplicated
587        // `event_type`; `serde_json::Value` would silently keep the last occurrence.
588        let elements: Vec<&RawValue> = serde_json::from_str(text)?;
589        let mut messages = Vec::with_capacity(elements.len());
590        let mut skipped = 0usize;
591
592        for element in elements {
593            let tag: EventTypeTag = serde_json::from_str(element.get())?;
594            match tag.event_type.as_deref() {
595                Some(event_type) if !matches!(event_type, "order" | "trade") => skipped += 1,
596                _ => messages.push(serde_json::from_str(element.get())?),
597            }
598        }
599
600        if skipped > 0 {
601            log::debug!("Skipped {skipped} user WS message(s) with an unrecognized event_type");
602        }
603
604        Ok(messages)
605    }
606
607    fn parse_reordered(text: &str) -> serde_json::Result<Self> {
608        let tag = serde_json::from_str::<UserWsTag>(text)?;
609        match tag.event_type {
610            UserWsEventTag::Order => serde_json::from_str(text).map(Self::Order),
611            UserWsEventTag::Trade => serde_json::from_str(text).map(Self::Trade),
612        }
613    }
614}
615
616#[derive(Deserialize)]
617#[serde(rename_all = "snake_case")]
618enum UserWsEventTag {
619    Order,
620    Trade,
621}
622
623#[derive(Deserialize)]
624struct UserWsTag {
625    event_type: UserWsEventTag,
626}
627
628/// Output message type from the Polymarket WebSocket handler.
629#[derive(Debug)]
630pub enum PolymarketWsMessage {
631    Market(MarketWsMessage),
632    User(UserWsMessage),
633    /// Emitted when the underlying WebSocket reconnects.
634    Reconnected,
635}
636
637/// Auth payload embedded in user-channel subscribe messages.
638#[derive(Debug, Serialize)]
639pub struct PolymarketWsAuth {
640    #[serde(rename = "apiKey")]
641    pub api_key: String,
642    pub secret: String,
643    pub passphrase: String,
644}
645
646/// Initial market-channel subscribe request sent for a fresh WebSocket session.
647///
648/// Wire format: `{"assets_ids": [...], "type": "market", "initial_dump": true}`
649/// When `custom_feature_enabled` is true, enables new-market, market-resolved, and best-bid/ask
650/// events.
651#[derive(Debug, Serialize)]
652pub struct MarketInitialSubscribeRequest {
653    pub assets_ids: Vec<String>,
654    #[serde(rename = "type")]
655    pub msg_type: &'static str,
656    pub initial_dump: bool,
657    #[serde(skip_serializing_if = "std::ops::Not::not")]
658    pub custom_feature_enabled: bool,
659}
660
661/// Incremental market-channel subscribe request sent after the initial session subscribe.
662///
663/// Wire format: `{"assets_ids": [...], "operation": "subscribe", "initial_dump": true}`
664/// When `custom_feature_enabled` is true, enables new-market, market-resolved, and best-bid/ask
665/// events.
666#[derive(Debug, Serialize)]
667pub struct MarketSubscribeRequest {
668    pub assets_ids: Vec<String>,
669    pub operation: &'static str,
670    pub initial_dump: bool,
671    #[serde(skip_serializing_if = "std::ops::Not::not")]
672    pub custom_feature_enabled: bool,
673}
674
675/// Market-channel dynamic unsubscribe request sent during an active session.
676///
677/// Wire format: `{"assets_ids": [...], "operation": "unsubscribe"}`
678#[derive(Debug, Serialize)]
679pub struct MarketUnsubscribeRequest {
680    pub assets_ids: Vec<String>,
681    pub operation: &'static str,
682}
683
684/// User-channel subscribe request sent on connect.
685///
686/// Wire format: `{"auth": {...}, "type": "user"}`
687#[derive(Debug, Serialize)]
688pub struct UserSubscribeRequest {
689    pub auth: PolymarketWsAuth,
690    #[serde(rename = "type")]
691    pub msg_type: &'static str,
692}
693
694#[cfg(test)]
695mod tests {
696    use rstest::rstest;
697
698    use super::*;
699    use crate::common::enums::{
700        PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus,
701        PolymarketOrderType, PolymarketOutcome, PolymarketTradeStatus,
702    };
703
704    #[rstest]
705    fn user_subscribe_request_matches_all_markets_wire_format() {
706        let request = UserSubscribeRequest {
707            auth: PolymarketWsAuth {
708                api_key: "fixture-key".to_string(),
709                secret: "fixture-secret".to_string(),
710                passphrase: "fixture-passphrase".to_string(),
711            },
712            msg_type: "user",
713        };
714
715        assert_eq!(
716            serde_json::to_value(request).unwrap(),
717            serde_json::json!({
718                "auth": {
719                    "apiKey": "fixture-key",
720                    "secret": "fixture-secret",
721                    "passphrase": "fixture-passphrase",
722                },
723                "type": "user",
724            }),
725        );
726    }
727
728    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
729        let path = format!("test_data/{filename}");
730        let content = std::fs::read_to_string(path).expect("Failed to read test data");
731        serde_json::from_str(&content).expect("Failed to parse test data")
732    }
733
734    fn load_text(filename: &str) -> String {
735        let path = format!("test_data/{filename}");
736        std::fs::read_to_string(path).expect("Failed to read test data")
737    }
738
739    /// An `auto_redeem` user-channel event as observed from the venue, which is undocumented and
740    /// not modelled by [`UserWsMessage`].
741    fn auto_redeem_element() -> serde_json::Value {
742        serde_json::json!({
743            "event_type": "auto_redeem",
744            "proxy_wallet": "0x0000000000000000000000000000000000000000",
745            "txn_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
746            "amount": "70",
747            "condition_id": "0xb88862256916cb0c82e72667bd43f3e6cfe94cd7d5cda0bf763ec82394021e07",
748            "question": "Bitcoin Up or Down - August 17, 9:35AM-9:40AM ET",
749            "slug": "btc-updown-5m-1786973700",
750            "neg_risk": false,
751            "timestamp": "1786974414920",
752            "position_id": "",
753            "outcome_index": 0,
754            "legs": 0,
755            "owner": "00000000-0000-0000-0000-000000000000",
756        })
757    }
758
759    #[rstest]
760    fn test_book_snapshot() {
761        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
762
763        assert_eq!(
764            snap.asset_id.as_str(),
765            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
766        );
767        assert_eq!(snap.bids.len(), 3);
768        assert_eq!(snap.asks.len(), 3);
769        assert_eq!(snap.bids[0].price, "0.48");
770        assert_eq!(snap.bids[0].size, "500.0");
771        assert_eq!(snap.asks[0].price, "0.53");
772        assert_eq!(snap.timestamp, "1703875200000");
773        assert!(snap.hash.is_none());
774        assert!(snap.min_order_size.is_none());
775        assert!(snap.tick_size.is_none());
776        assert!(snap.neg_risk.is_none());
777        assert!(snap.last_trade_price.is_none());
778    }
779
780    #[rstest]
781    fn test_book_snapshot_roundtrip() {
782        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
783        let json = serde_json::to_string(&snap).unwrap();
784        let snap2: PolymarketBookSnapshot = serde_json::from_str(&json).unwrap();
785        assert_eq!(snap, snap2);
786    }
787
788    #[rstest]
789    fn test_quotes() {
790        let quotes: PolymarketQuotes = load("ws_quotes.json");
791
792        assert_eq!(quotes.price_changes.len(), 2);
793        assert_eq!(quotes.price_changes[0].side, PolymarketOrderSide::Buy);
794        assert_eq!(quotes.price_changes[0].price, "0.51");
795        assert_eq!(quotes.price_changes[0].best_bid.as_deref(), Some("0.51"));
796        assert_eq!(quotes.price_changes[0].best_ask.as_deref(), Some("0.52"));
797        assert_eq!(quotes.price_changes[1].side, PolymarketOrderSide::Sell);
798        assert_eq!(quotes.timestamp, "1703875201000");
799    }
800
801    #[rstest]
802    fn test_last_trade() {
803        let trade: PolymarketTrade = load("ws_last_trade.json");
804
805        assert_eq!(trade.price, "0.51");
806        assert_eq!(trade.size, "25.0");
807        assert_eq!(trade.side, PolymarketOrderSide::Buy);
808        assert_eq!(trade.fee_rate_bps, "0");
809        assert_eq!(trade.timestamp, "1703875202000");
810        assert_eq!(
811            trade.transaction_hash.as_deref(),
812            Some("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab")
813        );
814    }
815
816    #[rstest]
817    fn test_optional_market_hash_fields_default() {
818        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_missing_hash.json");
819        let trade: PolymarketTrade = load("ws_last_trade_missing_transaction_hash.json");
820
821        assert!(snap.hash.is_none());
822        assert!(snap.min_order_size.is_none());
823        assert!(snap.tick_size.is_none());
824        assert!(snap.neg_risk.is_none());
825        assert!(snap.last_trade_price.is_none());
826        assert!(trade.transaction_hash.is_none());
827    }
828
829    #[rstest]
830    fn test_tick_size_change() {
831        let msg: PolymarketTickSizeChange = load("ws_tick_size_change.json");
832
833        assert_eq!(msg.new_tick_size, "0.01");
834        assert_eq!(msg.old_tick_size, "0.1");
835        assert_eq!(msg.timestamp, "1703875210000");
836    }
837
838    #[rstest]
839    fn test_user_order_placement() {
840        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
841
842        assert_eq!(order.event_type, PolymarketEventType::Placement);
843        assert_eq!(
844            order.status.as_ref().map(|status| status.status),
845            Some(PolymarketOrderStatus::Live)
846        );
847        assert_eq!(order.side, PolymarketOrderSide::Buy);
848        assert_eq!(order.order_type, Some(PolymarketOrderType::GTC));
849        assert_eq!(order.outcome, Some(PolymarketOutcome::yes()));
850        assert_eq!(order.original_size, "100.0");
851        assert_eq!(order.size_matched, "0.0");
852        assert!(order.associate_trades.is_none());
853        assert!(order.expiration.is_none());
854    }
855
856    #[rstest]
857    fn test_user_order_update() {
858        let order: PolymarketUserOrder = load("ws_user_order_update.json");
859
860        assert_eq!(order.event_type, PolymarketEventType::Update);
861        assert_eq!(order.size_matched, "25.0");
862        assert_eq!(
863            order.associate_trades.as_deref(),
864            Some(&["trade-0xabcdef1234".to_string()][..])
865        );
866    }
867
868    #[rstest]
869    fn test_user_order_cancellation() {
870        let order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
871
872        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
873        assert_eq!(
874            order.status.as_ref().map(|status| status.status),
875            Some(PolymarketOrderStatus::Canceled)
876        );
877        assert_eq!(order.size_matched, "0.0");
878    }
879
880    #[rstest]
881    fn test_user_order_status_preserves_rejection_reason() {
882        let raw = "UNMATCHED_invalid post-only order: order crosses book";
883        let status: PolymarketUserOrderStatus =
884            serde_json::from_str(&format!("\"{raw}\"")).unwrap();
885
886        assert_eq!(status.status, PolymarketOrderStatus::Unmatched);
887        assert_eq!(
888            status.reason.as_deref(),
889            Some("invalid post-only order: order crosses book")
890        );
891        assert_eq!(
892            serde_json::to_string(&status).unwrap(),
893            format!("\"{raw}\"")
894        );
895    }
896
897    /// Repro for issue #3987: venue cancels a FOK order with a status field
898    /// containing a trailing reason ("CANCELED_<reason>") and empty fields
899    /// on `size_matched`, `outcome`, and `created_at`.
900    #[rstest]
901    fn test_user_order_fok_killed() {
902        let msg: UserWsMessage = load("ws_user_order_fok_killed.json");
903
904        let UserWsMessage::Order(order) = msg else {
905            panic!("Expected UserWsMessage::Order");
906        };
907        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
908        assert_eq!(
909            order.status.as_ref().map(|status| status.status),
910            Some(PolymarketOrderStatus::Canceled)
911        );
912        assert_eq!(
913            order
914                .status
915                .as_ref()
916                .and_then(|status| status.reason.as_deref()),
917            Some("order couldn't be fully filled. FOK orders are fully filled or killed.")
918        );
919        assert_eq!(order.order_type, Some(PolymarketOrderType::FOK));
920        assert_eq!(order.size_matched, "");
921        assert_eq!(order.created_at.as_deref(), Some(""));
922        assert_eq!(
923            order.outcome.as_ref().map(PolymarketOutcome::as_str),
924            Some("")
925        );
926    }
927
928    #[rstest]
929    fn test_user_trade() {
930        let trade: PolymarketUserTrade = load("ws_user_trade.json");
931
932        assert_eq!(trade.event_type, PolymarketEventType::Trade);
933        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
934        assert_eq!(trade.side, PolymarketOrderSide::Buy);
935        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
936        assert_eq!(trade.price, "0.5");
937        assert_eq!(trade.size, "25.0");
938        assert_eq!(trade.fee_rate_bps, "0");
939        assert_eq!(trade.bucket_index, 1);
940        assert_eq!(trade.maker_orders.len(), 1);
941        assert_eq!(
942            trade.transaction_hash.as_deref(),
943            Some("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab")
944        );
945        assert_eq!(
946            trade.taker_order_id,
947            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
948        );
949    }
950
951    #[rstest]
952    fn test_user_trade_missing_transaction_hash() {
953        let mut value: serde_json::Value = load("ws_user_trade.json");
954        value
955            .as_object_mut()
956            .expect("trade fixture should be an object")
957            .remove("transaction_hash");
958
959        let trade: PolymarketUserTrade =
960            serde_json::from_value(value).expect("trade fixture should deserialize");
961
962        assert!(trade.transaction_hash.is_none());
963    }
964
965    #[rstest]
966    fn test_market_ws_message_book() {
967        let msg: MarketWsMessage = load("ws_market_book_msg.json");
968
969        assert!(matches!(msg, MarketWsMessage::Book(_)));
970        if let MarketWsMessage::Book(snap) = msg {
971            assert_eq!(snap.bids.len(), 2);
972            assert_eq!(snap.asks.len(), 2);
973            assert_eq!(snap.timestamp, "1703875200000");
974        }
975    }
976
977    #[rstest]
978    #[case("ws_market_book_msg.json")]
979    #[case("ws_market_price_change_msg.json")]
980    #[case("ws_market_last_trade_msg.json")]
981    #[case("ws_market_tick_size_msg.json")]
982    #[case("ws_market_new_market_msg.json")]
983    #[case("ws_market_resolved_msg.json")]
984    #[case("ws_market_best_bid_ask_msg.json")]
985    fn test_market_ws_message_parse(#[case] filename: &str) {
986        let text = load_text(filename);
987        let expected: MarketWsMessage =
988            serde_json::from_str(&text).expect("market fixture should deserialize");
989
990        let actual = MarketWsMessage::parse(&text).expect("market fixture should parse");
991
992        assert_eq!(actual, expected);
993    }
994
995    #[rstest]
996    fn test_market_ws_message_parse_with_reordered_event_type() {
997        let expected: MarketWsMessage = load("ws_market_book_msg.json");
998        let mut value: serde_json::Value = load("ws_market_book_msg.json");
999        let object = value
1000            .as_object_mut()
1001            .expect("market fixture should be an object");
1002        let event_type = object
1003            .remove("event_type")
1004            .expect("market fixture should contain event_type");
1005        object.insert("event_type".to_string(), event_type);
1006        let text = serde_json::to_string(&value).expect("market fixture should serialize");
1007
1008        assert!(!text.starts_with(r#"{"event_type":"#));
1009        assert_eq!(
1010            MarketWsMessage::parse(&text).expect("reordered market fixture should parse"),
1011            expected
1012        );
1013    }
1014
1015    #[rstest]
1016    fn test_market_ws_message_parse_rejects_duplicate_event_type() {
1017        let text = load_text("ws_market_book_msg.json").replacen(
1018            r#""event_type": "book","#,
1019            r#""event_type": "book", "event_type": "book","#,
1020            1,
1021        );
1022        let expected = serde_json::from_str::<MarketWsMessage>(&text)
1023            .expect_err("derived parser should reject a duplicate event_type");
1024        let actual = MarketWsMessage::parse(&text)
1025            .expect_err("optimized parser should reject a duplicate event_type");
1026
1027        assert_eq!(actual.to_string(), expected.to_string());
1028    }
1029
1030    #[rstest]
1031    fn test_market_ws_message_price_change() {
1032        let msg: MarketWsMessage = load("ws_market_price_change_msg.json");
1033
1034        assert!(matches!(msg, MarketWsMessage::PriceChange(_)));
1035        if let MarketWsMessage::PriceChange(quotes) = msg {
1036            assert_eq!(quotes.price_changes.len(), 1);
1037        }
1038    }
1039
1040    #[rstest]
1041    fn test_market_ws_message_last_trade_price() {
1042        let msg: MarketWsMessage = load("ws_market_last_trade_msg.json");
1043
1044        assert!(matches!(msg, MarketWsMessage::LastTradePrice(_)));
1045        if let MarketWsMessage::LastTradePrice(trade) = msg {
1046            assert_eq!(trade.price, "0.51");
1047        }
1048    }
1049
1050    #[rstest]
1051    fn test_market_ws_message_tick_size_change() {
1052        let msg: MarketWsMessage = load("ws_market_tick_size_msg.json");
1053
1054        assert!(matches!(msg, MarketWsMessage::TickSizeChange(_)));
1055        if let MarketWsMessage::TickSizeChange(change) = msg {
1056            assert_eq!(change.new_tick_size, "0.01");
1057            assert_eq!(change.old_tick_size, "0.1");
1058        }
1059    }
1060
1061    #[rstest]
1062    fn test_user_ws_message_order() {
1063        let msg: UserWsMessage = load("ws_user_order_msg.json");
1064
1065        let UserWsMessage::Order(order) = msg else {
1066            panic!("expected order message");
1067        };
1068        assert_eq!(
1069            order.asset_id.as_str(),
1070            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1071        );
1072        assert_eq!(order.associate_trades, Some(Vec::new()));
1073        assert_eq!(order.created_at.as_deref(), Some(""));
1074        assert_eq!(order.expiration.as_deref(), Some("0"));
1075        assert_eq!(
1076            order.id,
1077            "0x1111111111111111111111111111111111111111111111111111111111111111"
1078        );
1079        assert_eq!(
1080            order.maker_address.as_ref().map(Ustr::as_str),
1081            Some("0x1111111111111111111111111111111111111111")
1082        );
1083        assert_eq!(
1084            order.market.as_str(),
1085            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1086        );
1087        assert_eq!(
1088            order.order_owner.as_ref().map(Ustr::as_str),
1089            Some("11111111-2222-3333-4444-555555555555")
1090        );
1091        assert_eq!(order.order_type, Some(PolymarketOrderType::FOK));
1092        assert_eq!(order.original_size, "1");
1093        assert_eq!(
1094            order.outcome.as_ref().map(PolymarketOutcome::as_str),
1095            Some("")
1096        );
1097        assert_eq!(order.owner.as_str(), "11111111-2222-3333-4444-555555555555");
1098        assert_eq!(order.price, "0.01");
1099        assert_eq!(order.side, PolymarketOrderSide::Buy);
1100        assert_eq!(order.size_matched, "");
1101        assert_eq!(
1102            order.status.as_ref().map(|status| status.status),
1103            Some(PolymarketOrderStatus::Canceled)
1104        );
1105        assert_eq!(order.timestamp, "1786179547007");
1106        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
1107    }
1108
1109    #[rstest]
1110    fn test_user_ws_message_order_optional_fields_absent() {
1111        // Constructed from the documented required field set
1112        let json = r#"{
1113            "event_type":"order",
1114            "id":"order-1",
1115            "owner":"owner-1",
1116            "market":"market-1",
1117            "asset_id":"asset-1",
1118            "side":"SELL",
1119            "original_size":"2",
1120            "size_matched":"0",
1121            "price":"0.5",
1122            "type":"PLACEMENT",
1123            "timestamp":"1786179547008"
1124        }"#;
1125        let UserWsMessage::Order(order) = serde_json::from_str(json).unwrap() else {
1126            panic!("expected order message");
1127        };
1128
1129        assert_eq!(order.asset_id.as_str(), "asset-1");
1130        assert!(order.associate_trades.is_none());
1131        assert!(order.created_at.is_none());
1132        assert!(order.expiration.is_none());
1133        assert_eq!(order.id, "order-1");
1134        assert!(order.maker_address.is_none());
1135        assert_eq!(order.market.as_str(), "market-1");
1136        assert!(order.order_owner.is_none());
1137        assert!(order.order_type.is_none());
1138        assert_eq!(order.original_size, "2");
1139        assert!(order.outcome.is_none());
1140        assert_eq!(order.owner.as_str(), "owner-1");
1141        assert_eq!(order.price, "0.5");
1142        assert_eq!(order.side, PolymarketOrderSide::Sell);
1143        assert_eq!(order.size_matched, "0");
1144        assert!(order.status.is_none());
1145        assert_eq!(order.timestamp, "1786179547008");
1146        assert_eq!(order.event_type, PolymarketEventType::Placement);
1147    }
1148
1149    #[rstest]
1150    fn test_user_ws_message_trade() {
1151        let msg: UserWsMessage = load("ws_user_trade_msg.json");
1152
1153        assert!(matches!(msg, UserWsMessage::Trade(_)));
1154        if let UserWsMessage::Trade(trade) = msg {
1155            assert_eq!(trade.event_type, PolymarketEventType::Trade);
1156            assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
1157            assert!(trade.transaction_hash.is_none());
1158        }
1159    }
1160
1161    #[rstest]
1162    #[case("ws_user_order_msg.json")]
1163    #[case("ws_user_order_fok_killed.json")]
1164    #[case("ws_user_trade_msg.json")]
1165    fn test_user_ws_message_parse(#[case] filename: &str) {
1166        let text = load_text(filename);
1167        let expected: UserWsMessage =
1168            serde_json::from_str(&text).expect("user fixture should deserialize");
1169
1170        let actual = UserWsMessage::parse(&text).expect("user fixture should parse");
1171
1172        assert_eq!(actual, expected);
1173    }
1174
1175    #[rstest]
1176    fn test_user_ws_message_parse_with_reordered_event_type() {
1177        let expected: UserWsMessage = load("ws_user_trade_msg.json");
1178        let mut value: serde_json::Value = load("ws_user_trade_msg.json");
1179        let object = value
1180            .as_object_mut()
1181            .expect("user fixture should be an object");
1182        let event_type = object
1183            .remove("event_type")
1184            .expect("user fixture should contain event_type");
1185        object.insert("event_type".to_string(), event_type);
1186        let text = serde_json::to_string(&value).expect("user fixture should serialize");
1187
1188        assert!(!text.starts_with(r#"{"event_type":"#));
1189        assert_eq!(
1190            UserWsMessage::parse(&text).expect("reordered user fixture should parse"),
1191            expected
1192        );
1193    }
1194
1195    #[rstest]
1196    fn test_user_ws_message_parse_rejects_duplicate_event_type() {
1197        let text = load_text("ws_user_order_msg.json").replacen(
1198            r#""event_type": "order""#,
1199            r#""event_type": "order", "event_type": "order""#,
1200            1,
1201        );
1202        let expected = serde_json::from_str::<UserWsMessage>(&text)
1203            .expect_err("derived parser should reject a duplicate event_type");
1204        let actual = UserWsMessage::parse(&text)
1205            .expect_err("optimized parser should reject a duplicate event_type");
1206
1207        assert_eq!(actual.to_string(), expected.to_string());
1208    }
1209
1210    #[rstest]
1211    fn test_user_ws_message_parse_batch() {
1212        let text = load_text("ws_user_batch_msg.json");
1213        let expected: Vec<UserWsMessage> =
1214            serde_json::from_str(&text).expect("user batch fixture should deserialize");
1215
1216        let actual = UserWsMessage::parse_batch(&text).expect("user batch fixture should parse");
1217
1218        assert_eq!(actual, expected);
1219    }
1220
1221    #[rstest]
1222    fn test_user_ws_message_parse_batch_with_reordered_event_type() {
1223        let expected: Vec<UserWsMessage> = load("ws_user_batch_msg.json");
1224        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1225        let first = value
1226            .as_array_mut()
1227            .expect("user batch fixture should be an array")[0]
1228            .as_object_mut()
1229            .expect("user batch element should be an object");
1230        let event_type = first
1231            .remove("event_type")
1232            .expect("user batch element should contain event_type");
1233        first.insert("event_type".to_string(), event_type);
1234        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1235
1236        assert_eq!(
1237            UserWsMessage::parse_batch(&text).expect("reordered user batch should parse"),
1238            expected
1239        );
1240    }
1241
1242    #[rstest]
1243    fn test_user_ws_message_parse_batch_rejects_invalid_element() {
1244        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1245        value
1246            .as_array_mut()
1247            .expect("user batch fixture should be an array")[1]
1248            .as_object_mut()
1249            .expect("user batch element should be an object")
1250            .remove("event_type");
1251        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1252
1253        assert!(UserWsMessage::parse_batch(&text).is_err());
1254    }
1255
1256    /// A duplicated `event_type` is malformed and must be rejected, matching
1257    /// [`UserWsMessage::parse`].
1258    #[rstest]
1259    fn test_user_ws_message_parse_batch_rejects_duplicate_event_type() {
1260        let element = load_text("ws_user_order_msg.json").replacen(
1261            r#""event_type": "order""#,
1262            r#""event_type": "order", "event_type": "order""#,
1263            1,
1264        );
1265        let text = format!("[{element}]");
1266
1267        assert!(UserWsMessage::parse_batch(&text).is_err());
1268    }
1269
1270    /// Polymarket emits undocumented user-channel events such as `auto_redeem`, which must not
1271    /// discard the `order` and `trade` messages batched alongside them.
1272    #[rstest]
1273    fn test_user_ws_message_parse_batch_skips_unknown_event_type() {
1274        let expected: Vec<UserWsMessage> = load("ws_user_batch_msg.json");
1275        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1276        let elements = value
1277            .as_array_mut()
1278            .expect("user batch fixture should be an array");
1279        elements.insert(1, auto_redeem_element());
1280        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1281
1282        let actual =
1283            UserWsMessage::parse_batch(&text).expect("batch with unknown event_type should parse");
1284
1285        assert_eq!(actual, expected);
1286    }
1287
1288    #[rstest]
1289    fn test_user_ws_message_parse_batch_all_unknown_event_types() {
1290        let text = serde_json::to_string(&serde_json::Value::Array(vec![
1291            auto_redeem_element(),
1292            auto_redeem_element(),
1293        ]))
1294        .expect("unknown batch should serialize");
1295
1296        let actual =
1297            UserWsMessage::parse_batch(&text).expect("batch of unknown event types should parse");
1298
1299        assert!(actual.is_empty());
1300    }
1301
1302    #[rstest]
1303    fn test_market_ws_message_new_market() {
1304        let msg: MarketWsMessage = load("ws_market_new_market_msg.json");
1305        let raw: serde_json::Value = load("ws_market_new_market_msg.json");
1306
1307        let MarketWsMessage::NewMarket(nm) = msg else {
1308            panic!("expected new market message");
1309        };
1310        assert_eq!(nm.id, "market-001");
1311        assert_eq!(
1312            nm.question,
1313            "Map 1 Rounds Handicap: Sangal (-6.5) vs zeste (+6.5)"
1314        );
1315        assert_eq!(
1316            nm.market.as_str(),
1317            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1318        );
1319        assert_eq!(nm.slug, "sanitized-new-market");
1320        assert_eq!(nm.description, raw["description"].as_str().unwrap());
1321        assert_eq!(
1322            nm.assets_ids,
1323            vec![
1324                "10000000000000000000000000000000000000000000000000000000000000000000000000001",
1325                "10000000000000000000000000000000000000000000000000000000000000000000000000002",
1326            ]
1327        );
1328        assert_eq!(nm.outcomes, vec!["Sangal", "zeste"]);
1329        assert_eq!(nm.timestamp, "1786179115414");
1330        assert!(nm.tags.is_empty());
1331        assert_eq!(
1332            nm.condition_id,
1333            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1334        );
1335        assert!(!nm.active);
1336        assert_eq!(nm.clob_token_ids, nm.assets_ids);
1337        assert_eq!(nm.order_price_min_tick_size.as_deref(), Some("0.01"));
1338        assert_eq!(
1339            nm.group_item_title.as_deref(),
1340            Some("Map 1 Rounds Handicap: Sangal (-6.5) vs zeste (+6.5)")
1341        );
1342        assert_eq!(
1343            nm.sports_market_type.as_deref(),
1344            Some("round_handicap_game_1")
1345        );
1346        assert_eq!(nm.line.as_deref(), Some("-6.5"));
1347        assert_eq!(
1348            nm.game_start_time.as_deref(),
1349            Some("2026-08-08 09:00:00+00")
1350        );
1351        assert_eq!(nm.taker_base_fee, Some(Decimal::from(1000)));
1352        assert_eq!(nm.fees_enabled, Some(true));
1353        let schedule = nm.fee_schedule.as_ref().expect("captured fee schedule");
1354        assert_eq!(schedule.exponent, Decimal::ONE);
1355        assert_eq!(schedule.rate, Decimal::new(5, 2));
1356        assert!(schedule.taker_only);
1357        assert_eq!(schedule.rebate_rate, Decimal::new(15, 2));
1358        let event = nm.event_message.as_ref().expect("captured event metadata");
1359        assert_eq!(event.id, "event-001");
1360        assert_eq!(event.ticker, "sanitized-event");
1361        assert_eq!(event.slug, "sanitized-event");
1362        assert_eq!(
1363            event.title,
1364            "Counter-Strike: Sangal vs zeste (BO3) - Esports World Cup Open Qualifier Group 16"
1365        );
1366        assert_eq!(
1367            event.description,
1368            raw["event_message"]["description"].as_str().unwrap()
1369        );
1370    }
1371
1372    #[rstest]
1373    fn test_market_ws_message_resolved() {
1374        let msg: MarketWsMessage = load("ws_market_resolved_msg.json");
1375
1376        assert!(matches!(msg, MarketWsMessage::MarketResolved(_)));
1377        if let MarketWsMessage::MarketResolved(mr) = msg {
1378            assert_eq!(mr.id, "1031769");
1379            assert_eq!(mr.winning_outcome, "Yes");
1380            assert_eq!(mr.assets_ids.len(), 2);
1381            assert_eq!(
1382                mr.winning_asset_id,
1383                "76043073756653678226373981964075571318267289248134717369284518995922789326425"
1384            );
1385        }
1386    }
1387
1388    #[rstest]
1389    fn test_market_ws_message_best_bid_ask() {
1390        let msg: MarketWsMessage = load("ws_market_best_bid_ask_msg.json");
1391
1392        assert!(matches!(msg, MarketWsMessage::BestBidAsk(_)));
1393        if let MarketWsMessage::BestBidAsk(bba) = msg {
1394            assert_eq!(bba.best_bid, "0.73");
1395            assert_eq!(bba.best_ask, "0.77");
1396            assert_eq!(bba.spread, "0.04");
1397        }
1398    }
1399}