1use jiff::Timestamp;
17use nautilus_core::serialization::{
18 deserialize_decimal_token_borrowed, deserialize_optional_decimal_token_borrowed,
19};
20use rust_decimal::Decimal;
21use serde::{Deserialize, Deserializer, Serialize, de::Error};
22use serde_json::value::RawValue;
23use ustr::Ustr;
24
25use crate::common::{enums::TardisExchange, parse::deserialize_uppercase};
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct BookLevel {
30 pub price: Decimal,
32 pub amount: Decimal,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct BookChangeMsg {
40 #[serde(deserialize_with = "deserialize_uppercase")]
42 pub symbol: Ustr,
43 pub exchange: TardisExchange,
45 pub is_snapshot: bool,
47 #[serde(deserialize_with = "deserialize_book_levels")]
49 pub bids: Vec<BookLevel>,
50 #[serde(deserialize_with = "deserialize_book_levels")]
52 pub asks: Vec<BookLevel>,
53 pub timestamp: Timestamp,
55 pub local_timestamp: Timestamp,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct BookSnapshotMsg {
63 #[serde(deserialize_with = "deserialize_uppercase")]
65 pub symbol: Ustr,
66 pub exchange: TardisExchange,
68 pub name: String,
70 pub depth: u32,
72 pub interval: u32,
74 #[serde(deserialize_with = "deserialize_book_levels")]
76 pub bids: Vec<BookLevel>,
77 #[serde(deserialize_with = "deserialize_book_levels")]
79 pub asks: Vec<BookLevel>,
80 pub timestamp: Timestamp,
82 pub local_timestamp: Timestamp,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
88#[serde(tag = "type")]
89#[serde(rename_all = "camelCase")]
90pub struct TradeMsg {
91 #[serde(deserialize_with = "deserialize_uppercase")]
93 pub symbol: Ustr,
94 pub exchange: TardisExchange,
96 pub id: Option<String>,
98 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
100 pub price: Decimal,
101 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
103 pub amount: Decimal,
104 pub side: String,
106 pub timestamp: Timestamp,
108 pub local_timestamp: Timestamp,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct DerivativeTickerMsg {
116 #[serde(deserialize_with = "deserialize_uppercase")]
118 pub symbol: Ustr,
119 pub exchange: TardisExchange,
121 pub last_price: Option<f64>,
123 pub open_interest: Option<f64>,
125 #[serde(
127 default,
128 deserialize_with = "deserialize_optional_decimal_token_borrowed"
129 )]
130 pub funding_rate: Option<Decimal>,
131 pub funding_timestamp: Option<Timestamp>,
133 #[serde(
135 default,
136 deserialize_with = "deserialize_optional_decimal_token_borrowed"
137 )]
138 pub index_price: Option<Decimal>,
139 #[serde(
141 default,
142 deserialize_with = "deserialize_optional_decimal_token_borrowed"
143 )]
144 pub mark_price: Option<Decimal>,
145 pub timestamp: Timestamp,
147 pub local_timestamp: Timestamp,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct OptionSummaryMsg {
156 #[serde(deserialize_with = "deserialize_uppercase")]
158 pub symbol: Ustr,
159 pub exchange: TardisExchange,
161 pub option_type: String,
163 pub strike_price: f64,
165 pub expiration_date: Timestamp,
167 #[serde(
169 default,
170 deserialize_with = "deserialize_optional_decimal_token_borrowed"
171 )]
172 pub best_bid_price: Option<Decimal>,
173 #[serde(
175 default,
176 deserialize_with = "deserialize_optional_decimal_token_borrowed"
177 )]
178 pub best_bid_amount: Option<Decimal>,
179 #[serde(rename = "bestBidIV")]
181 pub best_bid_iv: Option<f64>,
182 #[serde(
184 default,
185 deserialize_with = "deserialize_optional_decimal_token_borrowed"
186 )]
187 pub best_ask_price: Option<Decimal>,
188 #[serde(
190 default,
191 deserialize_with = "deserialize_optional_decimal_token_borrowed"
192 )]
193 pub best_ask_amount: Option<Decimal>,
194 #[serde(rename = "bestAskIV")]
196 pub best_ask_iv: Option<f64>,
197 pub last_price: Option<f64>,
199 pub open_interest: Option<f64>,
201 pub mark_price: Option<f64>,
203 #[serde(rename = "markIV")]
205 pub mark_iv: Option<f64>,
206 pub delta: Option<f64>,
208 pub gamma: Option<f64>,
210 pub vega: Option<f64>,
212 pub theta: Option<f64>,
214 pub rho: Option<f64>,
216 pub underlying_price: Option<f64>,
218 pub underlying_index: String,
220 pub timestamp: Timestamp,
222 pub local_timestamp: Timestamp,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct BarMsg {
232 #[serde(deserialize_with = "deserialize_uppercase")]
234 pub symbol: Ustr,
235 pub exchange: TardisExchange,
237 pub name: String,
239 pub interval: u64,
241 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
243 pub open: Decimal,
244 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
246 pub high: Decimal,
247 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
249 pub low: Decimal,
250 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
252 pub close: Decimal,
253 #[serde(deserialize_with = "deserialize_decimal_token_borrowed")]
255 pub volume: Decimal,
256 pub buy_volume: f64,
258 pub sell_volume: f64,
260 pub trades: u64,
262 pub vwap: f64,
264 pub open_timestamp: Timestamp,
266 pub close_timestamp: Timestamp,
268 pub timestamp: Timestamp,
270 pub local_timestamp: Timestamp,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct DisconnectMsg {
279 pub exchange: TardisExchange,
281 pub local_timestamp: Timestamp,
283}
284
285#[allow(missing_docs)]
291#[derive(Debug, Clone, Serialize)]
292#[serde(rename_all = "snake_case", tag = "type")]
293pub enum WsMessage {
294 BookChange(BookChangeMsg),
295 BookSnapshot(BookSnapshotMsg),
296 Trade(TradeMsg),
297 TradeBar(BarMsg),
298 DerivativeTicker(DerivativeTickerMsg),
299 OptionSummary(OptionSummaryMsg),
300 Disconnect(DisconnectMsg),
301}
302
303impl<'de> Deserialize<'de> for WsMessage {
304 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305 let raw = Box::<RawValue>::deserialize(deserializer)?;
306 decode_ws_message(raw.get()).map_err(D::Error::custom)
307 }
308}
309
310pub(crate) fn decode_ws_message(text: &str) -> serde_json::Result<WsMessage> {
313 let header: WsMessageHeader = serde_json::from_str(text)?;
314
315 match header.kind {
316 WsMessageKind::BookChange => serde_json::from_str(text).map(WsMessage::BookChange),
317 WsMessageKind::BookSnapshot => serde_json::from_str(text).map(WsMessage::BookSnapshot),
318 WsMessageKind::Trade => serde_json::from_str(text).map(WsMessage::Trade),
319 WsMessageKind::TradeBar => serde_json::from_str(text).map(WsMessage::TradeBar),
320 WsMessageKind::DerivativeTicker => {
321 serde_json::from_str(text).map(WsMessage::DerivativeTicker)
322 }
323 WsMessageKind::OptionSummary => serde_json::from_str(text).map(WsMessage::OptionSummary),
324 WsMessageKind::Disconnect => serde_json::from_str(text).map(WsMessage::Disconnect),
325 }
326}
327
328#[derive(Deserialize)]
329struct WsMessageHeader {
330 #[serde(rename = "type")]
331 kind: WsMessageKind,
332}
333
334#[derive(Deserialize)]
335#[serde(rename_all = "snake_case")]
336enum WsMessageKind {
337 BookChange,
338 BookSnapshot,
339 Trade,
340 TradeBar,
341 DerivativeTicker,
342 OptionSummary,
343 Disconnect,
344}
345
346#[derive(Debug, Deserialize)]
347struct RawBookLevel {
348 #[serde(
349 default,
350 deserialize_with = "deserialize_optional_decimal_token_borrowed"
351 )]
352 price: Option<Decimal>,
353 #[serde(
354 default,
355 deserialize_with = "deserialize_optional_decimal_token_borrowed"
356 )]
357 amount: Option<Decimal>,
358}
359
360fn deserialize_book_levels<'de, D>(deserializer: D) -> Result<Vec<BookLevel>, D::Error>
361where
362 D: Deserializer<'de>,
363{
364 Vec::<RawBookLevel>::deserialize(deserializer)?
365 .into_iter()
366 .filter_map(|level| match (level.price, level.amount) {
367 (Some(price), Some(amount)) => Some(Ok(BookLevel { price, amount })),
368 (None, None) => None,
369 (None, Some(_)) => Some(Err(D::Error::custom("book level missing price"))),
370 (Some(_), None) => Some(Err(D::Error::custom("book level missing amount"))),
371 })
372 .collect()
373}
374
375#[cfg(test)]
376mod tests {
377 use rstest::rstest;
378 use rust_decimal_macros::dec;
379
380 use super::*;
381 use crate::common::testing::load_test_json;
382
383 #[rstest]
384 fn test_parse_book_change_message() {
385 let json_data = load_test_json("book_change.json");
386 let message: BookChangeMsg = serde_json::from_str(&json_data).unwrap();
387
388 assert_eq!(message.symbol, "XBTUSD");
389 assert_eq!(message.exchange, TardisExchange::Bitmex);
390 assert!(!message.is_snapshot);
391 assert!(message.bids.is_empty());
392 assert_eq!(message.asks.len(), 1);
393 assert_eq!(message.asks[0].price, dec!(7985.0));
394 assert_eq!(message.asks[0].amount, dec!(283318.0));
395 assert_eq!(
396 message.timestamp,
397 "2019-10-23T11:29:53.469Z".parse::<Timestamp>().unwrap()
398 );
399 assert_eq!(
400 message.local_timestamp,
401 "2019-10-23T11:29:53.469Z".parse::<Timestamp>().unwrap()
402 );
403 }
404
405 #[rstest]
406 fn test_parse_book_change_message_skips_empty_book_levels() {
407 let json_data = r#"{
408 "type": "book_change",
409 "symbol": "XBTUSD",
410 "exchange": "bitmex",
411 "isSnapshot": false,
412 "bids": [{"price": 7984, "amount": 100}, {}],
413 "asks": [{}],
414 "timestamp": "2019-10-23T11:29:53.469Z",
415 "localTimestamp": "2019-10-23T11:29:53.469Z"
416 }"#;
417
418 let message: BookChangeMsg = serde_json::from_str(json_data).unwrap();
419
420 assert_eq!(message.bids.len(), 1);
421 assert!(message.asks.is_empty());
422 assert_eq!(message.bids[0].price, dec!(7984.0));
423 assert_eq!(message.bids[0].amount, dec!(100.0));
424 }
425
426 #[rstest]
427 #[case(r#"[{"price": 7984}]"#, "book level missing amount")]
428 #[case(r#"[{"amount": 100}]"#, "book level missing price")]
429 fn test_parse_book_change_message_rejects_partial_book_level(
430 #[case] bids: &str,
431 #[case] error_message: &str,
432 ) {
433 let json_data = format!(
434 r#"{{
435 "type": "book_change",
436 "symbol": "XBTUSD",
437 "exchange": "bitmex",
438 "isSnapshot": false,
439 "bids": {bids},
440 "asks": [],
441 "timestamp": "2019-10-23T11:29:53.469Z",
442 "localTimestamp": "2019-10-23T11:29:53.469Z"
443 }}"#
444 );
445
446 let error = serde_json::from_str::<BookChangeMsg>(&json_data).unwrap_err();
447
448 assert!(error.to_string().contains(error_message));
449 }
450
451 #[rstest]
452 fn test_parse_book_snapshot_message() {
453 let json_data = load_test_json("book_snapshot.json");
454 let message: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
455
456 assert_eq!(message.symbol, "XBTUSD");
457 assert_eq!(message.exchange, TardisExchange::Bitmex);
458 assert_eq!(message.name, "book_snapshot_2_50ms");
459 assert_eq!(message.depth, 2);
460 assert_eq!(message.interval, 50);
461 assert_eq!(message.bids.len(), 2);
462 assert_eq!(message.asks.len(), 2);
463 assert_eq!(message.bids[0].price, dec!(7633.5));
464 assert_eq!(message.bids[0].amount, dec!(1906067.0));
465 assert_eq!(message.asks[0].price, dec!(7634.0));
466 assert_eq!(message.asks[0].amount, dec!(1467849.0));
467 assert_eq!(
468 message.timestamp,
469 "2019-10-25T13:39:46.950Z".parse::<Timestamp>().unwrap(),
470 );
471 assert_eq!(
472 message.local_timestamp,
473 "2019-10-25T13:39:46.961Z".parse::<Timestamp>().unwrap()
474 );
475 }
476
477 #[rstest]
478 fn test_parse_book_snapshot_message_skips_empty_book_levels() {
479 let json_data = r#"{
480 "type": "book_snapshot",
481 "symbol": "ETC",
482 "exchange": "hyperliquid",
483 "name": "book_snapshot_20_10s",
484 "depth": 20,
485 "interval": 10000,
486 "bids": [{"price": 20.002, "amount": 5.81}],
487 "asks": [{"price": 20.003, "amount": 162.45}, {}],
488 "timestamp": "2025-03-03T10:48:10.000Z",
489 "localTimestamp": "2025-03-03T10:48:10.596818Z"
490 }"#;
491
492 let message: BookSnapshotMsg = serde_json::from_str(json_data).unwrap();
493
494 assert_eq!(message.symbol, "ETC");
495 assert_eq!(message.exchange, TardisExchange::Hyperliquid);
496 assert_eq!(message.bids.len(), 1);
497 assert_eq!(message.asks.len(), 1);
498 assert_eq!(message.asks[0].price, dec!(20.003));
499 assert_eq!(message.asks[0].amount, dec!(162.45));
500 }
501
502 #[rstest]
503 fn test_parse_book_snapshot_message_rejects_partial_book_level() {
504 let json_data = r#"{
505 "type": "book_snapshot",
506 "symbol": "ETC",
507 "exchange": "hyperliquid",
508 "name": "book_snapshot_20_10s",
509 "depth": 20,
510 "interval": 10000,
511 "bids": [{"price": 20.002}],
512 "asks": [],
513 "timestamp": "2025-03-03T10:48:10.000Z",
514 "localTimestamp": "2025-03-03T10:48:10.596818Z"
515 }"#;
516
517 let error = serde_json::from_str::<BookSnapshotMsg>(json_data).unwrap_err();
518
519 assert!(error.to_string().contains("book level missing amount"));
520 }
521
522 #[rstest]
523 fn test_parse_trade_message() {
524 let json_data = load_test_json("trade.json");
525 let message: TradeMsg = serde_json::from_str(&json_data).unwrap();
526
527 assert_eq!(message.symbol, "XBTUSD");
528 assert_eq!(message.exchange, TardisExchange::Bitmex);
529 assert_eq!(
530 message.id,
531 Some("282a0445-0e3a-abeb-f403-11003204ea1b".to_string())
532 );
533 assert_eq!(message.price, dec!(7996.0));
534 assert_eq!(message.amount, dec!(50.0));
535 assert_eq!(message.side, "sell");
536 assert_eq!(
537 message.timestamp,
538 "2019-10-23T10:32:49.669Z".parse::<Timestamp>().unwrap()
539 );
540 assert_eq!(
541 message.local_timestamp,
542 "2019-10-23T10:32:49.740Z".parse::<Timestamp>().unwrap()
543 );
544 }
545
546 #[rstest]
547 fn test_parse_derivative_ticker_message() {
548 let json_data = load_test_json("derivative_ticker.json");
549 let message: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
550
551 assert_eq!(message.symbol, "BTC-PERPETUAL");
552 assert_eq!(message.exchange, TardisExchange::Deribit);
553 assert_eq!(message.last_price, Some(7_987.5));
554 assert_eq!(message.open_interest, Some(84_129_491.0));
555 assert_eq!(message.funding_rate, Some(dec!(-0.00001568)));
556 assert_eq!(message.index_price, Some(dec!(7989.28)));
557 assert_eq!(message.mark_price, Some(dec!(7987.56)));
558 assert_eq!(
559 message.timestamp,
560 "2019-10-23T11:34:29.302Z".parse::<Timestamp>().unwrap()
561 );
562 assert_eq!(
563 message.local_timestamp,
564 "2019-10-23T11:34:29.416Z".parse::<Timestamp>().unwrap()
565 );
566 }
567
568 #[rstest]
569 fn test_parse_option_summary_message() {
570 let json_data = load_test_json("option_summary.json");
571 let message: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
572
573 assert_eq!(message.symbol, "BTC-28JUN24-70000-C");
574 assert_eq!(message.exchange, TardisExchange::Deribit);
575 assert_eq!(message.option_type, "call");
576 assert_eq!(message.strike_price, 70_000.0);
577 assert_eq!(message.best_bid_iv, Some(0.55));
578 assert_eq!(message.best_ask_iv, Some(0.58));
579 assert_eq!(message.mark_iv, Some(0.565));
580 assert_eq!(message.delta, Some(0.25));
581 assert_eq!(message.gamma, Some(0.000_02));
582 assert_eq!(message.vega, Some(45.5));
583 assert_eq!(message.theta, Some(-15.2));
584 assert_eq!(message.rho, Some(0.05));
585 assert_eq!(message.underlying_price, Some(63_500.0));
586 assert_eq!(message.underlying_index, "BTC-USD");
587 assert_eq!(message.open_interest, Some(150.0));
588 assert_eq!(
589 message.timestamp,
590 "2024-01-15T10:30:00.123Z".parse::<Timestamp>().unwrap()
591 );
592 assert_eq!(
593 message.local_timestamp,
594 "2024-01-15T10:30:00.234Z".parse::<Timestamp>().unwrap()
595 );
596 }
597
598 #[rstest]
599 fn test_parse_bar_message() {
600 let json_data = load_test_json("bar.json");
601 let message: BarMsg = serde_json::from_str(&json_data).unwrap();
602
603 assert_eq!(message.symbol, "XBTUSD");
604 assert_eq!(message.exchange, TardisExchange::Bitmex);
605 assert_eq!(message.name, "trade_bar_10000ms");
606 assert_eq!(message.interval, 10_000);
607 assert_eq!(message.open, dec!(7623.5));
608 assert_eq!(message.high, dec!(7623.5));
609 assert_eq!(message.low, dec!(7623.0));
610 assert_eq!(message.close, dec!(7623.5));
611 assert_eq!(message.volume, dec!(37034.0));
612 assert_eq!(message.buy_volume, 24_244.0);
613 assert_eq!(message.sell_volume, 12_790.0);
614 assert_eq!(message.trades, 9);
615 assert_eq!(message.vwap, 7_623.327320840309);
616 assert_eq!(
617 message.open_timestamp,
618 "2019-10-25T13:11:31.574Z".parse::<Timestamp>().unwrap()
619 );
620 assert_eq!(
621 message.close_timestamp,
622 "2019-10-25T13:11:39.212Z".parse::<Timestamp>().unwrap()
623 );
624 assert_eq!(
625 message.local_timestamp,
626 "2019-10-25T13:11:40.369Z".parse::<Timestamp>().unwrap()
627 );
628 assert_eq!(
629 message.timestamp,
630 "2019-10-25T13:11:40.000Z".parse::<Timestamp>().unwrap()
631 );
632 }
633
634 #[rstest]
635 fn test_parse_disconnect_message() {
636 let json_data = load_test_json("disconnect.json");
637 let message: DisconnectMsg = serde_json::from_str(&json_data).unwrap();
638
639 assert_eq!(message.exchange, TardisExchange::Deribit);
640 assert_eq!(
641 message.local_timestamp,
642 "2019-10-23T11:34:29.416Z".parse::<Timestamp>().unwrap()
643 );
644 }
645
646 #[rstest]
647 #[case("book_change.json", "book_change")]
648 #[case("book_snapshot.json", "book_snapshot")]
649 #[case("trade.json", "trade")]
650 #[case("derivative_ticker.json", "derivative_ticker")]
651 #[case("option_summary.json", "option_summary")]
652 #[case("bar.json", "trade_bar")]
653 #[case("disconnect.json", "disconnect")]
654 fn test_ws_message_deserialize_matches_decode(#[case] fixture: &str, #[case] kind: &str) {
655 let json_data = load_test_json(fixture);
656 let message = decode_ws_message(&json_data).unwrap();
657
658 let decoded_kind = match &message {
659 WsMessage::BookChange(_) => "book_change",
660 WsMessage::BookSnapshot(_) => "book_snapshot",
661 WsMessage::Trade(_) => "trade",
662 WsMessage::TradeBar(_) => "trade_bar",
663 WsMessage::DerivativeTicker(_) => "derivative_ticker",
664 WsMessage::OptionSummary(_) => "option_summary",
665 WsMessage::Disconnect(_) => "disconnect",
666 };
667
668 let decoded = serde_json::to_value(message).unwrap();
669 let direct: WsMessage = serde_json::from_str(&json_data).unwrap();
670 let value: serde_json::Value = serde_json::from_str(&json_data).unwrap();
671 let buffered: WsMessage = serde_json::from_value(value).unwrap();
672
673 assert_eq!(decoded_kind, kind);
674 assert_eq!(serde_json::to_value(direct).unwrap(), decoded);
675 assert_eq!(serde_json::to_value(buffered).unwrap(), decoded);
676 }
677
678 #[rstest]
679 #[case(r#"{"type":"quote","symbol":"XBTUSD"}"#, "unknown variant `quote`")]
680 #[case(r#"{"symbol":"XBTUSD"}"#, "missing field `type`")]
681 fn test_decode_ws_message_rejects_invalid_type(#[case] text: &str, #[case] expected: &str) {
682 let error = decode_ws_message(text).unwrap_err();
683
684 assert!(error.to_string().contains(expected));
685 }
686}