Skip to main content

nautilus_coinbase/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 Coinbase Advanced Trade API.
17//!
18//! All incoming messages share an envelope with `channel`, `timestamp`,
19//! `sequence_num`, and a channel-specific `events` array. Outgoing
20//! subscription messages use a flat format with `type`, `product_ids`,
21//! `channel`, and `jwt`.
22
23use std::collections::HashMap;
24
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use crate::common::{
30    enums::{
31        CoinbaseContractExpiryType, CoinbaseMarginLevel, CoinbaseMarginWindowType,
32        CoinbaseOrderSide, CoinbaseOrderStatus, CoinbaseOrderType, CoinbaseProductStatus,
33        CoinbaseProductType, CoinbaseRiskManagedBy, CoinbaseTimeInForce, CoinbaseTriggerStatus,
34        CoinbaseWsChannel,
35    },
36    parse::deserialize_decimal_from_str,
37};
38
39/// Subscribe or unsubscribe request sent to the WebSocket.
40///
41/// Public channels (`level2`, `market_trades`, `ticker`, etc.) do not require
42/// a JWT. Set `jwt` to `None` for unauthenticated subscriptions; the field
43/// is omitted from the serialized JSON.
44#[derive(Debug, Clone, Serialize)]
45pub struct CoinbaseWsSubscription {
46    /// `"subscribe"` or `"unsubscribe"`.
47    #[serde(rename = "type")]
48    pub msg_type: CoinbaseWsAction,
49    /// Product IDs to subscribe to (omitted for channel-level subscriptions).
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub product_ids: Vec<Ustr>,
52    /// Channel name (subscription-side, e.g. `level2`).
53    pub channel: CoinbaseWsChannel,
54    /// JWT for authentication (required for `user` and `futures_balance_summary`).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub jwt: Option<String>,
57}
58
59/// WebSocket subscription action type.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum CoinbaseWsAction {
63    Subscribe,
64    Unsubscribe,
65}
66
67/// Top-level WebSocket message dispatched by channel.
68///
69/// Uses serde internally-tagged enum on the `channel` field so each variant
70/// deserializes only the events relevant to that channel.
71#[derive(Debug, Clone, Deserialize)]
72#[serde(tag = "channel")]
73pub enum CoinbaseWsMessage {
74    /// Order book snapshot or incremental update.
75    #[serde(rename = "l2_data")]
76    L2Data {
77        timestamp: String,
78        sequence_num: u64,
79        events: Vec<WsL2DataEvent>,
80    },
81
82    /// Market trade executions.
83    #[serde(rename = "market_trades")]
84    MarketTrades {
85        timestamp: String,
86        sequence_num: u64,
87        events: Vec<WsMarketTradesEvent>,
88    },
89
90    /// Price ticker for a single product.
91    #[serde(rename = "ticker")]
92    Ticker {
93        timestamp: String,
94        sequence_num: u64,
95        events: Vec<WsTickerEvent>,
96    },
97
98    /// Batched ticker updates for multiple products.
99    #[serde(rename = "ticker_batch")]
100    TickerBatch {
101        timestamp: String,
102        sequence_num: u64,
103        events: Vec<WsTickerEvent>,
104    },
105
106    /// OHLC candle updates.
107    #[serde(rename = "candles")]
108    Candles {
109        timestamp: String,
110        sequence_num: u64,
111        events: Vec<WsCandlesEvent>,
112    },
113
114    /// User order status updates.
115    ///
116    /// The feed handler deserializes this channel but ignores it until the
117    /// execution client is wired.
118    #[serde(rename = "user")]
119    User {
120        timestamp: String,
121        sequence_num: u64,
122        events: Vec<WsUserEvent>,
123    },
124
125    /// Connection heartbeat.
126    #[serde(rename = "heartbeats")]
127    Heartbeats {
128        timestamp: String,
129        sequence_num: u64,
130        events: Vec<WsHeartbeatEvent>,
131    },
132
133    /// Futures balance summary (requires auth).
134    ///
135    /// The feed handler deserializes this channel but ignores it until account
136    /// state handling is added.
137    #[serde(rename = "futures_balance_summary")]
138    FuturesBalanceSummary {
139        timestamp: String,
140        sequence_num: u64,
141        events: Vec<WsFuturesBalanceSummaryEvent>,
142    },
143
144    /// System status updates.
145    ///
146    /// The feed handler deserializes this channel but ignores it until venue
147    /// status handling is added.
148    #[serde(rename = "status")]
149    Status {
150        timestamp: String,
151        sequence_num: u64,
152        events: Vec<WsStatusEvent>,
153    },
154
155    /// Subscription confirmation.
156    #[serde(rename = "subscriptions")]
157    Subscriptions {
158        timestamp: String,
159        sequence_num: u64,
160        events: Vec<WsSubscriptionsEvent>,
161    },
162}
163
164/// L2 data event containing book updates.
165#[derive(Debug, Clone, Deserialize)]
166pub struct WsL2DataEvent {
167    /// `"snapshot"` for initial state, `"update"` for incremental.
168    #[serde(rename = "type")]
169    pub event_type: WsEventType,
170    pub product_id: Ustr,
171    pub updates: Vec<WsL2Update>,
172}
173
174/// A single order book level update.
175#[derive(Debug, Clone, Deserialize)]
176pub struct WsL2Update {
177    pub side: WsBookSide,
178    pub event_time: String,
179    pub price_level: String,
180    pub new_quantity: String,
181}
182
183/// Book side in L2 data messages.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum WsBookSide {
187    Bid,
188    Offer,
189}
190
191/// Market trades event.
192#[derive(Debug, Clone, Deserialize)]
193pub struct WsMarketTradesEvent {
194    /// `"snapshot"` or `"update"`.
195    #[serde(rename = "type")]
196    pub event_type: WsEventType,
197    pub trades: Vec<WsTrade>,
198}
199
200/// A single trade from the market_trades channel.
201#[derive(Debug, Clone, Deserialize)]
202pub struct WsTrade {
203    pub trade_id: String,
204    pub product_id: Ustr,
205    pub price: String,
206    pub size: String,
207    pub side: CoinbaseOrderSide,
208    pub time: String,
209}
210
211/// Ticker event.
212#[derive(Debug, Clone, Deserialize)]
213pub struct WsTickerEvent {
214    /// `"snapshot"` or `"update"`.
215    #[serde(rename = "type")]
216    pub event_type: WsEventType,
217    pub tickers: Vec<WsTicker>,
218}
219
220/// Ticker data for a single product.
221#[derive(Debug, Clone, Deserialize)]
222pub struct WsTicker {
223    pub product_id: Ustr,
224    pub price: String,
225    pub volume_24_h: String,
226    pub low_24_h: String,
227    pub high_24_h: String,
228    #[serde(default)]
229    pub low_52_w: String,
230    #[serde(default)]
231    pub high_52_w: String,
232    pub price_percent_chg_24_h: String,
233    pub best_bid: String,
234    pub best_bid_quantity: String,
235    pub best_ask: String,
236    pub best_ask_quantity: String,
237}
238
239/// Candles event.
240#[derive(Debug, Clone, Deserialize)]
241pub struct WsCandlesEvent {
242    /// `"snapshot"` or `"update"`.
243    #[serde(rename = "type")]
244    pub event_type: WsEventType,
245    pub candles: Vec<WsCandle>,
246}
247
248/// A single candle from the candles channel.
249#[derive(Debug, Clone, Deserialize)]
250pub struct WsCandle {
251    pub start: String,
252    pub high: String,
253    pub low: String,
254    pub open: String,
255    pub close: String,
256    pub volume: String,
257    pub product_id: Ustr,
258}
259
260/// User event containing order status updates.
261#[derive(Debug, Clone, Deserialize)]
262pub struct WsUserEvent {
263    /// `"snapshot"` or `"update"`.
264    #[serde(rename = "type")]
265    pub event_type: WsEventType,
266    pub orders: Vec<WsOrderUpdate>,
267}
268
269/// Order status update from the user channel.
270#[derive(Debug, Clone, Deserialize)]
271pub struct WsOrderUpdate {
272    pub order_id: String,
273    pub client_order_id: String,
274    pub contract_expiry_type: CoinbaseContractExpiryType,
275    pub cumulative_quantity: String,
276    pub leaves_quantity: String,
277    pub avg_price: String,
278    pub total_fees: String,
279    pub status: CoinbaseOrderStatus,
280    pub product_id: Ustr,
281    pub product_type: CoinbaseProductType,
282    pub creation_time: String,
283    pub order_side: CoinbaseOrderSide,
284    pub order_type: CoinbaseOrderType,
285    pub risk_managed_by: CoinbaseRiskManagedBy,
286    pub time_in_force: CoinbaseTimeInForce,
287    pub trigger_status: CoinbaseTriggerStatus,
288    #[serde(default)]
289    pub cancel_reason: String,
290    #[serde(default)]
291    pub reject_reason: String,
292    #[serde(default)]
293    pub total_value_after_fees: String,
294}
295
296/// Heartbeat event.
297#[derive(Debug, Clone, Deserialize)]
298pub struct WsHeartbeatEvent {
299    pub current_time: String,
300    pub heartbeat_counter: u64,
301}
302
303/// Futures balance summary event.
304#[derive(Debug, Clone, Deserialize)]
305pub struct WsFuturesBalanceSummaryEvent {
306    #[serde(rename = "type")]
307    pub event_type: WsEventType,
308    pub fcm_balance_summary: WsFcmBalanceSummary,
309}
310
311/// Futures balance summary snapshot.
312#[derive(Debug, Clone, Deserialize)]
313pub struct WsFcmBalanceSummary {
314    #[serde(deserialize_with = "deserialize_decimal_from_str")]
315    pub futures_buying_power: Decimal,
316    #[serde(deserialize_with = "deserialize_decimal_from_str")]
317    pub total_usd_balance: Decimal,
318    #[serde(deserialize_with = "deserialize_decimal_from_str")]
319    pub cbi_usd_balance: Decimal,
320    #[serde(deserialize_with = "deserialize_decimal_from_str")]
321    pub cfm_usd_balance: Decimal,
322    #[serde(deserialize_with = "deserialize_decimal_from_str")]
323    pub total_open_orders_hold_amount: Decimal,
324    #[serde(deserialize_with = "deserialize_decimal_from_str")]
325    pub unrealized_pnl: Decimal,
326    #[serde(deserialize_with = "deserialize_decimal_from_str")]
327    pub daily_realized_pnl: Decimal,
328    #[serde(deserialize_with = "deserialize_decimal_from_str")]
329    pub initial_margin: Decimal,
330    #[serde(deserialize_with = "deserialize_decimal_from_str")]
331    pub available_margin: Decimal,
332    #[serde(deserialize_with = "deserialize_decimal_from_str")]
333    pub liquidation_threshold: Decimal,
334    #[serde(deserialize_with = "deserialize_decimal_from_str")]
335    pub liquidation_buffer_amount: Decimal,
336    #[serde(deserialize_with = "deserialize_decimal_from_str")]
337    pub liquidation_buffer_percentage: Decimal,
338    pub intraday_margin_window_measure: WsMarginWindowMeasure,
339    pub overnight_margin_window_measure: WsMarginWindowMeasure,
340}
341
342/// Margin window summary inside a futures balance snapshot.
343#[derive(Debug, Clone, Deserialize)]
344pub struct WsMarginWindowMeasure {
345    pub margin_window_type: CoinbaseMarginWindowType,
346    pub margin_level: CoinbaseMarginLevel,
347    #[serde(deserialize_with = "deserialize_decimal_from_str")]
348    pub initial_margin: Decimal,
349    #[serde(deserialize_with = "deserialize_decimal_from_str")]
350    pub maintenance_margin: Decimal,
351    #[serde(deserialize_with = "deserialize_decimal_from_str")]
352    pub liquidation_buffer_percentage: Decimal,
353    #[serde(deserialize_with = "deserialize_decimal_from_str")]
354    pub total_hold: Decimal,
355    #[serde(deserialize_with = "deserialize_decimal_from_str")]
356    pub futures_buying_power: Decimal,
357}
358
359/// Status channel event.
360#[derive(Debug, Clone, Deserialize)]
361pub struct WsStatusEvent {
362    #[serde(rename = "type")]
363    pub event_type: WsEventType,
364    #[serde(default)]
365    pub products: Vec<WsStatusProduct>,
366}
367
368/// Status channel product snapshot.
369#[derive(Debug, Clone, Deserialize)]
370pub struct WsStatusProduct {
371    pub product_type: CoinbaseProductType,
372    pub id: Ustr,
373    pub base_currency: Ustr,
374    pub quote_currency: Ustr,
375    pub base_increment: String,
376    pub quote_increment: String,
377    pub display_name: String,
378    pub status: CoinbaseProductStatus,
379    pub status_message: String,
380    #[serde(deserialize_with = "deserialize_decimal_from_str")]
381    pub min_market_funds: Decimal,
382}
383
384/// Subscription confirmation event.
385#[derive(Debug, Clone, Deserialize)]
386pub struct WsSubscriptionsEvent {
387    pub subscriptions: HashMap<CoinbaseWsChannel, Vec<Ustr>>,
388}
389
390/// Event type discriminator for snapshot vs incremental update.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum WsEventType {
394    Snapshot,
395    Update,
396}
397
398#[cfg(test)]
399mod tests {
400    use rstest::rstest;
401
402    use super::*;
403    use crate::common::testing::load_test_fixture;
404
405    #[rstest]
406    fn test_deserialize_l2_snapshot() {
407        let json = load_test_fixture("ws_l2_data_snapshot.json");
408        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
409
410        match msg {
411            CoinbaseWsMessage::L2Data {
412                timestamp,
413                sequence_num,
414                events,
415            } => {
416                assert!(!timestamp.is_empty());
417                assert_eq!(sequence_num, 0);
418                assert_eq!(events.len(), 1);
419
420                let event = &events[0];
421                assert_eq!(event.event_type, WsEventType::Snapshot);
422                assert_eq!(event.product_id, "BTC-USD");
423                assert!(!event.updates.is_empty());
424
425                let bid = event
426                    .updates
427                    .iter()
428                    .find(|u| u.side == WsBookSide::Bid)
429                    .expect("should have a bid update");
430                assert!(!bid.price_level.is_empty());
431                assert!(!bid.new_quantity.is_empty());
432            }
433            other => panic!("Expected L2Data, was {other:?}"),
434        }
435    }
436
437    #[rstest]
438    fn test_deserialize_l2_update() {
439        let json = load_test_fixture("ws_l2_data_update.json");
440        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
441
442        match msg {
443            CoinbaseWsMessage::L2Data {
444                sequence_num,
445                events,
446                ..
447            } => {
448                assert!(sequence_num > 0);
449                assert_eq!(events[0].event_type, WsEventType::Update);
450            }
451            other => panic!("Expected L2Data, was {other:?}"),
452        }
453    }
454
455    #[rstest]
456    fn test_deserialize_market_trades() {
457        let json = load_test_fixture("ws_market_trades.json");
458        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
459
460        match msg {
461            CoinbaseWsMessage::MarketTrades { events, .. } => {
462                assert_eq!(events.len(), 1);
463                assert!(!events[0].trades.is_empty());
464
465                let trade = &events[0].trades[0];
466                assert_eq!(trade.product_id, "BTC-USD");
467                assert!(!trade.price.is_empty());
468                assert!(!trade.size.is_empty());
469                assert!(!trade.trade_id.is_empty());
470            }
471            other => panic!("Expected MarketTrades, was {other:?}"),
472        }
473    }
474
475    #[rstest]
476    fn test_deserialize_ticker() {
477        let json = load_test_fixture("ws_ticker.json");
478        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
479
480        match msg {
481            CoinbaseWsMessage::Ticker { events, .. } => {
482                assert_eq!(events.len(), 1);
483                assert!(!events[0].tickers.is_empty());
484
485                let ticker = &events[0].tickers[0];
486                assert_eq!(ticker.product_id, "BTC-USD");
487                assert!(!ticker.best_bid.is_empty());
488                assert!(!ticker.best_ask.is_empty());
489                assert!(!ticker.best_bid_quantity.is_empty());
490                assert!(!ticker.best_ask_quantity.is_empty());
491            }
492            other => panic!("Expected Ticker, was {other:?}"),
493        }
494    }
495
496    #[rstest]
497    fn test_deserialize_candles() {
498        let json = load_test_fixture("ws_candles.json");
499        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
500
501        match msg {
502            CoinbaseWsMessage::Candles { events, .. } => {
503                assert_eq!(events.len(), 1);
504                assert!(!events[0].candles.is_empty());
505
506                let candle = &events[0].candles[0];
507                assert_eq!(candle.product_id, "BTC-USD");
508                assert!(!candle.open.is_empty());
509                assert!(!candle.high.is_empty());
510                assert!(!candle.low.is_empty());
511                assert!(!candle.close.is_empty());
512                assert!(!candle.volume.is_empty());
513            }
514            other => panic!("Expected Candles, was {other:?}"),
515        }
516    }
517
518    #[rstest]
519    fn test_deserialize_user_order_update() {
520        let json = load_test_fixture("ws_user.json");
521        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
522
523        match msg {
524            CoinbaseWsMessage::User { events, .. } => {
525                assert_eq!(events.len(), 1);
526                assert!(!events[0].orders.is_empty());
527
528                let order = &events[0].orders[0];
529                assert!(!order.order_id.is_empty());
530                assert_eq!(order.product_id, "BTC-USD");
531                assert_eq!(order.status, CoinbaseOrderStatus::Open);
532                assert_eq!(order.order_side, CoinbaseOrderSide::Buy);
533                assert_eq!(order.order_type, CoinbaseOrderType::Limit);
534                assert_eq!(
535                    order.contract_expiry_type,
536                    CoinbaseContractExpiryType::Unknown
537                );
538                assert_eq!(order.product_type, CoinbaseProductType::Spot);
539                assert_eq!(order.risk_managed_by, CoinbaseRiskManagedBy::Unknown);
540                assert_eq!(order.time_in_force, CoinbaseTimeInForce::GoodUntilCancelled);
541                assert_eq!(
542                    order.trigger_status,
543                    CoinbaseTriggerStatus::InvalidOrderType
544                );
545            }
546            other => panic!("Expected User, was {other:?}"),
547        }
548    }
549
550    #[rstest]
551    fn test_deserialize_heartbeat() {
552        let json = load_test_fixture("ws_heartbeats.json");
553        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
554
555        match msg {
556            CoinbaseWsMessage::Heartbeats { events, .. } => {
557                assert_eq!(events.len(), 1);
558                assert!(!events[0].current_time.is_empty());
559                assert!(events[0].heartbeat_counter > 0);
560            }
561            other => panic!("Expected Heartbeats, was {other:?}"),
562        }
563    }
564
565    #[rstest]
566    fn test_deserialize_status_channel() {
567        let json = r#"{
568          "channel": "status",
569          "client_id": "",
570          "timestamp": "2023-02-09T20:29:49.753424311Z",
571          "sequence_num": 0,
572          "events": [
573            {
574              "type": "snapshot",
575              "products": [
576                {
577                  "product_type": "SPOT",
578                  "id": "BTC-USD",
579                  "base_currency": "BTC",
580                  "quote_currency": "USD",
581                  "base_increment": "0.00000001",
582                  "quote_increment": "0.01",
583                  "display_name": "BTC/USD",
584                  "status": "online",
585                  "status_message": "",
586                  "min_market_funds": "1"
587                }
588              ]
589            }
590          ]
591        }"#;
592        let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
593
594        match msg {
595            CoinbaseWsMessage::Status { events, .. } => {
596                assert_eq!(events.len(), 1);
597                assert_eq!(events[0].event_type, WsEventType::Snapshot);
598                assert_eq!(events[0].products.len(), 1);
599                let product = &events[0].products[0];
600                assert_eq!(product.id, "BTC-USD");
601                assert_eq!(product.product_type, CoinbaseProductType::Spot);
602                assert_eq!(product.status, CoinbaseProductStatus::Online);
603                assert_eq!(product.min_market_funds, Decimal::ONE);
604            }
605            other => panic!("Expected Status, was {other:?}"),
606        }
607    }
608
609    #[rstest]
610    fn test_deserialize_futures_balance_summary_channel() {
611        let json = r#"{
612          "channel": "futures_balance_summary",
613          "client_id": "",
614          "timestamp": "2023-02-09T20:33:57.609931463Z",
615          "sequence_num": 0,
616          "events": [
617            {
618              "type": "snapshot",
619              "fcm_balance_summary": {
620                "futures_buying_power": "100.00",
621                "total_usd_balance": "200.00",
622                "cbi_usd_balance": "300.00",
623                "cfm_usd_balance": "400.00",
624                "total_open_orders_hold_amount": "500.00",
625                "unrealized_pnl": "600.00",
626                "daily_realized_pnl": "0",
627                "initial_margin": "700.00",
628                "available_margin": "800.00",
629                "liquidation_threshold": "900.00",
630                "liquidation_buffer_amount": "1000.00",
631                "liquidation_buffer_percentage": "1000",
632                "intraday_margin_window_measure": {
633                  "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_INTRADAY",
634                  "margin_level": "MARGIN_LEVEL_TYPE_BASE",
635                  "initial_margin": "100.00",
636                  "maintenance_margin": "200.00",
637                  "liquidation_buffer_percentage": "1000",
638                  "total_hold": "100.00",
639                  "futures_buying_power": "400.00"
640                },
641                "overnight_margin_window_measure": {
642                  "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_OVERNIGHT",
643                  "margin_level": "MARGIN_LEVEL_TYPE_BASE",
644                  "initial_margin": "300.00",
645                  "maintenance_margin": "200.00",
646                  "liquidation_buffer_percentage": "1000",
647                  "total_hold": "-30.00",
648                  "futures_buying_power": "2000.00"
649                }
650              }
651            }
652          ]
653        }"#;
654        let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
655
656        match msg {
657            CoinbaseWsMessage::FuturesBalanceSummary { events, .. } => {
658                assert_eq!(events.len(), 1);
659                assert_eq!(events[0].event_type, WsEventType::Snapshot);
660                let summary = &events[0].fcm_balance_summary;
661                assert_eq!(summary.futures_buying_power, Decimal::from(100));
662                assert_eq!(summary.daily_realized_pnl, Decimal::ZERO);
663                assert_eq!(
664                    summary.intraday_margin_window_measure.margin_window_type,
665                    CoinbaseMarginWindowType::Intraday
666                );
667                assert_eq!(
668                    summary.overnight_margin_window_measure.margin_level,
669                    CoinbaseMarginLevel::Base
670                );
671                assert_eq!(
672                    summary.overnight_margin_window_measure.total_hold,
673                    "-30.00".parse::<Decimal>().unwrap()
674                );
675            }
676            other => panic!("Expected FuturesBalanceSummary, was {other:?}"),
677        }
678    }
679
680    #[rstest]
681    fn test_deserialize_subscriptions() {
682        let json = load_test_fixture("ws_subscriptions.json");
683        let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
684
685        match msg {
686            CoinbaseWsMessage::Subscriptions { events, .. } => {
687                assert_eq!(events.len(), 1);
688                assert_eq!(
689                    events[0].subscriptions.get(&CoinbaseWsChannel::Level2),
690                    Some(&vec![Ustr::from("BTC-USD")])
691                );
692                assert_eq!(
693                    events[0]
694                        .subscriptions
695                        .get(&CoinbaseWsChannel::MarketTrades),
696                    Some(&vec![Ustr::from("BTC-USD"), Ustr::from("ETH-USD")])
697                );
698            }
699            other => panic!("Expected Subscriptions, was {other:?}"),
700        }
701    }
702
703    #[rstest]
704    fn test_serialize_subscribe_request_with_jwt() {
705        let sub = CoinbaseWsSubscription {
706            msg_type: CoinbaseWsAction::Subscribe,
707            product_ids: vec![Ustr::from("BTC-USD")],
708            channel: CoinbaseWsChannel::User,
709            jwt: Some("test-jwt-token".to_string()),
710        };
711
712        let json = serde_json::to_value(&sub).unwrap();
713        assert_eq!(json["type"], "subscribe");
714        assert_eq!(json["channel"], "user");
715        assert_eq!(json["product_ids"][0], "BTC-USD");
716        assert_eq!(json["jwt"], "test-jwt-token");
717    }
718
719    #[rstest]
720    fn test_serialize_subscribe_request_public_omits_jwt() {
721        let sub = CoinbaseWsSubscription {
722            msg_type: CoinbaseWsAction::Subscribe,
723            product_ids: vec![Ustr::from("BTC-USD")],
724            channel: CoinbaseWsChannel::Level2,
725            jwt: None,
726        };
727
728        let json = serde_json::to_value(&sub).unwrap();
729        assert_eq!(json["type"], "subscribe");
730        assert_eq!(json["channel"], "level2");
731        assert!(json.get("jwt").is_none());
732    }
733
734    #[rstest]
735    fn test_serialize_unsubscribe_request() {
736        let sub = CoinbaseWsSubscription {
737            msg_type: CoinbaseWsAction::Unsubscribe,
738            product_ids: vec![Ustr::from("ETH-USD")],
739            channel: CoinbaseWsChannel::MarketTrades,
740            jwt: None,
741        };
742
743        let json = serde_json::to_value(&sub).unwrap();
744        assert_eq!(json["type"], "unsubscribe");
745        assert_eq!(json["channel"], "market_trades");
746        assert!(json.get("jwt").is_none());
747    }
748
749    #[rstest]
750    fn test_serialize_channel_level_subscription_omits_product_ids() {
751        let sub = CoinbaseWsSubscription {
752            msg_type: CoinbaseWsAction::Subscribe,
753            product_ids: vec![],
754            channel: CoinbaseWsChannel::Heartbeats,
755            jwt: None,
756        };
757
758        let json = serde_json::to_value(&sub).unwrap();
759        assert_eq!(json["type"], "subscribe");
760        assert_eq!(json["channel"], "heartbeats");
761        assert!(json.get("product_ids").is_none());
762        assert!(json.get("jwt").is_none());
763    }
764
765    #[rstest]
766    fn test_ws_event_type_values() {
767        let snapshot: WsEventType = serde_json::from_str("\"snapshot\"").unwrap();
768        assert_eq!(snapshot, WsEventType::Snapshot);
769
770        let update: WsEventType = serde_json::from_str("\"update\"").unwrap();
771        assert_eq!(update, WsEventType::Update);
772    }
773
774    #[rstest]
775    fn test_ws_book_side_values() {
776        let bid: WsBookSide = serde_json::from_str("\"bid\"").unwrap();
777        assert_eq!(bid, WsBookSide::Bid);
778
779        let offer: WsBookSide = serde_json::from_str("\"offer\"").unwrap();
780        assert_eq!(offer, WsBookSide::Offer);
781    }
782}