Skip to main content

asterdex_sdk/futures/models/
websocket.rs

1// US-011: WebSocket message types — complete struct definitions and manual Deserialize impl.
2// The outer combined-stream wrapper { "stream": "...", "data": {...} } is already unwrapped
3// by the Wave 5 WebSocketClient before these types are constructed.
4
5use serde::Deserialize;
6
7// ---------------------------------------------------------------------------
8// Event structs
9// ---------------------------------------------------------------------------
10
11/// Aggregate trade event — dispatched when `"e": "aggTrade"`.
12#[derive(Debug, Deserialize)]
13pub struct AggTradeEvent {
14    #[serde(rename = "e")]
15    pub event_type: String,
16    #[serde(rename = "E")]
17    pub event_time: u64,
18    #[serde(rename = "s")]
19    pub symbol: String,
20    #[serde(rename = "a")]
21    pub agg_trade_id: u64,
22    #[serde(rename = "p")]
23    pub price: String,
24    #[serde(rename = "q")]
25    pub quantity: String,
26    #[serde(rename = "f")]
27    pub first_trade_id: u64,
28    #[serde(rename = "l")]
29    pub last_trade_id: u64,
30    #[serde(rename = "T")]
31    pub trade_time: u64,
32    #[serde(rename = "m")]
33    pub is_buyer_maker: bool,
34}
35
36/// Mark price event — dispatched when `"e": "markPriceUpdate"`.
37#[derive(Debug, Deserialize)]
38pub struct MarkPriceEvent {
39    #[serde(rename = "e")]
40    pub event_type: String,
41    #[serde(rename = "E")]
42    pub event_time: u64,
43    #[serde(rename = "s")]
44    pub symbol: String,
45    #[serde(rename = "p")]
46    pub mark_price: String,
47    #[serde(rename = "i")]
48    pub index_price: String,
49    #[serde(rename = "P")]
50    pub estimated_settle_price: String,
51    #[serde(rename = "r")]
52    pub last_funding_rate: String,
53    #[serde(rename = "T")]
54    pub next_funding_time: u64,
55}
56
57/// Kline/candlestick event — dispatched when `"e": "kline"`.
58#[derive(Debug, Deserialize)]
59pub struct KlineEvent {
60    #[serde(rename = "e")]
61    pub event_type: String,
62    #[serde(rename = "E")]
63    pub event_time: u64,
64    #[serde(rename = "s")]
65    pub symbol: String,
66    #[serde(rename = "k")]
67    pub kline: KlineData,
68}
69
70/// Inner kline data nested under the `k` field of a `KlineEvent`.
71#[derive(Debug, Deserialize)]
72pub struct KlineData {
73    #[serde(rename = "t")]
74    pub start_time: u64,
75    #[serde(rename = "T")]
76    pub close_time: u64,
77    #[serde(rename = "s")]
78    pub symbol: String,
79    /// Interval string e.g. `"1m"`, `"1h"`.
80    #[serde(rename = "i")]
81    pub interval: String,
82    #[serde(rename = "f")]
83    pub first_trade_id: i64,
84    #[serde(rename = "L")]
85    pub last_trade_id: i64,
86    #[serde(rename = "o")]
87    pub open: String,
88    #[serde(rename = "c")]
89    pub close: String,
90    #[serde(rename = "h")]
91    pub high: String,
92    #[serde(rename = "l")]
93    pub low: String,
94    #[serde(rename = "v")]
95    pub volume: String,
96    #[serde(rename = "n")]
97    pub trade_count: u64,
98    #[serde(rename = "x")]
99    pub is_closed: bool,
100    #[serde(rename = "q")]
101    pub quote_volume: String,
102    #[serde(rename = "V")]
103    pub taker_buy_volume: String,
104    #[serde(rename = "Q")]
105    pub taker_buy_quote_volume: String,
106    /// `"B"` field from the payload, marked "Ignore" in the V3 docs.
107    /// Exposed for forward compatibility; do not rely on its content.
108    #[serde(rename = "B", default)]
109    pub ignore: String,
110}
111
112/// 24-hour mini ticker event — dispatched when `"e": "24hrMiniTicker"`.
113#[derive(Debug, Deserialize)]
114pub struct MiniTickerEvent {
115    #[serde(rename = "e")]
116    pub event_type: String,
117    #[serde(rename = "E")]
118    pub event_time: u64,
119    #[serde(rename = "s")]
120    pub symbol: String,
121    #[serde(rename = "c")]
122    pub close_price: String,
123    #[serde(rename = "o")]
124    pub open_price: String,
125    #[serde(rename = "h")]
126    pub high_price: String,
127    #[serde(rename = "l")]
128    pub low_price: String,
129    #[serde(rename = "v")]
130    pub volume: String,
131    #[serde(rename = "q")]
132    pub quote_volume: String,
133}
134
135/// Full 24-hour ticker event — dispatched when `"e": "24hrTicker"`.
136#[derive(Debug, Deserialize)]
137pub struct TickerEvent {
138    #[serde(rename = "e")]
139    pub event_type: String,
140    #[serde(rename = "E")]
141    pub event_time: u64,
142    #[serde(rename = "s")]
143    pub symbol: String,
144    #[serde(rename = "p")]
145    pub price_change: String,
146    #[serde(rename = "P")]
147    pub price_change_percent: String,
148    #[serde(rename = "w")]
149    pub weighted_avg_price: String,
150    #[serde(rename = "c")]
151    pub last_price: String,
152    #[serde(rename = "Q")]
153    pub last_qty: String,
154    #[serde(rename = "o")]
155    pub open_price: String,
156    #[serde(rename = "h")]
157    pub high_price: String,
158    #[serde(rename = "l")]
159    pub low_price: String,
160    #[serde(rename = "v")]
161    pub volume: String,
162    #[serde(rename = "q")]
163    pub quote_volume: String,
164    #[serde(rename = "O")]
165    pub open_time: u64,
166    #[serde(rename = "C")]
167    pub close_time: u64,
168    #[serde(rename = "F")]
169    pub first_trade_id: i64,
170    #[serde(rename = "L")]
171    pub last_trade_id: i64,
172    #[serde(rename = "n")]
173    pub trade_count: u64,
174}
175
176/// Best-bid/ask book ticker event — dispatched when `"e": "bookTicker"`.
177///
178/// V3 doc format includes `e`, `E`, `T` fields. Older/Binance-compatible
179/// deployments may emit the message without `e` — in that case it is still
180/// detected by `"u"` key presence without `"e"` and `event_type`/`event_time`/
181/// `transaction_time` fall back to their defaults.
182#[derive(Debug, Deserialize)]
183pub struct BookTickerEvent {
184    /// `"bookTicker"` on V3; empty on legacy no-`"e"` deployments.
185    #[serde(rename = "e", default)]
186    pub event_type: String,
187    #[serde(rename = "u")]
188    pub update_id: u64,
189    /// Event time — present in V3; `0` on legacy deployments that omit it.
190    #[serde(rename = "E", default)]
191    pub event_time: u64,
192    /// Transaction time — present in V3; `0` on legacy deployments that omit it.
193    #[serde(rename = "T", default)]
194    pub transaction_time: u64,
195    #[serde(rename = "s")]
196    pub symbol: String,
197    #[serde(rename = "b")]
198    pub best_bid_price: String,
199    #[serde(rename = "B")]
200    pub best_bid_qty: String,
201    #[serde(rename = "a")]
202    pub best_ask_price: String,
203    #[serde(rename = "A")]
204    pub best_ask_qty: String,
205}
206
207/// Liquidation force order event — dispatched when `"e": "forceOrder"`.
208#[derive(Debug, Deserialize)]
209pub struct ForceOrderEvent {
210    #[serde(rename = "e")]
211    pub event_type: String,
212    #[serde(rename = "E")]
213    pub event_time: u64,
214    #[serde(rename = "o")]
215    pub order: ForceOrderData,
216}
217
218/// Inner order data for a force-order event.
219#[derive(Debug, Deserialize)]
220pub struct ForceOrderData {
221    #[serde(rename = "s")]
222    pub symbol: String,
223    #[serde(rename = "S")]
224    pub side: String,
225    #[serde(rename = "o")]
226    pub order_type: String,
227    #[serde(rename = "f")]
228    pub time_in_force: String,
229    #[serde(rename = "q")]
230    pub quantity: String,
231    #[serde(rename = "p")]
232    pub price: String,
233    /// May be `"0"` for orders not yet filled — kept as String (BR-010).
234    #[serde(rename = "ap")]
235    pub average_price: String,
236    #[serde(rename = "X")]
237    pub order_status: String,
238    #[serde(rename = "l")]
239    pub last_filled_qty: String,
240    #[serde(rename = "z")]
241    pub accumulated_qty: String,
242    #[serde(rename = "T")]
243    pub trade_time: u64,
244}
245
246/// Partial depth snapshot event (legacy format).
247///
248/// Detected by `"lastUpdateId"` key with no `"e"` field. Some deployments emit
249/// partial book depth streams (`<symbol>@depth5/10/20`) in this REST-snapshot-like
250/// format. On V3 docs, partial depth streams emit the `depthUpdate` format instead
251/// and deserialize as [`DiffDepthEvent`].
252#[derive(Debug, Deserialize)]
253pub struct PartialDepthEvent {
254    #[serde(rename = "lastUpdateId")]
255    pub last_update_id: u64,
256    pub bids: Vec<[String; 2]>,
257    pub asks: Vec<[String; 2]>,
258}
259
260/// Differential depth stream event — dispatched when `"e": "depthUpdate"`.
261///
262/// Also used for partial book depth streams (`<symbol>@depth<levels>`) on
263/// deployments that emit the `depthUpdate` format for partial depth (V3 docs).
264#[derive(Debug, Deserialize)]
265pub struct DiffDepthEvent {
266    #[serde(rename = "e")]
267    pub event_type: String,
268    #[serde(rename = "E")]
269    pub event_time: u64,
270    /// Transaction time — present in V3 doc; absent on older deployments.
271    #[serde(rename = "T", default)]
272    pub transaction_time: u64,
273    #[serde(rename = "s")]
274    pub symbol: String,
275    #[serde(rename = "U")]
276    pub first_update_id: u64,
277    #[serde(rename = "u")]
278    pub final_update_id: u64,
279    #[serde(rename = "pu", default)]
280    pub prev_final_update_id: u64,
281    #[serde(rename = "b")]
282    pub bids: Vec<[String; 2]>,
283    #[serde(rename = "a")]
284    pub asks: Vec<[String; 2]>,
285}
286
287// ---------------------------------------------------------------------------
288// WebSocketMessage enum — manual Deserialize impl
289// ---------------------------------------------------------------------------
290
291/// Typed WebSocket message.
292///
293/// Deserialization dispatches on the `e` (event_type) field for object messages.
294/// Array JSON (all-symbol streams such as `!markPrice@arr`) is detected by the
295/// top-level value being a JSON array and dispatched on the first element's `e`.
296///
297/// Special cases without an `"e"` field (legacy Binance-compatible deployments):
298/// - `PartialDepth` — detected by the presence of `"lastUpdateId"`.
299/// - `BookTicker` — detected by `"u"` key with no `"e"` field. V3 deployments
300///   emit `"e": "bookTicker"` and are dispatched through the normal match path.
301///
302/// Any unrecognised or malformed message is mapped to `Unknown(serde_json::Value)`
303/// and a `tracing::warn!` is emitted — the SDK never panics on unknown events.
304#[derive(Debug)]
305pub enum WebSocketMessage {
306    /// Single aggregate trade event (`"e": "aggTrade"`).
307    AggTrade(AggTradeEvent),
308    /// Single mark price update (`"e": "markPriceUpdate"`).
309    MarkPrice(MarkPriceEvent),
310    /// All-symbol mark price array (`!markPrice@arr`).
311    AllMarkPrices(Vec<MarkPriceEvent>),
312    /// Kline/candlestick event (`"e": "kline"`).
313    Kline(KlineEvent),
314    /// 24-hour mini ticker for a single symbol.
315    MiniTicker(MiniTickerEvent),
316    /// All-symbol 24-hour mini tickers (`!miniTicker@arr`).
317    AllMiniTickers(Vec<MiniTickerEvent>),
318    /// Full 24-hour ticker for a single symbol.
319    Ticker(TickerEvent),
320    /// All-symbol full 24-hour tickers.
321    AllTickers(Vec<TickerEvent>),
322    /// Best bid/ask book ticker (detected by `"u"` key without `"e"`).
323    BookTicker(BookTickerEvent),
324    /// Liquidation force order event (`"e": "forceOrder"`).
325    ForceOrder(ForceOrderEvent),
326    /// All liquidation force orders.
327    AllForceOrders(Vec<ForceOrderEvent>),
328    /// Partial depth snapshot (detected by `"lastUpdateId"` key).
329    PartialDepth(PartialDepthEvent),
330    /// Differential depth update (`"e": "depthUpdate"`).
331    DiffDepth(DiffDepthEvent),
332    /// Unknown or unrecognised message — captured as raw JSON for forward compatibility.
333    Unknown(serde_json::Value),
334}
335
336// US-011: manual Deserialize for WebSocketMessage — dispatches on "e" field.
337impl<'de> serde::Deserialize<'de> for WebSocketMessage {
338    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
339        let v = serde_json::Value::deserialize(deserializer)?;
340
341        // ---------------------------------------------------------------
342        // Array = all-symbol stream (e.g. !markPrice@arr, !miniTicker@arr)
343        // ---------------------------------------------------------------
344        if let Some(arr) = v.as_array() {
345            if arr.is_empty() {
346                tracing::warn!("Received empty WebSocket array message");
347                return Ok(WebSocketMessage::Unknown(v));
348            }
349            let first = &arr[0];
350            match first.get("e").and_then(|e| e.as_str()).unwrap_or("") {
351                "markPriceUpdate" => {
352                    match serde_json::from_value::<Vec<MarkPriceEvent>>(v.clone()) {
353                        Ok(events) => return Ok(WebSocketMessage::AllMarkPrices(events)),
354                        Err(err) => {
355                            tracing::warn!(error = ?err, "Failed to parse AllMarkPrices array");
356                            return Ok(WebSocketMessage::Unknown(v));
357                        }
358                    }
359                }
360                "24hrMiniTicker" => {
361                    match serde_json::from_value::<Vec<MiniTickerEvent>>(v.clone()) {
362                        Ok(events) => return Ok(WebSocketMessage::AllMiniTickers(events)),
363                        Err(err) => {
364                            tracing::warn!(error = ?err, "Failed to parse AllMiniTickers array");
365                            return Ok(WebSocketMessage::Unknown(v));
366                        }
367                    }
368                }
369                "24hrTicker" => {
370                    match serde_json::from_value::<Vec<TickerEvent>>(v.clone()) {
371                        Ok(events) => return Ok(WebSocketMessage::AllTickers(events)),
372                        Err(err) => {
373                            tracing::warn!(error = ?err, "Failed to parse AllTickers array");
374                            return Ok(WebSocketMessage::Unknown(v));
375                        }
376                    }
377                }
378                "forceOrder" => {
379                    match serde_json::from_value::<Vec<ForceOrderEvent>>(v.clone()) {
380                        Ok(events) => return Ok(WebSocketMessage::AllForceOrders(events)),
381                        Err(err) => {
382                            tracing::warn!(error = ?err, "Failed to parse AllForceOrders array");
383                            return Ok(WebSocketMessage::Unknown(v));
384                        }
385                    }
386                }
387                other => {
388                    tracing::warn!(event_type = other, "Unknown WebSocket array event type");
389                    return Ok(WebSocketMessage::Unknown(v));
390                }
391            }
392        }
393
394        // ---------------------------------------------------------------
395        // Object messages — check for special no-"e" cases first
396        // ---------------------------------------------------------------
397
398        // PartialDepth: has "lastUpdateId" but no "e" field.
399        if v.get("e").is_none() && v.get("lastUpdateId").is_some() {
400            match serde_json::from_value::<PartialDepthEvent>(v.clone()) {
401                Ok(event) => return Ok(WebSocketMessage::PartialDepth(event)),
402                Err(err) => {
403                    tracing::warn!(error = ?err, "Failed to parse PartialDepth event");
404                    return Ok(WebSocketMessage::Unknown(v));
405                }
406            }
407        }
408
409        // BookTicker: has "u" (update_id) but no "e" field.
410        if v.get("e").is_none() && v.get("u").is_some() {
411            match serde_json::from_value::<BookTickerEvent>(v.clone()) {
412                Ok(event) => return Ok(WebSocketMessage::BookTicker(event)),
413                Err(err) => {
414                    tracing::warn!(error = ?err, "Failed to parse BookTicker event");
415                    return Ok(WebSocketMessage::Unknown(v));
416                }
417            }
418        }
419
420        // ---------------------------------------------------------------
421        // Dispatch by "e" field value
422        // ---------------------------------------------------------------
423        let event_type = v.get("e").and_then(|e| e.as_str()).unwrap_or("");
424
425        /// Helper: deserialize or fall back to Unknown with a warning.
426        macro_rules! parse_event {
427            ($variant:ident, $type:ty, $label:literal) => {
428                match serde_json::from_value::<$type>(v.clone()) {
429                    Ok(event) => Ok(WebSocketMessage::$variant(event)),
430                    Err(err) => {
431                        tracing::warn!(
432                            error = ?err,
433                            event_type = $label,
434                            "Failed to parse known WebSocket event; falling back to Unknown"
435                        );
436                        Ok(WebSocketMessage::Unknown(v))
437                    }
438                }
439            };
440        }
441
442        match event_type {
443            "aggTrade" => parse_event!(AggTrade, AggTradeEvent, "aggTrade"),
444            "markPriceUpdate" => parse_event!(MarkPrice, MarkPriceEvent, "markPriceUpdate"),
445            "kline" => parse_event!(Kline, KlineEvent, "kline"),
446            "24hrMiniTicker" => parse_event!(MiniTicker, MiniTickerEvent, "24hrMiniTicker"),
447            "24hrTicker" => parse_event!(Ticker, TickerEvent, "24hrTicker"),
448            "bookTicker" => parse_event!(BookTicker, BookTickerEvent, "bookTicker"),
449            "forceOrder" => parse_event!(ForceOrder, ForceOrderEvent, "forceOrder"),
450            "depthUpdate" => parse_event!(DiffDepth, DiffDepthEvent, "depthUpdate"),
451            other => {
452                tracing::warn!(event_type = other, "Unknown WebSocket message event type");
453                Ok(WebSocketMessage::Unknown(v))
454            }
455        }
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Unit tests
461// ---------------------------------------------------------------------------
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn agg_trade_deserializes_correctly() {
469        let json = r#"{
470            "e": "aggTrade",
471            "E": 1625184000000,
472            "s": "BTCUSDT",
473            "a": 12345,
474            "p": "30000.00",
475            "q": "0.001",
476            "f": 100,
477            "l": 105,
478            "T": 1625184000000,
479            "m": false
480        }"#;
481        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
482        assert!(matches!(msg, WebSocketMessage::AggTrade(ref e) if e.symbol == "BTCUSDT"));
483        if let WebSocketMessage::AggTrade(event) = msg {
484            assert_eq!(event.price, "30000.00");
485            assert_eq!(event.quantity, "0.001");
486            assert!(!event.is_buyer_maker);
487        }
488    }
489
490    #[test]
491    fn unknown_event_type_maps_to_unknown_variant() {
492        let json = r#"{"e":"futureData","x":"someValue"}"#;
493        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
494        assert!(matches!(msg, WebSocketMessage::Unknown(_)));
495    }
496
497    #[test]
498    fn kline_event_deserializes_correctly() {
499        let json = r#"{
500            "e": "kline",
501            "E": 1625184000000,
502            "s": "BTCUSDT",
503            "k": {
504                "t": 1625184000000, "T": 1625184059999, "s": "BTCUSDT",
505                "i": "1m", "f": 1, "L": 10,
506                "o": "30000", "c": "30100", "h": "30200", "l": "29900",
507                "v": "100", "n": 10, "x": false,
508                "q": "3010000", "V": "50", "Q": "1505000"
509            }
510        }"#;
511        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
512        assert!(matches!(msg, WebSocketMessage::Kline(ref e) if e.kline.interval == "1m"));
513        if let WebSocketMessage::Kline(event) = msg {
514            assert!(!event.kline.is_closed);
515            assert_eq!(event.kline.open, "30000");
516            assert_eq!(event.kline.close, "30100");
517        }
518    }
519
520    #[test]
521    fn partial_depth_deserializes_correctly() {
522        let json = r#"{"lastUpdateId":123456,"bids":[["30000","1.5"],["29999","2.0"]],"asks":[["30001","0.5"]]}"#;
523        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
524        assert!(matches!(msg, WebSocketMessage::PartialDepth(_)));
525        if let WebSocketMessage::PartialDepth(event) = msg {
526            assert_eq!(event.bids[0][0], "30000");
527            assert_eq!(event.bids[0][1], "1.5");
528        }
529    }
530
531    #[test]
532    fn all_mark_prices_array_deserializes_correctly() {
533        let json = r#"[{"e":"markPriceUpdate","E":1625184000000,"s":"BTCUSDT","p":"30000","i":"30001","P":"29999","r":"0.0001","T":1625198400000}]"#;
534        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
535        assert!(matches!(msg, WebSocketMessage::AllMarkPrices(_)));
536        if let WebSocketMessage::AllMarkPrices(events) = msg {
537            assert_eq!(events.len(), 1);
538            assert_eq!(events[0].symbol, "BTCUSDT");
539        }
540    }
541
542    #[test]
543    fn mark_price_single_deserializes_correctly() {
544        let json = r#"{
545            "e": "markPriceUpdate",
546            "E": 1625184000000,
547            "s": "BTCUSDT",
548            "p": "30000.00",
549            "i": "30001.00",
550            "P": "29999.00",
551            "r": "0.0001",
552            "T": 1625198400000
553        }"#;
554        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
555        assert!(matches!(msg, WebSocketMessage::MarkPrice(_)));
556        if let WebSocketMessage::MarkPrice(event) = msg {
557            assert_eq!(event.symbol, "BTCUSDT");
558            assert_eq!(event.mark_price, "30000.00");
559            assert_eq!(event.last_funding_rate, "0.0001");
560            assert_eq!(event.next_funding_time, 1625198400000);
561        }
562    }
563
564    #[test]
565    fn book_ticker_deserializes_correctly() {
566        let json = r#"{"u":12345,"s":"BTCUSDT","b":"30000","B":"1.5","a":"30001","A":"0.5"}"#;
567        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
568        assert!(matches!(msg, WebSocketMessage::BookTicker(ref e) if e.symbol == "BTCUSDT"));
569        if let WebSocketMessage::BookTicker(event) = msg {
570            assert_eq!(event.best_bid_price, "30000");
571            assert_eq!(event.best_ask_price, "30001");
572        }
573    }
574
575    #[test]
576    fn diff_depth_deserializes_correctly() {
577        let json = r#"{
578            "e": "depthUpdate",
579            "E": 1625184000000,
580            "T": 1625183999000,
581            "s": "BTCUSDT",
582            "U": 100,
583            "u": 200,
584            "pu": 99,
585            "b": [["29999","1.0"],["29998","2.0"]],
586            "a": [["30001","0.5"]]
587        }"#;
588        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
589        assert!(matches!(msg, WebSocketMessage::DiffDepth(ref e) if e.symbol == "BTCUSDT"));
590        if let WebSocketMessage::DiffDepth(event) = msg {
591            assert_eq!(event.first_update_id, 100);
592            assert_eq!(event.final_update_id, 200);
593            assert_eq!(event.prev_final_update_id, 99);
594            assert_eq!(event.transaction_time, 1625183999000);
595            assert_eq!(event.bids[0][0], "29999");
596        }
597    }
598
599    #[test]
600    fn diff_depth_without_transaction_time_uses_default() {
601        // Legacy deployments may not emit "T"
602        let json = r#"{
603            "e": "depthUpdate",
604            "E": 1625184000000,
605            "s": "BTCUSDT",
606            "U": 100,
607            "u": 200,
608            "b": [["29999","1.0"]],
609            "a": [["30001","0.5"]]
610        }"#;
611        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
612        if let WebSocketMessage::DiffDepth(event) = msg {
613            assert_eq!(event.transaction_time, 0);
614        } else {
615            panic!("expected DiffDepth");
616        }
617    }
618
619    #[test]
620    fn book_ticker_v3_with_event_type_deserializes() {
621        // V3 doc format: includes "e":"bookTicker", "E", "T"
622        let json = r#"{
623            "e": "bookTicker",
624            "u": 400900217,
625            "E": 1568014460893,
626            "T": 1568014460891,
627            "s": "BNBUSDT",
628            "b": "25.35190000",
629            "B": "31.21000000",
630            "a": "25.36520000",
631            "A": "40.66000000"
632        }"#;
633        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
634        assert!(matches!(msg, WebSocketMessage::BookTicker(ref e) if e.symbol == "BNBUSDT"));
635        if let WebSocketMessage::BookTicker(event) = msg {
636            assert_eq!(event.event_type, "bookTicker");
637            assert_eq!(event.event_time, 1568014460893);
638            assert_eq!(event.transaction_time, 1568014460891);
639            assert_eq!(event.update_id, 400900217);
640        }
641    }
642
643    #[test]
644    fn mini_ticker_deserializes_correctly() {
645        let json = r#"{
646            "e": "24hrMiniTicker",
647            "E": 1625184000000,
648            "s": "ETHUSDT",
649            "c": "2000.00",
650            "o": "1950.00",
651            "h": "2050.00",
652            "l": "1900.00",
653            "v": "5000",
654            "q": "9750000"
655        }"#;
656        let msg: WebSocketMessage = serde_json::from_str(json).unwrap();
657        assert!(matches!(msg, WebSocketMessage::MiniTicker(ref e) if e.symbol == "ETHUSDT"));
658        if let WebSocketMessage::MiniTicker(event) = msg {
659            assert_eq!(event.close_price, "2000.00");
660            assert_eq!(event.volume, "5000");
661        }
662    }
663}