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 an error if `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 an error if `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 an error if `text` is not a valid user-message batch.
582    pub fn parse_batch(text: &str) -> serde_json::Result<Vec<Self>> {
583        // Elements stay raw so the derived impl parses each one and rejects a duplicated
584        // `event_type`; `serde_json::Value` would silently keep the last occurrence.
585        let elements: Vec<&RawValue> = serde_json::from_str(text)?;
586        let mut messages = Vec::with_capacity(elements.len());
587        let mut skipped = 0usize;
588
589        for element in elements {
590            if Self::has_unrecognized_event_type(element.get()) {
591                skipped += 1;
592            } else {
593                messages.push(serde_json::from_str(element.get())?);
594            }
595        }
596
597        if skipped > 0 {
598            log::debug!("Skipped {skipped} user WS message(s) with an unrecognized event_type");
599        }
600
601        Ok(messages)
602    }
603
604    pub(crate) fn has_unrecognized_event_type(text: &str) -> bool {
605        /// Reads only the tag, to classify a message before deserializing it.
606        #[derive(Deserialize)]
607        struct EventTypeTag {
608            event_type: Option<String>,
609        }
610
611        serde_json::from_str::<EventTypeTag>(text).is_ok_and(|tag| {
612            tag.event_type
613                .is_some_and(|event_type| !matches!(event_type.as_str(), "order" | "trade"))
614        })
615    }
616
617    fn parse_reordered(text: &str) -> serde_json::Result<Self> {
618        let tag = serde_json::from_str::<UserWsTag>(text)?;
619        match tag.event_type {
620            UserWsEventTag::Order => serde_json::from_str(text).map(Self::Order),
621            UserWsEventTag::Trade => serde_json::from_str(text).map(Self::Trade),
622        }
623    }
624}
625
626#[derive(Deserialize)]
627#[serde(rename_all = "snake_case")]
628enum UserWsEventTag {
629    Order,
630    Trade,
631}
632
633#[derive(Deserialize)]
634struct UserWsTag {
635    event_type: UserWsEventTag,
636}
637
638/// Output message type from the Polymarket WebSocket handler.
639#[derive(Debug)]
640pub enum PolymarketWsMessage {
641    Market(MarketWsMessage),
642    User(UserWsMessage),
643    /// Emitted when the underlying WebSocket reconnects.
644    ///
645    /// `shard_id` identifies the market pool shard that reconnected. Direct
646    /// clients without pool routing emit `None`.
647    Reconnected {
648        shard_id: Option<usize>,
649    },
650}
651
652/// Auth payload embedded in user-channel subscribe messages.
653#[derive(Debug, Serialize, Zeroize)]
654pub struct PolymarketWsAuth {
655    #[serde(rename = "apiKey")]
656    pub api_key: SecretString,
657    pub secret: SecretString,
658    pub passphrase: SecretString,
659}
660
661/// Initial market-channel subscribe request sent for a fresh WebSocket session.
662///
663/// Wire format: `{"assets_ids": [...], "type": "market", "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 MarketInitialSubscribeRequest {
668    pub assets_ids: Vec<String>,
669    #[serde(rename = "type")]
670    pub msg_type: &'static str,
671    pub initial_dump: bool,
672    #[serde(skip_serializing_if = "std::ops::Not::not")]
673    pub custom_feature_enabled: bool,
674}
675
676/// Incremental market-channel subscribe request sent after the initial session subscribe.
677///
678/// Wire format: `{"assets_ids": [...], "operation": "subscribe", "initial_dump": true}`
679/// When `custom_feature_enabled` is true, enables new-market, market-resolved, and best-bid/ask
680/// events.
681#[derive(Debug, Serialize)]
682pub struct MarketSubscribeRequest {
683    pub assets_ids: Vec<String>,
684    pub operation: &'static str,
685    pub initial_dump: bool,
686    #[serde(skip_serializing_if = "std::ops::Not::not")]
687    pub custom_feature_enabled: bool,
688}
689
690/// Market-channel dynamic unsubscribe request sent during an active session.
691///
692/// Wire format: `{"assets_ids": [...], "operation": "unsubscribe"}`
693#[derive(Debug, Serialize)]
694pub struct MarketUnsubscribeRequest {
695    pub assets_ids: Vec<String>,
696    pub operation: &'static str,
697}
698
699/// User-channel subscribe request sent on connect.
700///
701/// Wire format: `{"auth": {...}, "type": "user"}`
702#[derive(Debug, Serialize, Zeroize)]
703pub struct UserSubscribeRequest {
704    pub auth: PolymarketWsAuth,
705    #[serde(rename = "type")]
706    #[zeroize(skip)]
707    pub msg_type: &'static str,
708}
709
710#[cfg(test)]
711mod tests {
712    use rstest::rstest;
713
714    use super::*;
715    use crate::common::enums::{
716        PolymarketEventType, PolymarketLiquiditySide, PolymarketOrderSide, PolymarketOrderStatus,
717        PolymarketOrderType, PolymarketOutcome, PolymarketTradeStatus,
718    };
719
720    #[rstest]
721    fn user_subscribe_request_matches_all_markets_wire_format() {
722        let request = UserSubscribeRequest {
723            auth: PolymarketWsAuth {
724                api_key: SecretString::from("fixture-key"),
725                secret: SecretString::from("fixture-secret"),
726                passphrase: SecretString::from("fixture-passphrase"),
727            },
728            msg_type: "user",
729        };
730
731        assert_eq!(
732            serde_json::to_value(request).unwrap(),
733            serde_json::json!({
734                "auth": {
735                    "apiKey": "fixture-key",
736                    "secret": "fixture-secret",
737                    "passphrase": "fixture-passphrase",
738                },
739                "type": "user",
740            }),
741        );
742    }
743
744    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
745        let path = format!("test_data/{filename}");
746        let content = std::fs::read_to_string(path).expect("Failed to read test data");
747        serde_json::from_str(&content).expect("Failed to parse test data")
748    }
749
750    fn load_text(filename: &str) -> String {
751        let path = format!("test_data/{filename}");
752        std::fs::read_to_string(path).expect("Failed to read test data")
753    }
754
755    /// An `auto_redeem` user-channel event as observed from the venue, which is undocumented and
756    /// not modeled by [`UserWsMessage`].
757    fn auto_redeem_element() -> serde_json::Value {
758        serde_json::json!({
759            "event_type": "auto_redeem",
760            "proxy_wallet": "0x0000000000000000000000000000000000000000",
761            "txn_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
762            "amount": "70",
763            "condition_id": "0xb88862256916cb0c82e72667bd43f3e6cfe94cd7d5cda0bf763ec82394021e07",
764            "question": "Bitcoin Up or Down - August 17, 9:35AM-9:40AM ET",
765            "slug": "btc-updown-5m-1786973700",
766            "neg_risk": false,
767            "timestamp": "1786974414920",
768            "position_id": "",
769            "outcome_index": 0,
770            "legs": 0,
771            "owner": "00000000-0000-0000-0000-000000000000",
772        })
773    }
774
775    #[rstest]
776    fn test_book_snapshot() {
777        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
778
779        assert_eq!(
780            snap.asset_id,
781            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
782        );
783        assert_eq!(snap.bids.len(), 3);
784        assert_eq!(snap.asks.len(), 3);
785        assert_eq!(snap.bids[0].price, "0.48");
786        assert_eq!(snap.bids[0].size, "500.0");
787        assert_eq!(snap.asks[0].price, "0.53");
788        assert_eq!(snap.timestamp, "1703875200000");
789        assert!(snap.hash.is_none());
790        assert!(snap.min_order_size.is_none());
791        assert!(snap.tick_size.is_none());
792        assert!(snap.neg_risk.is_none());
793        assert!(snap.last_trade_price.is_none());
794    }
795
796    #[rstest]
797    fn test_book_snapshot_roundtrip() {
798        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
799        let json = serde_json::to_string(&snap).unwrap();
800        let snap2: PolymarketBookSnapshot = serde_json::from_str(&json).unwrap();
801        assert_eq!(snap, snap2);
802    }
803
804    #[rstest]
805    fn test_quotes() {
806        let quotes: PolymarketQuotes = load("ws_quotes.json");
807
808        assert_eq!(quotes.price_changes.len(), 2);
809        assert_eq!(quotes.price_changes[0].side, PolymarketOrderSide::Buy);
810        assert_eq!(quotes.price_changes[0].price, "0.51");
811        assert_eq!(quotes.price_changes[0].best_bid.as_deref(), Some("0.51"));
812        assert_eq!(quotes.price_changes[0].best_ask.as_deref(), Some("0.52"));
813        assert_eq!(quotes.price_changes[1].side, PolymarketOrderSide::Sell);
814        assert_eq!(quotes.timestamp, "1703875201000");
815    }
816
817    #[rstest]
818    fn test_last_trade() {
819        let trade: PolymarketTrade = load("ws_last_trade.json");
820
821        assert_eq!(trade.price, "0.51");
822        assert_eq!(trade.size, "25.0");
823        assert_eq!(trade.side, PolymarketOrderSide::Buy);
824        assert_eq!(trade.fee_rate_bps, "0");
825        assert_eq!(trade.timestamp, "1703875202000");
826        assert_eq!(
827            trade.transaction_hash.as_deref(),
828            Some("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab")
829        );
830    }
831
832    #[rstest]
833    fn test_optional_market_hash_fields_default() {
834        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_missing_hash.json");
835        let trade: PolymarketTrade = load("ws_last_trade_missing_transaction_hash.json");
836
837        assert!(snap.hash.is_none());
838        assert!(snap.min_order_size.is_none());
839        assert!(snap.tick_size.is_none());
840        assert!(snap.neg_risk.is_none());
841        assert!(snap.last_trade_price.is_none());
842        assert!(trade.transaction_hash.is_none());
843    }
844
845    #[rstest]
846    fn test_tick_size_change() {
847        let msg: PolymarketTickSizeChange = load("ws_tick_size_change.json");
848
849        assert_eq!(msg.new_tick_size, "0.01");
850        assert_eq!(msg.old_tick_size, "0.1");
851        assert_eq!(msg.timestamp, "1703875210000");
852    }
853
854    #[rstest]
855    fn test_user_order_placement() {
856        let order: PolymarketUserOrder = load("ws_user_order_placement.json");
857
858        assert_eq!(order.event_type, PolymarketEventType::Placement);
859        assert_eq!(
860            order.status.as_ref().map(|status| status.status),
861            Some(PolymarketOrderStatus::Live)
862        );
863        assert_eq!(order.side, PolymarketOrderSide::Buy);
864        assert_eq!(order.order_type, Some(PolymarketOrderType::GTC));
865        assert_eq!(order.outcome, Some(PolymarketOutcome::yes()));
866        assert_eq!(order.original_size, "100.0");
867        assert_eq!(order.size_matched, "0.0");
868        assert!(order.associate_trades.is_none());
869        assert!(order.expiration.is_none());
870    }
871
872    #[rstest]
873    fn test_user_order_update() {
874        let order: PolymarketUserOrder = load("ws_user_order_update.json");
875
876        assert_eq!(order.event_type, PolymarketEventType::Update);
877        assert_eq!(order.size_matched, "25.0");
878        assert_eq!(
879            order.associate_trades.as_deref(),
880            Some(&["trade-0xabcdef1234".to_string()][..])
881        );
882    }
883
884    #[rstest]
885    fn test_user_order_cancellation() {
886        let order: PolymarketUserOrder = load("ws_user_order_cancellation.json");
887
888        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
889        assert_eq!(
890            order.status.as_ref().map(|status| status.status),
891            Some(PolymarketOrderStatus::Canceled)
892        );
893        assert_eq!(order.size_matched, "0.0");
894    }
895
896    #[rstest]
897    fn test_user_order_status_preserves_rejection_reason() {
898        let raw = "UNMATCHED_invalid post-only order: order crosses book";
899        let status: PolymarketUserOrderStatus =
900            serde_json::from_str(&format!("\"{raw}\"")).unwrap();
901
902        assert_eq!(status.status, PolymarketOrderStatus::Unmatched);
903        assert_eq!(
904            status.reason.as_deref(),
905            Some("invalid post-only order: order crosses book")
906        );
907        assert_eq!(
908            serde_json::to_string(&status).unwrap(),
909            format!("\"{raw}\"")
910        );
911    }
912
913    /// Repro for issue #3987: venue cancels a FOK order with a status field
914    /// containing a trailing reason ("CANCELED_<reason>") and empty fields
915    /// on `size_matched`, `outcome`, and `created_at`.
916    #[rstest]
917    fn test_user_order_fok_killed() {
918        let msg: UserWsMessage = load("ws_user_order_fok_killed.json");
919
920        let UserWsMessage::Order(order) = msg else {
921            panic!("Expected UserWsMessage::Order");
922        };
923        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
924        assert_eq!(
925            order.status.as_ref().map(|status| status.status),
926            Some(PolymarketOrderStatus::Canceled)
927        );
928        assert_eq!(
929            order
930                .status
931                .as_ref()
932                .and_then(|status| status.reason.as_deref()),
933            Some("order couldn't be fully filled. FOK orders are fully filled or killed.")
934        );
935        assert_eq!(order.order_type, Some(PolymarketOrderType::FOK));
936        assert_eq!(order.size_matched, "");
937        assert_eq!(order.created_at.as_deref(), Some(""));
938        assert_eq!(
939            order.outcome.as_ref().map(PolymarketOutcome::as_str),
940            Some("")
941        );
942    }
943
944    #[rstest]
945    fn test_user_trade() {
946        let trade: PolymarketUserTrade = load("ws_user_trade.json");
947
948        assert_eq!(trade.event_type, PolymarketEventType::Trade);
949        assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
950        assert_eq!(trade.side, PolymarketOrderSide::Buy);
951        assert_eq!(trade.trader_side, PolymarketLiquiditySide::Taker);
952        assert_eq!(trade.price, "0.5");
953        assert_eq!(trade.size, "25.0");
954        assert_eq!(trade.fee_rate_bps, "0");
955        assert_eq!(trade.bucket_index, 1);
956        assert_eq!(trade.maker_orders.len(), 1);
957        assert_eq!(
958            trade.transaction_hash.as_deref(),
959            Some("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab")
960        );
961        assert_eq!(
962            trade.taker_order_id,
963            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12"
964        );
965    }
966
967    #[rstest]
968    fn test_user_trade_missing_transaction_hash() {
969        let mut value: serde_json::Value = load("ws_user_trade.json");
970        value
971            .as_object_mut()
972            .expect("trade fixture should be an object")
973            .remove("transaction_hash");
974
975        let trade: PolymarketUserTrade =
976            serde_json::from_value(value).expect("trade fixture should deserialize");
977
978        assert!(trade.transaction_hash.is_none());
979    }
980
981    #[rstest]
982    fn test_market_ws_message_book() {
983        let msg: MarketWsMessage = load("ws_market_book_msg.json");
984
985        assert!(matches!(msg, MarketWsMessage::Book(_)));
986        if let MarketWsMessage::Book(snap) = msg {
987            assert_eq!(snap.bids.len(), 2);
988            assert_eq!(snap.asks.len(), 2);
989            assert_eq!(snap.timestamp, "1703875200000");
990        }
991    }
992
993    #[rstest]
994    #[case("ws_market_book_msg.json")]
995    #[case("ws_market_price_change_msg.json")]
996    #[case("ws_market_last_trade_msg.json")]
997    #[case("ws_market_tick_size_msg.json")]
998    #[case("ws_market_new_market_msg.json")]
999    #[case("ws_market_resolved_msg.json")]
1000    #[case("ws_market_best_bid_ask_msg.json")]
1001    fn test_market_ws_message_parse(#[case] filename: &str) {
1002        let text = load_text(filename);
1003        let expected: MarketWsMessage =
1004            serde_json::from_str(&text).expect("market fixture should deserialize");
1005
1006        let actual = MarketWsMessage::parse(&text).expect("market fixture should parse");
1007
1008        assert_eq!(actual, expected);
1009    }
1010
1011    #[rstest]
1012    fn test_market_ws_message_parse_with_reordered_event_type() {
1013        let expected: MarketWsMessage = load("ws_market_book_msg.json");
1014        let mut value: serde_json::Value = load("ws_market_book_msg.json");
1015        let object = value
1016            .as_object_mut()
1017            .expect("market fixture should be an object");
1018        let event_type = object
1019            .remove("event_type")
1020            .expect("market fixture should contain event_type");
1021        object.insert("event_type".to_string(), event_type);
1022        let text = serde_json::to_string(&value).expect("market fixture should serialize");
1023
1024        assert!(!text.starts_with(r#"{"event_type":"#));
1025        assert_eq!(
1026            MarketWsMessage::parse(&text).expect("reordered market fixture should parse"),
1027            expected
1028        );
1029    }
1030
1031    #[rstest]
1032    fn test_market_ws_message_parse_rejects_duplicate_event_type() {
1033        let text = load_text("ws_market_book_msg.json").replacen(
1034            r#""event_type": "book","#,
1035            r#""event_type": "book", "event_type": "book","#,
1036            1,
1037        );
1038        let expected = serde_json::from_str::<MarketWsMessage>(&text)
1039            .expect_err("derived parser should reject a duplicate event_type");
1040        let actual = MarketWsMessage::parse(&text)
1041            .expect_err("optimized parser should reject a duplicate event_type");
1042
1043        assert_eq!(actual.to_string(), expected.to_string());
1044    }
1045
1046    #[rstest]
1047    fn test_market_ws_message_price_change() {
1048        let msg: MarketWsMessage = load("ws_market_price_change_msg.json");
1049
1050        assert!(matches!(msg, MarketWsMessage::PriceChange(_)));
1051        if let MarketWsMessage::PriceChange(quotes) = msg {
1052            assert_eq!(quotes.price_changes.len(), 1);
1053        }
1054    }
1055
1056    #[rstest]
1057    fn test_market_ws_message_last_trade_price() {
1058        let msg: MarketWsMessage = load("ws_market_last_trade_msg.json");
1059
1060        assert!(matches!(msg, MarketWsMessage::LastTradePrice(_)));
1061        if let MarketWsMessage::LastTradePrice(trade) = msg {
1062            assert_eq!(trade.price, "0.51");
1063        }
1064    }
1065
1066    #[rstest]
1067    fn test_market_ws_message_tick_size_change() {
1068        let msg: MarketWsMessage = load("ws_market_tick_size_msg.json");
1069
1070        assert!(matches!(msg, MarketWsMessage::TickSizeChange(_)));
1071        if let MarketWsMessage::TickSizeChange(change) = msg {
1072            assert_eq!(change.new_tick_size, "0.01");
1073            assert_eq!(change.old_tick_size, "0.1");
1074        }
1075    }
1076
1077    #[rstest]
1078    fn test_user_ws_message_order() {
1079        let msg: UserWsMessage = load("ws_user_order_msg.json");
1080
1081        let UserWsMessage::Order(order) = msg else {
1082            panic!("expected order message");
1083        };
1084        assert_eq!(
1085            order.asset_id,
1086            "10000000000000000000000000000000000000000000000000000000000000000000000000001"
1087        );
1088        assert_eq!(order.associate_trades, Some(Vec::new()));
1089        assert_eq!(order.created_at.as_deref(), Some(""));
1090        assert_eq!(order.expiration.as_deref(), Some("0"));
1091        assert_eq!(
1092            order.id,
1093            "0x1111111111111111111111111111111111111111111111111111111111111111"
1094        );
1095        assert_eq!(
1096            order.maker_address.as_ref().map(Ustr::as_str),
1097            Some("0x1111111111111111111111111111111111111111")
1098        );
1099        assert_eq!(
1100            order.market,
1101            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1102        );
1103        assert_eq!(
1104            order.order_owner.as_ref().map(Ustr::as_str),
1105            Some("11111111-2222-3333-4444-555555555555")
1106        );
1107        assert_eq!(order.order_type, Some(PolymarketOrderType::FOK));
1108        assert_eq!(order.original_size, "1");
1109        assert_eq!(
1110            order.outcome.as_ref().map(PolymarketOutcome::as_str),
1111            Some("")
1112        );
1113        assert_eq!(order.owner, "11111111-2222-3333-4444-555555555555");
1114        assert_eq!(order.price, "0.01");
1115        assert_eq!(order.side, PolymarketOrderSide::Buy);
1116        assert_eq!(order.size_matched, "");
1117        assert_eq!(
1118            order.status.as_ref().map(|status| status.status),
1119            Some(PolymarketOrderStatus::Canceled)
1120        );
1121        assert_eq!(order.timestamp, "1786179547007");
1122        assert_eq!(order.event_type, PolymarketEventType::Cancellation);
1123    }
1124
1125    #[rstest]
1126    fn test_user_ws_message_order_optional_fields_absent() {
1127        // Constructed from the documented required field set
1128        let json = r#"{
1129            "event_type":"order",
1130            "id":"order-1",
1131            "owner":"owner-1",
1132            "market":"market-1",
1133            "asset_id":"asset-1",
1134            "side":"SELL",
1135            "original_size":"2",
1136            "size_matched":"0",
1137            "price":"0.5",
1138            "type":"PLACEMENT",
1139            "timestamp":"1786179547008"
1140        }"#;
1141        let UserWsMessage::Order(order) = serde_json::from_str(json).unwrap() else {
1142            panic!("expected order message");
1143        };
1144
1145        assert_eq!(order.asset_id, "asset-1");
1146        assert!(order.associate_trades.is_none());
1147        assert!(order.created_at.is_none());
1148        assert!(order.expiration.is_none());
1149        assert_eq!(order.id, "order-1");
1150        assert!(order.maker_address.is_none());
1151        assert_eq!(order.market, "market-1");
1152        assert!(order.order_owner.is_none());
1153        assert!(order.order_type.is_none());
1154        assert_eq!(order.original_size, "2");
1155        assert!(order.outcome.is_none());
1156        assert_eq!(order.owner, "owner-1");
1157        assert_eq!(order.price, "0.5");
1158        assert_eq!(order.side, PolymarketOrderSide::Sell);
1159        assert_eq!(order.size_matched, "0");
1160        assert!(order.status.is_none());
1161        assert_eq!(order.timestamp, "1786179547008");
1162        assert_eq!(order.event_type, PolymarketEventType::Placement);
1163    }
1164
1165    #[rstest]
1166    fn test_user_ws_message_trade() {
1167        let msg: UserWsMessage = load("ws_user_trade_msg.json");
1168
1169        assert!(matches!(msg, UserWsMessage::Trade(_)));
1170        if let UserWsMessage::Trade(trade) = msg {
1171            assert_eq!(trade.event_type, PolymarketEventType::Trade);
1172            assert_eq!(trade.status, PolymarketTradeStatus::Confirmed);
1173            assert!(trade.transaction_hash.is_none());
1174        }
1175    }
1176
1177    #[rstest]
1178    #[case("ws_user_order_msg.json")]
1179    #[case("ws_user_order_fok_killed.json")]
1180    #[case("ws_user_trade_msg.json")]
1181    fn test_user_ws_message_parse(#[case] filename: &str) {
1182        let text = load_text(filename);
1183        let expected: UserWsMessage =
1184            serde_json::from_str(&text).expect("user fixture should deserialize");
1185
1186        let actual = UserWsMessage::parse(&text).expect("user fixture should parse");
1187
1188        assert_eq!(actual, expected);
1189    }
1190
1191    #[rstest]
1192    fn test_user_ws_message_parse_with_reordered_event_type() {
1193        let expected: UserWsMessage = load("ws_user_trade_msg.json");
1194        let mut value: serde_json::Value = load("ws_user_trade_msg.json");
1195        let object = value
1196            .as_object_mut()
1197            .expect("user fixture should be an object");
1198        let event_type = object
1199            .remove("event_type")
1200            .expect("user fixture should contain event_type");
1201        object.insert("event_type".to_string(), event_type);
1202        let text = serde_json::to_string(&value).expect("user fixture should serialize");
1203
1204        assert!(!text.starts_with(r#"{"event_type":"#));
1205        assert_eq!(
1206            UserWsMessage::parse(&text).expect("reordered user fixture should parse"),
1207            expected
1208        );
1209    }
1210
1211    #[rstest]
1212    fn test_user_ws_message_parse_rejects_duplicate_event_type() {
1213        let text = load_text("ws_user_order_msg.json").replacen(
1214            r#""event_type": "order""#,
1215            r#""event_type": "order", "event_type": "order""#,
1216            1,
1217        );
1218        let expected = serde_json::from_str::<UserWsMessage>(&text)
1219            .expect_err("derived parser should reject a duplicate event_type");
1220        let actual = UserWsMessage::parse(&text)
1221            .expect_err("optimized parser should reject a duplicate event_type");
1222
1223        assert_eq!(actual.to_string(), expected.to_string());
1224    }
1225
1226    #[rstest]
1227    fn test_user_ws_message_parse_batch() {
1228        let text = load_text("ws_user_batch_msg.json");
1229        let expected: Vec<UserWsMessage> =
1230            serde_json::from_str(&text).expect("user batch fixture should deserialize");
1231
1232        let actual = UserWsMessage::parse_batch(&text).expect("user batch fixture should parse");
1233
1234        assert_eq!(actual, expected);
1235    }
1236
1237    #[rstest]
1238    fn test_user_ws_message_parse_batch_with_reordered_event_type() {
1239        let expected: Vec<UserWsMessage> = load("ws_user_batch_msg.json");
1240        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1241        let first = value
1242            .as_array_mut()
1243            .expect("user batch fixture should be an array")[0]
1244            .as_object_mut()
1245            .expect("user batch element should be an object");
1246        let event_type = first
1247            .remove("event_type")
1248            .expect("user batch element should contain event_type");
1249        first.insert("event_type".to_string(), event_type);
1250        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1251
1252        assert_eq!(
1253            UserWsMessage::parse_batch(&text).expect("reordered user batch should parse"),
1254            expected
1255        );
1256    }
1257
1258    #[rstest]
1259    fn test_user_ws_message_parse_batch_rejects_invalid_element() {
1260        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1261        value
1262            .as_array_mut()
1263            .expect("user batch fixture should be an array")[1]
1264            .as_object_mut()
1265            .expect("user batch element should be an object")
1266            .remove("event_type");
1267        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1268
1269        assert!(UserWsMessage::parse_batch(&text).is_err());
1270    }
1271
1272    /// A duplicated `event_type` is malformed and must be rejected, matching
1273    /// [`UserWsMessage::parse`].
1274    #[rstest]
1275    fn test_user_ws_message_parse_batch_rejects_duplicate_event_type() {
1276        let element = load_text("ws_user_order_msg.json").replacen(
1277            r#""event_type": "order""#,
1278            r#""event_type": "order", "event_type": "order""#,
1279            1,
1280        );
1281        let text = format!("[{element}]");
1282
1283        assert!(UserWsMessage::parse_batch(&text).is_err());
1284    }
1285
1286    /// Polymarket emits undocumented user-channel events such as `auto_redeem`, which must not
1287    /// discard the `order` and `trade` messages batched alongside them.
1288    #[rstest]
1289    fn test_user_ws_message_parse_batch_skips_unknown_event_type() {
1290        let expected: Vec<UserWsMessage> = load("ws_user_batch_msg.json");
1291        let mut value: serde_json::Value = load("ws_user_batch_msg.json");
1292        let elements = value
1293            .as_array_mut()
1294            .expect("user batch fixture should be an array");
1295        elements.insert(1, auto_redeem_element());
1296        let text = serde_json::to_string(&value).expect("user batch fixture should serialize");
1297
1298        let actual =
1299            UserWsMessage::parse_batch(&text).expect("batch with unknown event_type should parse");
1300
1301        assert_eq!(actual, expected);
1302    }
1303
1304    #[rstest]
1305    fn test_user_ws_message_parse_batch_all_unknown_event_types() {
1306        let text = serde_json::to_string(&serde_json::Value::Array(vec![
1307            auto_redeem_element(),
1308            auto_redeem_element(),
1309        ]))
1310        .expect("unknown batch should serialize");
1311
1312        let actual =
1313            UserWsMessage::parse_batch(&text).expect("batch of unknown event types should parse");
1314
1315        assert!(actual.is_empty());
1316    }
1317
1318    #[rstest]
1319    #[case::auto_redeem(auto_redeem_element().to_string(), true)]
1320    #[case::order(load_text("ws_user_order_msg.json"), false)]
1321    #[case::trade(load_text("ws_user_trade_msg.json"), false)]
1322    #[case::missing_event_type(serde_json::json!({"type": "order"}).to_string(), false)]
1323    #[case::not_an_object(serde_json::json!([]).to_string(), false)]
1324    fn test_user_ws_message_has_unrecognized_event_type(
1325        #[case] text: String,
1326        #[case] expected: bool,
1327    ) {
1328        assert_eq!(UserWsMessage::has_unrecognized_event_type(&text), expected);
1329    }
1330
1331    #[rstest]
1332    fn test_market_ws_message_new_market() {
1333        let msg: MarketWsMessage = load("ws_market_new_market_msg.json");
1334        let raw: serde_json::Value = load("ws_market_new_market_msg.json");
1335
1336        let MarketWsMessage::NewMarket(nm) = msg else {
1337            panic!("expected new market message");
1338        };
1339        assert_eq!(nm.id, "market-001");
1340        assert_eq!(
1341            nm.question,
1342            "Map 1 Rounds Handicap: Sangal (-6.5) vs zeste (+6.5)"
1343        );
1344        assert_eq!(
1345            nm.market,
1346            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1347        );
1348        assert_eq!(nm.slug, "sanitized-new-market");
1349        assert_eq!(nm.description, raw["description"].as_str().unwrap());
1350        assert_eq!(
1351            nm.assets_ids,
1352            vec![
1353                "10000000000000000000000000000000000000000000000000000000000000000000000000001",
1354                "10000000000000000000000000000000000000000000000000000000000000000000000000002",
1355            ]
1356        );
1357        assert_eq!(nm.outcomes, vec!["Sangal", "zeste"]);
1358        assert_eq!(nm.timestamp, "1786179115414");
1359        assert!(nm.tags.is_empty());
1360        assert_eq!(
1361            nm.condition_id,
1362            "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
1363        );
1364        assert!(!nm.active);
1365        assert_eq!(nm.clob_token_ids, nm.assets_ids);
1366        assert_eq!(nm.order_price_min_tick_size.as_deref(), Some("0.01"));
1367        assert_eq!(
1368            nm.group_item_title.as_deref(),
1369            Some("Map 1 Rounds Handicap: Sangal (-6.5) vs zeste (+6.5)")
1370        );
1371        assert_eq!(
1372            nm.sports_market_type.as_deref(),
1373            Some("round_handicap_game_1")
1374        );
1375        assert_eq!(nm.line.as_deref(), Some("-6.5"));
1376        assert_eq!(
1377            nm.game_start_time.as_deref(),
1378            Some("2026-08-08 09:00:00+00")
1379        );
1380        assert_eq!(nm.taker_base_fee, Some(Decimal::from(1000)));
1381        assert_eq!(nm.fees_enabled, Some(true));
1382        let schedule = nm.fee_schedule.as_ref().expect("captured fee schedule");
1383        assert_eq!(schedule.exponent, Decimal::ONE);
1384        assert_eq!(schedule.rate, Decimal::new(5, 2));
1385        assert!(schedule.taker_only);
1386        assert_eq!(schedule.rebate_rate, Decimal::new(15, 2));
1387        let event = nm.event_message.as_ref().expect("captured event metadata");
1388        assert_eq!(event.id, "event-001");
1389        assert_eq!(event.ticker, "sanitized-event");
1390        assert_eq!(event.slug, "sanitized-event");
1391        assert_eq!(
1392            event.title,
1393            "Counter-Strike: Sangal vs zeste (BO3) - Esports World Cup Open Qualifier Group 16"
1394        );
1395        assert_eq!(
1396            event.description,
1397            raw["event_message"]["description"].as_str().unwrap()
1398        );
1399    }
1400
1401    #[rstest]
1402    fn test_market_ws_message_resolved() {
1403        let msg: MarketWsMessage = load("ws_market_resolved_msg.json");
1404
1405        assert!(matches!(msg, MarketWsMessage::MarketResolved(_)));
1406        if let MarketWsMessage::MarketResolved(mr) = msg {
1407            assert_eq!(mr.id, "1031769");
1408            assert_eq!(mr.winning_outcome, "Yes");
1409            assert_eq!(mr.assets_ids.len(), 2);
1410            assert_eq!(
1411                mr.winning_asset_id,
1412                "76043073756653678226373981964075571318267289248134717369284518995922789326425"
1413            );
1414        }
1415    }
1416
1417    #[rstest]
1418    fn test_market_ws_message_best_bid_ask() {
1419        let msg: MarketWsMessage = load("ws_market_best_bid_ask_msg.json");
1420
1421        assert!(matches!(msg, MarketWsMessage::BestBidAsk(_)));
1422        if let MarketWsMessage::BestBidAsk(bba) = msg {
1423            assert_eq!(bba.best_bid, "0.73");
1424            assert_eq!(bba.best_ask, "0.77");
1425            assert_eq!(bba.spread, "0.04");
1426        }
1427    }
1428}