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