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