1use 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::{
37 deserialize_decimal_from_str, deserialize_product_status_or_unknown,
38 deserialize_product_type_or_unknown,
39 },
40};
41
42#[derive(Debug, Clone, Serialize)]
48pub struct CoinbaseWsSubscription {
49 #[serde(rename = "type")]
51 pub msg_type: CoinbaseWsAction,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
54 pub product_ids: Vec<Ustr>,
55 pub channel: CoinbaseWsChannel,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub jwt: Option<String>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum CoinbaseWsAction {
66 Subscribe,
67 Unsubscribe,
68}
69
70#[derive(Debug, Clone, Deserialize)]
75#[serde(tag = "channel")]
76pub enum CoinbaseWsMessage {
77 #[serde(rename = "l2_data")]
79 L2Data {
80 timestamp: String,
81 sequence_num: u64,
82 events: Vec<WsL2DataEvent>,
83 },
84
85 #[serde(rename = "market_trades")]
87 MarketTrades {
88 timestamp: String,
89 sequence_num: u64,
90 events: Vec<WsMarketTradesEvent>,
91 },
92
93 #[serde(rename = "ticker")]
95 Ticker {
96 timestamp: String,
97 sequence_num: u64,
98 events: Vec<WsTickerEvent>,
99 },
100
101 #[serde(rename = "ticker_batch")]
103 TickerBatch {
104 timestamp: String,
105 sequence_num: u64,
106 events: Vec<WsTickerEvent>,
107 },
108
109 #[serde(rename = "candles")]
111 Candles {
112 timestamp: String,
113 sequence_num: u64,
114 events: Vec<WsCandlesEvent>,
115 },
116
117 #[serde(rename = "user")]
122 User {
123 timestamp: String,
124 sequence_num: u64,
125 events: Vec<WsUserEvent>,
126 },
127
128 #[serde(rename = "heartbeats")]
130 Heartbeats {
131 timestamp: String,
132 sequence_num: u64,
133 events: Vec<WsHeartbeatEvent>,
134 },
135
136 #[serde(rename = "futures_balance_summary")]
141 FuturesBalanceSummary {
142 timestamp: String,
143 sequence_num: u64,
144 events: Vec<WsFuturesBalanceSummaryEvent>,
145 },
146
147 #[serde(rename = "status")]
152 Status {
153 timestamp: String,
154 sequence_num: u64,
155 events: Vec<WsStatusEvent>,
156 },
157
158 #[serde(rename = "subscriptions")]
160 Subscriptions {
161 timestamp: String,
162 sequence_num: u64,
163 events: Vec<WsSubscriptionsEvent>,
164 },
165}
166
167#[derive(Debug, Clone, Deserialize)]
169pub struct WsL2DataEvent {
170 #[serde(rename = "type")]
172 pub event_type: WsEventType,
173 pub product_id: Ustr,
174 pub updates: Vec<WsL2Update>,
175}
176
177#[derive(Debug, Clone, Deserialize)]
179pub struct WsL2Update {
180 pub side: WsBookSide,
181 pub event_time: String,
182 pub price_level: String,
183 pub new_quantity: String,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum WsBookSide {
190 Bid,
191 Offer,
192}
193
194#[derive(Debug, Clone, Deserialize)]
196pub struct WsMarketTradesEvent {
197 #[serde(rename = "type")]
199 pub event_type: WsEventType,
200 pub trades: Vec<WsTrade>,
201}
202
203#[derive(Debug, Clone, Deserialize)]
205pub struct WsTrade {
206 pub trade_id: String,
207 pub product_id: Ustr,
208 pub price: String,
209 pub size: String,
210 pub side: CoinbaseOrderSide,
211 pub time: String,
212}
213
214#[derive(Debug, Clone, Deserialize)]
216pub struct WsTickerEvent {
217 #[serde(rename = "type")]
219 pub event_type: WsEventType,
220 pub tickers: Vec<WsTicker>,
221}
222
223#[derive(Debug, Clone, Deserialize)]
225pub struct WsTicker {
226 pub product_id: Ustr,
227 pub price: String,
228 pub volume_24_h: String,
229 pub low_24_h: String,
230 pub high_24_h: String,
231 #[serde(default)]
232 pub low_52_w: String,
233 #[serde(default)]
234 pub high_52_w: String,
235 pub price_percent_chg_24_h: String,
236 pub best_bid: String,
237 pub best_bid_quantity: String,
238 pub best_ask: String,
239 pub best_ask_quantity: String,
240}
241
242#[derive(Debug, Clone, Deserialize)]
244pub struct WsCandlesEvent {
245 #[serde(rename = "type")]
247 pub event_type: WsEventType,
248 pub candles: Vec<WsCandle>,
249}
250
251#[derive(Debug, Clone, Deserialize)]
253pub struct WsCandle {
254 pub start: String,
255 pub high: String,
256 pub low: String,
257 pub open: String,
258 pub close: String,
259 pub volume: String,
260 pub product_id: Ustr,
261}
262
263#[derive(Debug, Clone, Deserialize)]
265pub struct WsUserEvent {
266 #[serde(rename = "type")]
268 pub event_type: WsEventType,
269 pub orders: Vec<WsOrderUpdate>,
270}
271
272#[derive(Debug, Clone, Deserialize)]
274pub struct WsOrderUpdate {
275 pub order_id: String,
276 pub client_order_id: String,
277 pub contract_expiry_type: CoinbaseContractExpiryType,
278 pub cumulative_quantity: String,
279 pub leaves_quantity: String,
280 pub avg_price: String,
281 pub total_fees: String,
282 pub status: CoinbaseOrderStatus,
283 pub product_id: Ustr,
284 pub product_type: CoinbaseProductType,
285 pub creation_time: String,
286 pub order_side: CoinbaseOrderSide,
287 pub order_type: CoinbaseOrderType,
288 pub risk_managed_by: CoinbaseRiskManagedBy,
289 pub time_in_force: CoinbaseTimeInForce,
290 pub trigger_status: CoinbaseTriggerStatus,
291 #[serde(default)]
292 pub cancel_reason: String,
293 #[serde(default)]
294 pub reject_reason: String,
295 #[serde(default)]
296 pub total_value_after_fees: String,
297}
298
299#[derive(Debug, Clone, Deserialize)]
301pub struct WsHeartbeatEvent {
302 pub current_time: String,
303 pub heartbeat_counter: u64,
304}
305
306#[derive(Debug, Clone, Deserialize)]
308pub struct WsFuturesBalanceSummaryEvent {
309 #[serde(rename = "type")]
310 pub event_type: WsEventType,
311 pub fcm_balance_summary: WsFcmBalanceSummary,
312}
313
314#[derive(Debug, Clone, Deserialize)]
316pub struct WsFcmBalanceSummary {
317 #[serde(deserialize_with = "deserialize_decimal_from_str")]
318 pub futures_buying_power: Decimal,
319 #[serde(deserialize_with = "deserialize_decimal_from_str")]
320 pub total_usd_balance: Decimal,
321 #[serde(deserialize_with = "deserialize_decimal_from_str")]
322 pub cbi_usd_balance: Decimal,
323 #[serde(deserialize_with = "deserialize_decimal_from_str")]
324 pub cfm_usd_balance: Decimal,
325 #[serde(deserialize_with = "deserialize_decimal_from_str")]
326 pub total_open_orders_hold_amount: Decimal,
327 #[serde(deserialize_with = "deserialize_decimal_from_str")]
328 pub unrealized_pnl: Decimal,
329 #[serde(deserialize_with = "deserialize_decimal_from_str")]
330 pub daily_realized_pnl: Decimal,
331 #[serde(deserialize_with = "deserialize_decimal_from_str")]
332 pub initial_margin: Decimal,
333 #[serde(deserialize_with = "deserialize_decimal_from_str")]
334 pub available_margin: Decimal,
335 #[serde(deserialize_with = "deserialize_decimal_from_str")]
336 pub liquidation_threshold: Decimal,
337 #[serde(deserialize_with = "deserialize_decimal_from_str")]
338 pub liquidation_buffer_amount: Decimal,
339 #[serde(deserialize_with = "deserialize_decimal_from_str")]
340 pub liquidation_buffer_percentage: Decimal,
341 pub intraday_margin_window_measure: WsMarginWindowMeasure,
342 pub overnight_margin_window_measure: WsMarginWindowMeasure,
343}
344
345#[derive(Debug, Clone, Deserialize)]
347pub struct WsMarginWindowMeasure {
348 pub margin_window_type: CoinbaseMarginWindowType,
349 pub margin_level: CoinbaseMarginLevel,
350 #[serde(deserialize_with = "deserialize_decimal_from_str")]
351 pub initial_margin: Decimal,
352 #[serde(deserialize_with = "deserialize_decimal_from_str")]
353 pub maintenance_margin: Decimal,
354 #[serde(deserialize_with = "deserialize_decimal_from_str")]
355 pub liquidation_buffer_percentage: Decimal,
356 #[serde(deserialize_with = "deserialize_decimal_from_str")]
357 pub total_hold: Decimal,
358 #[serde(deserialize_with = "deserialize_decimal_from_str")]
359 pub futures_buying_power: Decimal,
360}
361
362#[derive(Debug, Clone, Deserialize)]
364pub struct WsStatusEvent {
365 #[serde(rename = "type")]
366 pub event_type: WsEventType,
367 #[serde(default)]
368 pub products: Vec<WsStatusProduct>,
369}
370
371#[derive(Debug, Clone, Deserialize)]
373pub struct WsStatusProduct {
374 #[serde(deserialize_with = "deserialize_product_type_or_unknown")]
375 pub product_type: CoinbaseProductType,
376 pub id: Ustr,
377 pub base_currency: Ustr,
378 pub quote_currency: Ustr,
379 pub base_increment: String,
380 pub quote_increment: String,
381 pub display_name: String,
382 #[serde(deserialize_with = "deserialize_product_status_or_unknown")]
383 pub status: CoinbaseProductStatus,
384 pub status_message: String,
385 #[serde(deserialize_with = "deserialize_decimal_from_str")]
386 pub min_market_funds: Decimal,
387}
388
389#[derive(Debug, Clone, Deserialize)]
391pub struct WsSubscriptionsEvent {
392 pub subscriptions: HashMap<CoinbaseWsChannel, Vec<Ustr>>,
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum WsEventType {
399 Snapshot,
400 Update,
401}
402
403#[cfg(test)]
404mod tests {
405 use rstest::rstest;
406
407 use super::*;
408 use crate::common::testing::load_test_fixture;
409
410 #[rstest]
411 fn test_deserialize_l2_snapshot() {
412 let json = load_test_fixture("ws_l2_data_snapshot.json");
413 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
414
415 match msg {
416 CoinbaseWsMessage::L2Data {
417 timestamp,
418 sequence_num,
419 events,
420 } => {
421 assert!(!timestamp.is_empty());
422 assert_eq!(sequence_num, 0);
423 assert_eq!(events.len(), 1);
424
425 let event = &events[0];
426 assert_eq!(event.event_type, WsEventType::Snapshot);
427 assert_eq!(event.product_id, "BTC-USD");
428 assert!(!event.updates.is_empty());
429
430 let bid = event
431 .updates
432 .iter()
433 .find(|u| u.side == WsBookSide::Bid)
434 .expect("should have a bid update");
435 assert!(!bid.price_level.is_empty());
436 assert!(!bid.new_quantity.is_empty());
437 }
438 other => panic!("Expected L2Data, was {other:?}"),
439 }
440 }
441
442 #[rstest]
443 fn test_deserialize_l2_update() {
444 let json = load_test_fixture("ws_l2_data_update.json");
445 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
446
447 match msg {
448 CoinbaseWsMessage::L2Data {
449 sequence_num,
450 events,
451 ..
452 } => {
453 assert!(sequence_num > 0);
454 assert_eq!(events[0].event_type, WsEventType::Update);
455 }
456 other => panic!("Expected L2Data, was {other:?}"),
457 }
458 }
459
460 #[rstest]
461 fn test_deserialize_market_trades() {
462 let json = load_test_fixture("ws_market_trades.json");
463 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
464
465 match msg {
466 CoinbaseWsMessage::MarketTrades { events, .. } => {
467 assert_eq!(events.len(), 1);
468 assert!(!events[0].trades.is_empty());
469
470 let trade = &events[0].trades[0];
471 assert_eq!(trade.product_id, "BTC-USD");
472 assert!(!trade.price.is_empty());
473 assert!(!trade.size.is_empty());
474 assert!(!trade.trade_id.is_empty());
475 }
476 other => panic!("Expected MarketTrades, was {other:?}"),
477 }
478 }
479
480 #[rstest]
481 fn test_deserialize_ticker() {
482 let json = load_test_fixture("ws_ticker.json");
483 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
484
485 match msg {
486 CoinbaseWsMessage::Ticker { events, .. } => {
487 assert_eq!(events.len(), 1);
488 assert!(!events[0].tickers.is_empty());
489
490 let ticker = &events[0].tickers[0];
491 assert_eq!(ticker.product_id, "BTC-USD");
492 assert!(!ticker.best_bid.is_empty());
493 assert!(!ticker.best_ask.is_empty());
494 assert!(!ticker.best_bid_quantity.is_empty());
495 assert!(!ticker.best_ask_quantity.is_empty());
496 }
497 other => panic!("Expected Ticker, was {other:?}"),
498 }
499 }
500
501 #[rstest]
502 fn test_deserialize_candles() {
503 let json = load_test_fixture("ws_candles.json");
504 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
505
506 match msg {
507 CoinbaseWsMessage::Candles { events, .. } => {
508 assert_eq!(events.len(), 1);
509 assert!(!events[0].candles.is_empty());
510
511 let candle = &events[0].candles[0];
512 assert_eq!(candle.product_id, "BTC-USD");
513 assert!(!candle.open.is_empty());
514 assert!(!candle.high.is_empty());
515 assert!(!candle.low.is_empty());
516 assert!(!candle.close.is_empty());
517 assert!(!candle.volume.is_empty());
518 }
519 other => panic!("Expected Candles, was {other:?}"),
520 }
521 }
522
523 #[rstest]
524 fn test_deserialize_user_order_update() {
525 let json = load_test_fixture("ws_user.json");
526 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
527
528 match msg {
529 CoinbaseWsMessage::User { events, .. } => {
530 assert_eq!(events.len(), 1);
531 assert!(!events[0].orders.is_empty());
532
533 let order = &events[0].orders[0];
534 assert!(!order.order_id.is_empty());
535 assert_eq!(order.product_id, "BTC-USD");
536 assert_eq!(order.status, CoinbaseOrderStatus::Open);
537 assert_eq!(order.order_side, CoinbaseOrderSide::Buy);
538 assert_eq!(order.order_type, CoinbaseOrderType::Limit);
539 assert_eq!(
540 order.contract_expiry_type,
541 CoinbaseContractExpiryType::Unknown
542 );
543 assert_eq!(order.product_type, CoinbaseProductType::Spot);
544 assert_eq!(order.risk_managed_by, CoinbaseRiskManagedBy::Unknown);
545 assert_eq!(order.time_in_force, CoinbaseTimeInForce::GoodUntilCancelled);
546 assert_eq!(
547 order.trigger_status,
548 CoinbaseTriggerStatus::InvalidOrderType
549 );
550 }
551 other => panic!("Expected User, was {other:?}"),
552 }
553 }
554
555 #[rstest]
556 fn test_deserialize_heartbeat() {
557 let json = load_test_fixture("ws_heartbeats.json");
558 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
559
560 match msg {
561 CoinbaseWsMessage::Heartbeats { events, .. } => {
562 assert_eq!(events.len(), 1);
563 assert!(!events[0].current_time.is_empty());
564 assert!(events[0].heartbeat_counter > 0);
565 }
566 other => panic!("Expected Heartbeats, was {other:?}"),
567 }
568 }
569
570 #[rstest]
571 fn test_deserialize_status_channel() {
572 let json = r#"{
573 "channel": "status",
574 "client_id": "",
575 "timestamp": "2023-02-09T20:29:49.753424311Z",
576 "sequence_num": 0,
577 "events": [
578 {
579 "type": "snapshot",
580 "products": [
581 {
582 "product_type": "SPOT",
583 "id": "BTC-USD",
584 "base_currency": "BTC",
585 "quote_currency": "USD",
586 "base_increment": "0.00000001",
587 "quote_increment": "0.01",
588 "display_name": "BTC/USD",
589 "status": "online",
590 "status_message": "",
591 "min_market_funds": "1"
592 }
593 ]
594 }
595 ]
596 }"#;
597 let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
598
599 match msg {
600 CoinbaseWsMessage::Status { events, .. } => {
601 assert_eq!(events.len(), 1);
602 assert_eq!(events[0].event_type, WsEventType::Snapshot);
603 assert_eq!(events[0].products.len(), 1);
604 let product = &events[0].products[0];
605 assert_eq!(product.id, "BTC-USD");
606 assert_eq!(product.product_type, CoinbaseProductType::Spot);
607 assert_eq!(product.status, CoinbaseProductStatus::Online);
608 assert_eq!(product.min_market_funds, Decimal::ONE);
609 }
610 other => panic!("Expected Status, was {other:?}"),
611 }
612 }
613
614 #[rstest]
615 fn test_deserialize_status_channel_unknown_status_does_not_fail() {
616 let json = r#"{
619 "channel": "status",
620 "client_id": "",
621 "timestamp": "2023-02-09T20:29:49.753424311Z",
622 "sequence_num": 0,
623 "events": [
624 {
625 "type": "snapshot",
626 "products": [
627 {
628 "product_type": "SPOT",
629 "id": "BTC-USD",
630 "base_currency": "BTC",
631 "quote_currency": "USD",
632 "base_increment": "0.00000001",
633 "quote_increment": "0.01",
634 "display_name": "BTC/USD",
635 "status": "auction",
636 "status_message": "",
637 "min_market_funds": "1"
638 }
639 ]
640 }
641 ]
642 }"#;
643 let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
644
645 match msg {
646 CoinbaseWsMessage::Status { events, .. } => {
647 assert_eq!(events[0].products[0].status, CoinbaseProductStatus::Unknown);
648 }
649 other => panic!("Expected Status, was {other:?}"),
650 }
651 }
652
653 #[rstest]
654 fn test_deserialize_status_channel_unknown_product_type_does_not_fail() {
655 let json = r#"{
658 "channel": "status",
659 "client_id": "",
660 "timestamp": "2023-02-09T20:29:49.753424311Z",
661 "sequence_num": 0,
662 "events": [
663 {
664 "type": "snapshot",
665 "products": [
666 {
667 "product_type": "PERPETUAL",
668 "id": "BTC-PERP",
669 "base_currency": "BTC",
670 "quote_currency": "USD",
671 "base_increment": "0.00000001",
672 "quote_increment": "0.01",
673 "display_name": "BTC-PERP",
674 "status": "online",
675 "status_message": "",
676 "min_market_funds": "1"
677 }
678 ]
679 }
680 ]
681 }"#;
682 let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
683
684 match msg {
685 CoinbaseWsMessage::Status { events, .. } => {
686 assert_eq!(
687 events[0].products[0].product_type,
688 CoinbaseProductType::Unknown
689 );
690 }
691 other => panic!("Expected Status, was {other:?}"),
692 }
693 }
694
695 #[rstest]
696 fn test_deserialize_futures_balance_summary_channel() {
697 let json = r#"{
698 "channel": "futures_balance_summary",
699 "client_id": "",
700 "timestamp": "2023-02-09T20:33:57.609931463Z",
701 "sequence_num": 0,
702 "events": [
703 {
704 "type": "snapshot",
705 "fcm_balance_summary": {
706 "futures_buying_power": "100.00",
707 "total_usd_balance": "200.00",
708 "cbi_usd_balance": "300.00",
709 "cfm_usd_balance": "400.00",
710 "total_open_orders_hold_amount": "500.00",
711 "unrealized_pnl": "600.00",
712 "daily_realized_pnl": "0",
713 "initial_margin": "700.00",
714 "available_margin": "800.00",
715 "liquidation_threshold": "900.00",
716 "liquidation_buffer_amount": "1000.00",
717 "liquidation_buffer_percentage": "1000",
718 "intraday_margin_window_measure": {
719 "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_INTRADAY",
720 "margin_level": "MARGIN_LEVEL_TYPE_BASE",
721 "initial_margin": "100.00",
722 "maintenance_margin": "200.00",
723 "liquidation_buffer_percentage": "1000",
724 "total_hold": "100.00",
725 "futures_buying_power": "400.00"
726 },
727 "overnight_margin_window_measure": {
728 "margin_window_type": "FCM_MARGIN_WINDOW_TYPE_OVERNIGHT",
729 "margin_level": "MARGIN_LEVEL_TYPE_BASE",
730 "initial_margin": "300.00",
731 "maintenance_margin": "200.00",
732 "liquidation_buffer_percentage": "1000",
733 "total_hold": "-30.00",
734 "futures_buying_power": "2000.00"
735 }
736 }
737 }
738 ]
739 }"#;
740 let msg: CoinbaseWsMessage = serde_json::from_str(json).unwrap();
741
742 match msg {
743 CoinbaseWsMessage::FuturesBalanceSummary { events, .. } => {
744 assert_eq!(events.len(), 1);
745 assert_eq!(events[0].event_type, WsEventType::Snapshot);
746 let summary = &events[0].fcm_balance_summary;
747 assert_eq!(summary.futures_buying_power, Decimal::from(100));
748 assert_eq!(summary.daily_realized_pnl, Decimal::ZERO);
749 assert_eq!(
750 summary.intraday_margin_window_measure.margin_window_type,
751 CoinbaseMarginWindowType::Intraday
752 );
753 assert_eq!(
754 summary.overnight_margin_window_measure.margin_level,
755 CoinbaseMarginLevel::Base
756 );
757 assert_eq!(
758 summary.overnight_margin_window_measure.total_hold,
759 "-30.00".parse::<Decimal>().unwrap()
760 );
761 }
762 other => panic!("Expected FuturesBalanceSummary, was {other:?}"),
763 }
764 }
765
766 #[rstest]
767 fn test_deserialize_subscriptions() {
768 let json = load_test_fixture("ws_subscriptions.json");
769 let msg: CoinbaseWsMessage = serde_json::from_str(&json).unwrap();
770
771 match msg {
772 CoinbaseWsMessage::Subscriptions { events, .. } => {
773 assert_eq!(events.len(), 1);
774 assert_eq!(
775 events[0].subscriptions.get(&CoinbaseWsChannel::Level2),
776 Some(&vec![Ustr::from("BTC-USD")])
777 );
778 assert_eq!(
779 events[0]
780 .subscriptions
781 .get(&CoinbaseWsChannel::MarketTrades),
782 Some(&vec![Ustr::from("BTC-USD"), Ustr::from("ETH-USD")])
783 );
784 }
785 other => panic!("Expected Subscriptions, was {other:?}"),
786 }
787 }
788
789 #[rstest]
790 fn test_serialize_subscribe_request_with_jwt() {
791 let sub = CoinbaseWsSubscription {
792 msg_type: CoinbaseWsAction::Subscribe,
793 product_ids: vec![Ustr::from("BTC-USD")],
794 channel: CoinbaseWsChannel::User,
795 jwt: Some("test-jwt-token".to_string()),
796 };
797
798 let json = serde_json::to_value(&sub).unwrap();
799 assert_eq!(json["type"], "subscribe");
800 assert_eq!(json["channel"], "user");
801 assert_eq!(json["product_ids"][0], "BTC-USD");
802 assert_eq!(json["jwt"], "test-jwt-token");
803 }
804
805 #[rstest]
806 fn test_serialize_subscribe_request_public_omits_jwt() {
807 let sub = CoinbaseWsSubscription {
808 msg_type: CoinbaseWsAction::Subscribe,
809 product_ids: vec![Ustr::from("BTC-USD")],
810 channel: CoinbaseWsChannel::Level2,
811 jwt: None,
812 };
813
814 let json = serde_json::to_value(&sub).unwrap();
815 assert_eq!(json["type"], "subscribe");
816 assert_eq!(json["channel"], "level2");
817 assert!(json.get("jwt").is_none());
818 }
819
820 #[rstest]
821 fn test_serialize_unsubscribe_request() {
822 let sub = CoinbaseWsSubscription {
823 msg_type: CoinbaseWsAction::Unsubscribe,
824 product_ids: vec![Ustr::from("ETH-USD")],
825 channel: CoinbaseWsChannel::MarketTrades,
826 jwt: None,
827 };
828
829 let json = serde_json::to_value(&sub).unwrap();
830 assert_eq!(json["type"], "unsubscribe");
831 assert_eq!(json["channel"], "market_trades");
832 assert!(json.get("jwt").is_none());
833 }
834
835 #[rstest]
836 fn test_serialize_channel_level_subscription_omits_product_ids() {
837 let sub = CoinbaseWsSubscription {
838 msg_type: CoinbaseWsAction::Subscribe,
839 product_ids: vec![],
840 channel: CoinbaseWsChannel::Heartbeats,
841 jwt: None,
842 };
843
844 let json = serde_json::to_value(&sub).unwrap();
845 assert_eq!(json["type"], "subscribe");
846 assert_eq!(json["channel"], "heartbeats");
847 assert!(json.get("product_ids").is_none());
848 assert!(json.get("jwt").is_none());
849 }
850
851 #[rstest]
852 fn test_ws_event_type_values() {
853 let snapshot: WsEventType = serde_json::from_str("\"snapshot\"").unwrap();
854 assert_eq!(snapshot, WsEventType::Snapshot);
855
856 let update: WsEventType = serde_json::from_str("\"update\"").unwrap();
857 assert_eq!(update, WsEventType::Update);
858 }
859
860 #[rstest]
861 fn test_ws_book_side_values() {
862 let bid: WsBookSide = serde_json::from_str("\"bid\"").unwrap();
863 assert_eq!(bid, WsBookSide::Bid);
864
865 let offer: WsBookSide = serde_json::from_str("\"offer\"").unwrap();
866 assert_eq!(offer, WsBookSide::Offer);
867 }
868}