1use std::sync::Arc;
17
18use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::UnixNanos;
21use nautilus_model::{
22 data::{
23 Bar, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
24 OptionGreekValues, OptionGreeks, OrderBookDelta, OrderBookDeltas, OrderBookDepth,
25 QuoteTick, TradeTick,
26 },
27 enums::{AggregationSource, BookAction, GreeksConvention, OrderSide, RecordFlag},
28 identifiers::{InstrumentId, TradeId},
29 types::{Price, Quantity},
30};
31use rust_decimal::Decimal;
32
33use super::{
34 message::{
35 BarMsg, BookChangeMsg, BookLevel, BookSnapshotMsg, DerivativeTickerMsg, OptionSummaryMsg,
36 TradeMsg, WsMessage,
37 },
38 types::TardisInstrumentMiniInfo,
39};
40use crate::{
41 common::parse::{
42 derive_trade_id, normalize_amount, parse_aggressor_side, parse_bar_spec, parse_book_action,
43 },
44 config::BookSnapshotOutput,
45};
46
47fn timestamp_to_unix_nanos(timestamp: Timestamp, field: &str) -> anyhow::Result<UnixNanos> {
48 let nanos = u64::try_from(timestamp.as_nanosecond())
49 .with_context(|| format!("invalid timestamp: {field} is outside the UnixNanos range"))?;
50 Ok(UnixNanos::from(nanos))
51}
52
53#[must_use]
54pub fn parse_tardis_ws_message(
55 msg: WsMessage,
56 info: &Arc<TardisInstrumentMiniInfo>,
57 book_snapshot_output: &BookSnapshotOutput,
58) -> Option<Data> {
59 match msg {
60 WsMessage::BookChange(msg) => {
61 if msg.bids.is_empty() && msg.asks.is_empty() {
62 let exchange = msg.exchange;
63 let symbol = &msg.symbol;
64 log::error!("Invalid book change for {exchange} {symbol} (empty bids and asks)");
65 return None;
66 }
67
68 match parse_book_change_msg_as_deltas(
69 &msg,
70 info.price_precision,
71 info.size_precision,
72 info.instrument_id,
73 ) {
74 Ok(deltas) => Some(Data::BookDeltas(Box::new(deltas))),
75 Err(e) => {
76 log::error!("Failed to parse book change message: {e}");
77 None
78 }
79 }
80 }
81 WsMessage::BookSnapshot(msg) => match msg.depth {
82 1 => {
83 match parse_book_snapshot_msg_as_quote(
84 &msg,
85 info.price_precision,
86 info.size_precision,
87 info.instrument_id,
88 ) {
89 Ok(quote) => Some(Data::Quote(quote)),
90 Err(e) => {
91 log::error!("Failed to parse book snapshot quote message: {e}");
92 None
93 }
94 }
95 }
96 _ => match book_snapshot_output {
97 BookSnapshotOutput::Depth => {
98 match parse_book_snapshot_msg_as_depth(
99 &msg,
100 info.price_precision,
101 info.size_precision,
102 info.instrument_id,
103 ) {
104 Ok(depth) => Some(Data::BookDepth(Box::new(depth))),
105 Err(e) => {
106 log::error!("Failed to parse book snapshot as depth: {e}");
107 None
108 }
109 }
110 }
111 BookSnapshotOutput::Deltas => {
112 match parse_book_snapshot_msg_as_deltas(
113 &msg,
114 info.price_precision,
115 info.size_precision,
116 info.instrument_id,
117 ) {
118 Ok(deltas) => Some(Data::BookDeltas(Box::new(deltas))),
119 Err(e) => {
120 log::error!("Failed to parse book snapshot as deltas: {e}");
121 None
122 }
123 }
124 }
125 },
126 },
127 WsMessage::Trade(msg) => {
128 match parse_trade_msg(
129 &msg,
130 info.price_precision,
131 info.size_precision,
132 info.instrument_id,
133 ) {
134 Ok(trade) => Some(Data::Trade(trade)),
135 Err(e) => {
136 log::error!("Failed to parse trade message: {e}");
137 None
138 }
139 }
140 }
141 WsMessage::TradeBar(msg) => {
142 match parse_bar_msg(
143 &msg,
144 info.price_precision,
145 info.size_precision,
146 info.instrument_id,
147 ) {
148 Ok(bar) => Some(Data::Bar(bar)),
149 Err(e) => {
150 log::error!("Failed to parse bar message: {e}");
151 None
152 }
153 }
154 }
155 WsMessage::OptionSummary(msg) => Some(Data::OptionGreeks(parse_option_summary_msg(
156 &msg,
157 info.instrument_id,
158 ))),
159 WsMessage::DerivativeTicker(_) => None,
162 WsMessage::Disconnect(_) => None,
163 }
164}
165
166#[must_use]
167pub fn parse_tardis_ws_message_data(
168 msg: WsMessage,
169 info: &Arc<TardisInstrumentMiniInfo>,
170 book_snapshot_output: &BookSnapshotOutput,
171 extract_bbo_as_quotes: bool,
172) -> Vec<Data> {
173 match msg {
174 WsMessage::OptionSummary(msg) if extract_bbo_as_quotes => {
175 let mut data = Vec::with_capacity(2);
176
177 match parse_option_summary_msg_as_quote(
178 &msg,
179 info.price_precision,
180 info.size_precision,
181 info.instrument_id,
182 ) {
183 Ok(Some(quote)) => data.push(Data::Quote(quote)),
184 Ok(None) => {}
185 Err(e) => {
186 log::error!("Failed to parse option summary quote message: {e}");
187 }
188 }
189
190 data.push(Data::OptionGreeks(parse_option_summary_msg(
191 &msg,
192 info.instrument_id,
193 )));
194 data
195 }
196 msg => parse_tardis_ws_message(msg, info, book_snapshot_output)
197 .into_iter()
198 .collect(),
199 }
200}
201
202#[must_use]
208pub fn parse_option_summary_msg(
209 msg: &OptionSummaryMsg,
210 instrument_id: InstrumentId,
211) -> OptionGreeks {
212 OptionGreeks {
213 instrument_id,
214 convention: GreeksConvention::BlackScholes,
215 greeks: OptionGreekValues {
216 delta: msg.delta.unwrap_or(0.0),
217 gamma: msg.gamma.unwrap_or(0.0),
218 vega: msg.vega.unwrap_or(0.0),
219 theta: msg.theta.unwrap_or(0.0),
220 rho: msg.rho.unwrap_or(0.0),
221 },
222 mark_iv: msg.mark_iv,
223 bid_iv: msg.best_bid_iv,
224 ask_iv: msg.best_ask_iv,
225 underlying_price: msg.underlying_price,
226 open_interest: msg.open_interest,
227 ts_event: UnixNanos::from(msg.timestamp),
228 ts_init: UnixNanos::from(msg.local_timestamp),
229 }
230}
231
232pub fn parse_option_summary_msg_as_quote(
240 msg: &OptionSummaryMsg,
241 price_precision: u8,
242 size_precision: u8,
243 instrument_id: InstrumentId,
244) -> anyhow::Result<Option<QuoteTick>> {
245 let (Some(best_bid_price), Some(best_bid_amount), Some(best_ask_price), Some(best_ask_amount)) = (
246 msg.best_bid_price,
247 msg.best_bid_amount,
248 msg.best_ask_price,
249 msg.best_ask_amount,
250 ) else {
251 return Ok(None);
252 };
253
254 let bid_price = Price::from_decimal_dp(best_bid_price, price_precision)
255 .with_context(|| format!("invalid option summary bid price for message: {msg:?}"))?;
256 let ask_price = Price::from_decimal_dp(best_ask_price, price_precision)
257 .with_context(|| format!("invalid option summary ask price for message: {msg:?}"))?;
258 let bid_size = parse_non_zero_quantity(best_bid_amount, size_precision)
259 .with_context(|| format!("invalid option summary bid size for message: {msg:?}"))?;
260 let ask_size = parse_non_zero_quantity(best_ask_amount, size_precision)
261 .with_context(|| format!("invalid option summary ask size for message: {msg:?}"))?;
262
263 Ok(Some(QuoteTick::new(
264 instrument_id,
265 bid_price,
266 ask_price,
267 bid_size,
268 ask_size,
269 UnixNanos::from(msg.timestamp),
270 UnixNanos::from(msg.local_timestamp),
271 )))
272}
273
274#[must_use]
277pub fn parse_tardis_ws_message_funding_rate(
278 msg: WsMessage,
279 info: &Arc<TardisInstrumentMiniInfo>,
280) -> Option<FundingRateUpdate> {
281 match msg {
282 WsMessage::DerivativeTicker(msg) => {
283 match parse_derivative_ticker_msg(&msg, info.instrument_id) {
284 Ok(funding_rate) => funding_rate,
285 Err(e) => {
286 log::error!("Failed to parse derivative ticker message for funding rate: {e}");
287 None
288 }
289 }
290 }
291 _ => None, }
293}
294
295pub fn parse_book_change_msg_as_deltas(
302 msg: &BookChangeMsg,
303 price_precision: u8,
304 size_precision: u8,
305 instrument_id: InstrumentId,
306) -> anyhow::Result<OrderBookDeltas> {
307 parse_book_msg_as_deltas(
308 &msg.bids,
309 &msg.asks,
310 msg.is_snapshot,
311 price_precision,
312 size_precision,
313 instrument_id,
314 msg.timestamp,
315 msg.local_timestamp,
316 )
317}
318
319pub fn parse_book_snapshot_msg_as_deltas(
326 msg: &BookSnapshotMsg,
327 price_precision: u8,
328 size_precision: u8,
329 instrument_id: InstrumentId,
330) -> anyhow::Result<OrderBookDeltas> {
331 parse_book_msg_as_deltas(
332 &msg.bids,
333 &msg.asks,
334 true,
335 price_precision,
336 size_precision,
337 instrument_id,
338 msg.timestamp,
339 msg.local_timestamp,
340 )
341}
342
343pub fn parse_book_snapshot_msg_as_depth(
350 msg: &BookSnapshotMsg,
351 price_precision: u8,
352 size_precision: u8,
353 instrument_id: InstrumentId,
354) -> anyhow::Result<OrderBookDepth> {
355 let ts_event = timestamp_to_unix_nanos(msg.timestamp, "event timestamp")?;
356 let ts_init = timestamp_to_unix_nanos(msg.local_timestamp, "init timestamp")?;
357
358 let mut bids = Vec::with_capacity(msg.bids.len());
359 let mut asks = Vec::with_capacity(msg.asks.len());
360 let mut bid_counts = Vec::with_capacity(msg.bids.len());
361 let mut ask_counts = Vec::with_capacity(msg.asks.len());
362
363 for level in &msg.bids {
364 let price = Price::from_decimal_dp(level.price, price_precision)?;
365 let size = Quantity::from_decimal_dp(level.amount, size_precision)?;
366 bids.push(BookOrder::new(OrderSide::Buy, price, size, 0));
367 bid_counts.push(1);
368 }
369
370 for level in &msg.asks {
371 let price = Price::from_decimal_dp(level.price, price_precision)?;
372 let size = Quantity::from_decimal_dp(level.amount, size_precision)?;
373 asks.push(BookOrder::new(OrderSide::Sell, price, size, 0));
374 ask_counts.push(1);
375 }
376
377 Ok(OrderBookDepth::new(
378 instrument_id,
379 bids,
380 asks,
381 bid_counts,
382 ask_counts,
383 RecordFlag::F_SNAPSHOT as u8,
384 0, ts_event,
386 ts_init,
387 ))
388}
389
390#[expect(clippy::too_many_arguments)]
392pub fn parse_book_msg_as_deltas(
398 bids: &[BookLevel],
399 asks: &[BookLevel],
400 is_snapshot: bool,
401 price_precision: u8,
402 size_precision: u8,
403 instrument_id: InstrumentId,
404 timestamp: Timestamp,
405 local_timestamp: Timestamp,
406) -> anyhow::Result<OrderBookDeltas> {
407 let ts_event = timestamp_to_unix_nanos(timestamp, "event timestamp")?;
408 let ts_init = timestamp_to_unix_nanos(local_timestamp, "init timestamp")?;
409
410 let capacity = if is_snapshot {
411 bids.len() + asks.len() + 1
412 } else {
413 bids.len() + asks.len()
414 };
415 let mut deltas: Vec<OrderBookDelta> = Vec::with_capacity(capacity);
416
417 if is_snapshot {
418 deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init));
419 }
420
421 for level in bids {
422 match parse_book_level(
423 instrument_id,
424 price_precision,
425 size_precision,
426 OrderSide::Buy,
427 level,
428 is_snapshot,
429 ts_event,
430 ts_init,
431 ) {
432 Ok(delta) => deltas.push(delta),
433 Err(e) => log::warn!("Skipping invalid bid level for {instrument_id}: {e}"),
434 }
435 }
436
437 for level in asks {
438 match parse_book_level(
439 instrument_id,
440 price_precision,
441 size_precision,
442 OrderSide::Sell,
443 level,
444 is_snapshot,
445 ts_event,
446 ts_init,
447 ) {
448 Ok(delta) => deltas.push(delta),
449 Err(e) => log::warn!("Skipping invalid ask level for {instrument_id}: {e}"),
450 }
451 }
452
453 if let Some(last_delta) = deltas.last_mut() {
454 last_delta.flags |= RecordFlag::F_LAST as u8;
455 }
456
457 Ok(OrderBookDeltas::new(instrument_id, deltas))
458}
459
460#[expect(clippy::too_many_arguments)]
467pub fn parse_book_level(
468 instrument_id: InstrumentId,
469 price_precision: u8,
470 size_precision: u8,
471 side: OrderSide,
472 level: &BookLevel,
473 is_snapshot: bool,
474 ts_event: UnixNanos,
475 ts_init: UnixNanos,
476) -> anyhow::Result<OrderBookDelta> {
477 let amount = normalize_amount(level.amount, size_precision);
478 let price = Price::from_decimal_dp(level.price, price_precision)?;
479 let size = Quantity::from_decimal_dp(amount, size_precision)?;
480 let action = parse_book_action(is_snapshot, size.as_f64());
481 let order_id = 0; let order = BookOrder::new(side, price, size, order_id);
483 let flags = if is_snapshot {
484 RecordFlag::F_SNAPSHOT as u8
485 } else {
486 0
487 };
488 let sequence = 0; anyhow::ensure!(
491 !(action != BookAction::Delete && size.is_zero()),
492 "Invalid zero size for {action}"
493 );
494
495 Ok(OrderBookDelta::new(
496 instrument_id,
497 action,
498 order,
499 flags,
500 sequence,
501 ts_event,
502 ts_init,
503 ))
504}
505
506pub fn parse_book_snapshot_msg_as_quote(
513 msg: &BookSnapshotMsg,
514 price_precision: u8,
515 size_precision: u8,
516 instrument_id: InstrumentId,
517) -> anyhow::Result<QuoteTick> {
518 let ts_event = UnixNanos::from(msg.timestamp);
519 let ts_init = UnixNanos::from(msg.local_timestamp);
520
521 let best_bid = msg
522 .bids
523 .first()
524 .context("missing best bid level for quote message")?;
525 let bid_price = Price::from_decimal_dp(best_bid.price, price_precision)
526 .with_context(|| format!("invalid bid price for message: {msg:?}"))?;
527 let bid_size = parse_non_zero_quantity(best_bid.amount, size_precision)
528 .with_context(|| format!("Invalid bid size for message: {msg:?}"))?;
529
530 let best_ask = msg
531 .asks
532 .first()
533 .context("missing best ask level for quote message")?;
534 let ask_price = Price::from_decimal_dp(best_ask.price, price_precision)
535 .with_context(|| format!("invalid ask price for message: {msg:?}"))?;
536 let ask_size = parse_non_zero_quantity(best_ask.amount, size_precision)
537 .with_context(|| format!("Invalid ask size for message: {msg:?}"))?;
538
539 Ok(QuoteTick::new(
540 instrument_id,
541 bid_price,
542 ask_price,
543 bid_size,
544 ask_size,
545 ts_event,
546 ts_init,
547 ))
548}
549
550pub fn parse_trade_msg(
557 msg: &TradeMsg,
558 price_precision: u8,
559 size_precision: u8,
560 instrument_id: InstrumentId,
561) -> anyhow::Result<TradeTick> {
562 let price = Price::from_decimal_dp(msg.price, price_precision)
563 .with_context(|| format!("invalid trade price in message: {msg:?}"))?;
564 let size = parse_non_zero_quantity(msg.amount, size_precision)
565 .with_context(|| format!("Invalid trade size in message: {msg:?}"))?;
566 let aggressor_side = parse_aggressor_side(&msg.side);
567 let ts_event = UnixNanos::from(msg.timestamp);
568 let ts_init = UnixNanos::from(msg.local_timestamp);
569 let trade_id = match msg.id.as_deref() {
570 Some(id) if !id.is_empty() => TradeId::new(id),
571 _ => {
572 let price = msg.price.normalize().to_string();
573 let amount = msg.amount.normalize().to_string();
574 derive_trade_id(msg.symbol, ts_event.as_u64(), &price, &amount, &msg.side)
575 }
576 };
577
578 Ok(TradeTick::new(
579 instrument_id,
580 price,
581 size,
582 aggressor_side,
583 trade_id,
584 ts_event,
585 ts_init,
586 ))
587}
588
589pub fn parse_bar_msg(
596 msg: &BarMsg,
597 price_precision: u8,
598 size_precision: u8,
599 instrument_id: InstrumentId,
600) -> anyhow::Result<Bar> {
601 let spec = parse_bar_spec(&msg.name)?;
602 let bar_type = BarType::new(instrument_id, spec, AggregationSource::External);
603
604 let open = Price::from_decimal_dp(msg.open, price_precision)?;
605 let high = Price::from_decimal_dp(msg.high, price_precision)?;
606 let low = Price::from_decimal_dp(msg.low, price_precision)?;
607 let close = Price::from_decimal_dp(msg.close, price_precision)?;
608 let volume = parse_non_zero_quantity(msg.volume, size_precision)?;
609 let ts_event = UnixNanos::from(msg.timestamp);
610 let ts_init = UnixNanos::from(msg.local_timestamp);
611
612 Ok(Bar::new(
613 bar_type, open, high, low, close, volume, ts_event, ts_init,
614 ))
615}
616
617fn parse_derivative_ticker_timestamps(
619 msg: &DerivativeTickerMsg,
620) -> anyhow::Result<(UnixNanos, UnixNanos)> {
621 Ok((
622 timestamp_to_unix_nanos(msg.timestamp, "event timestamp")?,
623 timestamp_to_unix_nanos(msg.local_timestamp, "init timestamp")?,
624 ))
625}
626
627pub fn parse_derivative_ticker_msg(
633 msg: &DerivativeTickerMsg,
634 instrument_id: InstrumentId,
635) -> anyhow::Result<Option<FundingRateUpdate>> {
636 let rate = match msg.funding_rate {
637 Some(rate) => rate,
638 None => return Ok(None),
639 };
640
641 let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
642 let next_funding_ns = msg
643 .funding_timestamp
644 .map(|ts| timestamp_to_unix_nanos(ts, "funding timestamp"))
645 .transpose()?;
646
647 Ok(Some(FundingRateUpdate::new(
648 instrument_id,
649 rate,
650 None,
651 next_funding_ns,
652 ts_event,
653 ts_init,
654 )))
655}
656
657pub fn parse_derivative_ticker_mark_price(
663 msg: &DerivativeTickerMsg,
664 instrument_id: InstrumentId,
665 price_precision: u8,
666) -> anyhow::Result<Option<MarkPriceUpdate>> {
667 let mark_price = match msg.mark_price {
668 Some(p) => p,
669 None => return Ok(None),
670 };
671
672 let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
673 let price = Price::from_decimal_dp(mark_price, price_precision)?;
674
675 Ok(Some(MarkPriceUpdate::new(
676 instrument_id,
677 price,
678 ts_event,
679 ts_init,
680 )))
681}
682
683pub fn parse_derivative_ticker_index_price(
689 msg: &DerivativeTickerMsg,
690 instrument_id: InstrumentId,
691 price_precision: u8,
692) -> anyhow::Result<Option<IndexPriceUpdate>> {
693 let index_price = match msg.index_price {
694 Some(p) => p,
695 None => return Ok(None),
696 };
697
698 let (ts_event, ts_init) = parse_derivative_ticker_timestamps(msg)?;
699 let price = Price::from_decimal_dp(index_price, price_precision)?;
700
701 Ok(Some(IndexPriceUpdate::new(
702 instrument_id,
703 price,
704 ts_event,
705 ts_init,
706 )))
707}
708
709fn parse_non_zero_quantity(amount: Decimal, precision: u8) -> anyhow::Result<Quantity> {
710 anyhow::ensure!(!amount.is_zero(), "value was zero");
711 let quantity = Quantity::from_decimal_dp(amount, precision)?;
712 anyhow::ensure!(
713 !quantity.is_zero(),
714 "value {amount} was zero after rounding to precision {precision}"
715 );
716 Ok(quantity)
717}
718
719#[cfg(test)]
720mod tests {
721 use nautilus_model::enums::AggressorSide;
722 use rstest::rstest;
723 use rust_decimal_macros::dec;
724
725 use super::*;
726 use crate::common::{
727 enums::TardisExchange, parse::parse_instrument_id, testing::load_test_json,
728 };
729
730 #[rstest]
731 fn test_parse_book_change_message() {
732 let json_data = load_test_json("book_change.json");
733 let msg: BookChangeMsg = serde_json::from_str(&json_data).unwrap();
734
735 let price_precision = 0;
736 let size_precision = 0;
737 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
738 let deltas =
739 parse_book_change_msg_as_deltas(&msg, price_precision, size_precision, instrument_id)
740 .unwrap();
741
742 assert_eq!(deltas.deltas.len(), 1);
743 assert_eq!(deltas.instrument_id, instrument_id);
744 assert_eq!(deltas.flags, RecordFlag::F_LAST as u8);
745 assert_eq!(deltas.sequence, 0);
746 assert_eq!(deltas.ts_event, UnixNanos::from(1571830193469000000));
747 assert_eq!(deltas.ts_init, UnixNanos::from(1571830193469000000));
748 assert_eq!(
749 deltas.deltas[0].instrument_id,
750 InstrumentId::from("XBTUSD.BITMEX")
751 );
752 assert_eq!(deltas.deltas[0].action, BookAction::Update);
753 assert_eq!(deltas.deltas[0].order.price, Price::from("7985"));
754 assert_eq!(deltas.deltas[0].order.size, Quantity::from(283318));
755 assert_eq!(deltas.deltas[0].order.order_id, 0);
756 assert_eq!(deltas.deltas[0].flags, RecordFlag::F_LAST as u8);
757 assert_eq!(deltas.deltas[0].sequence, 0);
758 assert_eq!(
759 deltas.deltas[0].ts_event,
760 UnixNanos::from(1571830193469000000)
761 );
762 assert_eq!(
763 deltas.deltas[0].ts_init,
764 UnixNanos::from(1571830193469000000)
765 );
766 }
767
768 #[rstest]
769 fn test_parse_book_snapshot_message_as_deltas() {
770 let json_data = load_test_json("book_snapshot.json");
771 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
772
773 let price_precision = 1;
774 let size_precision = 0;
775 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
776 let deltas =
777 parse_book_snapshot_msg_as_deltas(&msg, price_precision, size_precision, instrument_id)
778 .unwrap();
779
780 let clear_delta = deltas.deltas[0];
781 let bid_delta = deltas.deltas[1];
782 let ask_delta = deltas.deltas[3];
783
784 assert_eq!(deltas.deltas.len(), 5);
785 assert_eq!(deltas.instrument_id, instrument_id);
786 assert_eq!(
787 deltas.flags,
788 RecordFlag::F_LAST as u8 + RecordFlag::F_SNAPSHOT as u8
789 );
790 assert_eq!(deltas.sequence, 0);
791 assert_eq!(deltas.ts_event, UnixNanos::from(1572010786950000000));
792 assert_eq!(deltas.ts_init, UnixNanos::from(1572010786961000000));
793
794 assert_eq!(clear_delta.instrument_id, instrument_id);
796 assert_eq!(clear_delta.action, BookAction::Clear);
797 assert_eq!(clear_delta.flags, RecordFlag::F_SNAPSHOT as u8);
798 assert_eq!(clear_delta.sequence, 0);
799 assert_eq!(clear_delta.ts_event, UnixNanos::from(1572010786950000000));
800 assert_eq!(clear_delta.ts_init, UnixNanos::from(1572010786961000000));
801
802 assert_eq!(bid_delta.instrument_id, instrument_id);
804 assert_eq!(bid_delta.action, BookAction::Add);
805 assert_eq!(bid_delta.order.side, OrderSide::Buy.into());
806 assert_eq!(bid_delta.order.price, Price::from("7633.5"));
807 assert_eq!(bid_delta.order.size, Quantity::from(1906067));
808 assert_eq!(bid_delta.order.order_id, 0);
809 assert_eq!(bid_delta.flags, RecordFlag::F_SNAPSHOT as u8);
810 assert_eq!(bid_delta.sequence, 0);
811 assert_eq!(bid_delta.ts_event, UnixNanos::from(1572010786950000000));
812 assert_eq!(bid_delta.ts_init, UnixNanos::from(1572010786961000000));
813
814 assert_eq!(ask_delta.instrument_id, instrument_id);
816 assert_eq!(ask_delta.action, BookAction::Add);
817 assert_eq!(ask_delta.order.side, OrderSide::Sell.into());
818 assert_eq!(ask_delta.order.price, Price::from("7634.0"));
819 assert_eq!(ask_delta.order.size, Quantity::from(1467849));
820 assert_eq!(ask_delta.order.order_id, 0);
821 assert_eq!(ask_delta.flags, RecordFlag::F_SNAPSHOT as u8);
822 assert_eq!(ask_delta.sequence, 0);
823 assert_eq!(ask_delta.ts_event, UnixNanos::from(1572010786950000000));
824 assert_eq!(ask_delta.ts_init, UnixNanos::from(1572010786961000000));
825 }
826
827 #[rstest]
828 fn test_parse_book_snapshot_message_as_depth() {
829 let json_data = load_test_json("book_snapshot.json");
830 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
831
832 let price_precision = 1;
833 let size_precision = 0;
834 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
835
836 let depth =
837 parse_book_snapshot_msg_as_depth(&msg, price_precision, size_precision, instrument_id)
838 .unwrap();
839
840 assert_eq!(depth.instrument_id, instrument_id);
841 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
842 assert_eq!(depth.sequence, 0);
843 assert_eq!(depth.ts_event, UnixNanos::from(1572010786950000000));
844 assert_eq!(depth.ts_init, UnixNanos::from(1572010786961000000));
845
846 assert_eq!(depth.bids[0].side, OrderSide::Buy.into());
848 assert_eq!(depth.bids[0].price, Price::from("7633.5"));
849 assert_eq!(depth.bids[0].size, Quantity::from(1906067));
850 assert_eq!(depth.bids[0].order_id, 0);
851 assert_eq!(depth.bid_counts[0], 1);
852
853 assert_eq!(depth.bids[1].side, OrderSide::Buy.into());
855 assert_eq!(depth.bids[1].price, Price::from("7633.0"));
856 assert_eq!(depth.bids[1].size, Quantity::from(65319));
857 assert_eq!(depth.bid_counts[1], 1);
858
859 assert_eq!(depth.asks[0].side, OrderSide::Sell.into());
861 assert_eq!(depth.asks[0].price, Price::from("7634.0"));
862 assert_eq!(depth.asks[0].size, Quantity::from(1467849));
863 assert_eq!(depth.asks[0].order_id, 0);
864 assert_eq!(depth.ask_counts[0], 1);
865
866 assert_eq!(depth.asks[1].side, OrderSide::Sell.into());
868 assert_eq!(depth.asks[1].price, Price::from("7634.5"));
869 assert_eq!(depth.asks[1].size, Quantity::from(67939));
870 assert_eq!(depth.ask_counts[1], 1);
871
872 assert_eq!(depth.bids.len(), 2);
873 assert_eq!(depth.asks.len(), 2);
874 assert_eq!(depth.bid_counts.as_slice(), &[1; 2]);
875 assert_eq!(depth.ask_counts.as_slice(), &[1; 2]);
876 assert_eq!(depth.bids[1].order_id, 0);
877 assert_eq!(depth.asks[1].order_id, 0);
878 }
879
880 #[rstest]
881 fn test_parse_book_snapshot_message_as_depth_keeps_all_levels() {
882 let bids: Vec<BookLevel> = (0..25)
883 .map(|i| BookLevel {
884 price: dec!(7633.5) - Decimal::from(i) * dec!(0.5),
885 amount: Decimal::from(1000 + i),
886 })
887 .collect();
888 let asks: Vec<BookLevel> = (0..25)
889 .map(|i| BookLevel {
890 price: dec!(7634.0) + Decimal::from(i) * dec!(0.5),
891 amount: Decimal::from(2000 + i),
892 })
893 .collect();
894 let msg = BookSnapshotMsg {
895 symbol: ustr::Ustr::from("XBTUSD"),
896 exchange: TardisExchange::Bitmex,
897 name: "book_snapshot_25_100ms".to_string(),
898 depth: 25,
899 interval: 100,
900 bids,
901 asks,
902 timestamp: "2019-10-25T13:39:46.950Z".parse::<Timestamp>().unwrap(),
903 local_timestamp: "2019-10-25T13:39:46.961Z".parse::<Timestamp>().unwrap(),
904 };
905
906 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
907 let depth = parse_book_snapshot_msg_as_depth(&msg, 1, 0, instrument_id).unwrap();
908
909 assert_eq!(depth.instrument_id, instrument_id);
910 assert_eq!(depth.bids.len(), 25);
911 assert_eq!(depth.asks.len(), 25);
912 assert_eq!(depth.bid_counts.as_slice(), &[1; 25]);
913 assert_eq!(depth.ask_counts.as_slice(), &[1; 25]);
914 assert_eq!(depth.bids[0].price, Price::from("7633.5"));
915 assert_eq!(depth.bids[24].price, Price::from("7621.5"));
916 assert_eq!(depth.asks[0].price, Price::from("7634.0"));
917 assert_eq!(depth.asks[24].price, Price::from("7646.0"));
918 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
919 assert_eq!(depth.sequence, 0);
920 }
921
922 #[rstest]
923 fn test_parse_book_snapshot_message_as_quote() {
924 let json_data = load_test_json("book_snapshot.json");
925 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
926
927 let price_precision = 1;
928 let size_precision = 0;
929 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
930 let quote =
931 parse_book_snapshot_msg_as_quote(&msg, price_precision, size_precision, instrument_id)
932 .expect("Failed to parse book snapshot quote message");
933
934 assert_eq!(quote.instrument_id, instrument_id);
935 assert_eq!(quote.bid_price, Price::from("7633.5"));
936 assert_eq!(quote.bid_size, Quantity::from(1906067));
937 assert_eq!(quote.ask_price, Price::from("7634.0"));
938 assert_eq!(quote.ask_size, Quantity::from(1467849));
939 assert_eq!(quote.ts_event, UnixNanos::from(1572010786950000000));
940 assert_eq!(quote.ts_init, UnixNanos::from(1572010786961000000));
941 }
942
943 #[rstest]
944 fn test_parse_trade_message() {
945 let json_data = load_test_json("trade.json");
946 let msg: TradeMsg = serde_json::from_str(&json_data).unwrap();
947
948 let price_precision = 0;
949 let size_precision = 0;
950 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
951 let trade = parse_trade_msg(&msg, price_precision, size_precision, instrument_id)
952 .expect("Failed to parse trade message");
953
954 assert_eq!(trade.instrument_id, instrument_id);
955 assert_eq!(trade.price, Price::from("7996"));
956 assert_eq!(trade.size, Quantity::from(50));
957 assert_eq!(trade.aggressor_side, AggressorSide::Sell);
958 assert_eq!(trade.ts_event, UnixNanos::from(1571826769669000000));
959 assert_eq!(trade.ts_init, UnixNanos::from(1571826769740000000));
960 }
961
962 fn build_trade_msg_without_id() -> TradeMsg {
963 let json_data = load_test_json("trade.json");
964 let mut msg: TradeMsg = serde_json::from_str(&json_data).unwrap();
965 msg.id = None;
966 msg
967 }
968
969 #[rstest]
970 fn test_parse_trade_message_derives_trade_id_when_missing() {
971 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
972
973 let first = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
974 let second = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
975
976 assert_eq!(first.trade_id, second.trade_id, "derivation must be stable");
977 assert_eq!(first.trade_id.as_str().len(), 16);
978
979 let mut altered = build_trade_msg_without_id();
980 altered.price = dec!(7997.0);
981 let altered_trade = parse_trade_msg(&altered, 0, 0, instrument_id).unwrap();
982 assert_ne!(first.trade_id, altered_trade.trade_id);
983 }
984
985 #[rstest]
986 fn test_parse_trade_message_derives_trade_id_when_empty() {
987 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
988
989 let mut msg = build_trade_msg_without_id();
990 msg.id = Some(String::new());
991
992 let trade = parse_trade_msg(&msg, 0, 0, instrument_id).unwrap();
993 let fallback = parse_trade_msg(&build_trade_msg_without_id(), 0, 0, instrument_id).unwrap();
994 assert_eq!(trade.trade_id, fallback.trade_id);
995 }
996
997 #[rstest]
998 fn test_parse_bar_message() {
999 let json_data = load_test_json("bar.json");
1000 let msg: BarMsg = serde_json::from_str(&json_data).unwrap();
1001
1002 let price_precision = 1;
1003 let size_precision = 0;
1004 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1005 let bar = parse_bar_msg(&msg, price_precision, size_precision, instrument_id).unwrap();
1006
1007 assert_eq!(
1008 bar.bar_type,
1009 BarType::from("XBTUSD.BITMEX-10-SECOND-LAST-EXTERNAL")
1010 );
1011 assert_eq!(bar.open, Price::from("7623.5"));
1012 assert_eq!(bar.high, Price::from("7623.5"));
1013 assert_eq!(bar.low, Price::from("7623"));
1014 assert_eq!(bar.close, Price::from("7623.5"));
1015 assert_eq!(bar.volume, Quantity::from(37034));
1016 assert_eq!(bar.ts_event, UnixNanos::from(1572009100000000000));
1017 assert_eq!(bar.ts_init, UnixNanos::from(1572009100369000000));
1018 }
1019
1020 #[rstest]
1021 fn test_parse_tardis_ws_message_book_snapshot_routes_to_depth() {
1022 let json_data = load_test_json("book_snapshot.json");
1023 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
1024 let ws_msg = WsMessage::BookSnapshot(msg);
1025
1026 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1027 let info = Arc::new(TardisInstrumentMiniInfo::new(
1028 instrument_id,
1029 None,
1030 TardisExchange::Bitmex,
1031 1,
1032 0,
1033 ));
1034
1035 let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Depth);
1036
1037 assert!(result.is_some());
1038 assert!(matches!(result.unwrap(), Data::BookDepth(_)));
1039 }
1040
1041 #[rstest]
1042 fn test_parse_tardis_ws_message_sparse_book_snapshot_routes_to_depth() {
1043 let json_data = r#"{
1044 "type": "book_snapshot",
1045 "symbol": "ETC",
1046 "exchange": "hyperliquid",
1047 "name": "book_snapshot_20_10s",
1048 "depth": 20,
1049 "interval": 10000,
1050 "bids": [{"price": 20.002, "amount": 5.81}],
1051 "asks": [{"price": 20.003, "amount": 162.45}, {}],
1052 "timestamp": "2025-03-03T10:48:10.000Z",
1053 "localTimestamp": "2025-03-03T10:48:10.596818Z"
1054 }"#;
1055 let msg: BookSnapshotMsg = serde_json::from_str(json_data).unwrap();
1056 let ws_msg = WsMessage::BookSnapshot(msg);
1057
1058 let instrument_id = InstrumentId::from("ETC.HYPERLIQUID");
1059 let info = Arc::new(TardisInstrumentMiniInfo::new(
1060 instrument_id,
1061 None,
1062 TardisExchange::Hyperliquid,
1063 3,
1064 2,
1065 ));
1066
1067 let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Depth);
1068
1069 assert!(result.is_some());
1070 assert!(matches!(result.unwrap(), Data::BookDepth(_)));
1071 }
1072
1073 #[rstest]
1074 fn test_parse_tardis_ws_message_book_snapshot_routes_to_deltas() {
1075 let json_data = load_test_json("book_snapshot.json");
1076 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
1077 let ws_msg = WsMessage::BookSnapshot(msg);
1078
1079 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1080 let info = Arc::new(TardisInstrumentMiniInfo::new(
1081 instrument_id,
1082 None,
1083 TardisExchange::Bitmex,
1084 1,
1085 0,
1086 ));
1087
1088 let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Deltas);
1089
1090 assert!(result.is_some());
1091 assert!(matches!(result.unwrap(), Data::BookDeltas(_)));
1092 }
1093
1094 #[rstest]
1095 fn test_parse_tardis_ws_message_mexc_snapshot_5000_routes_to_deltas() {
1096 let json_data = load_test_json("mexc_book_snapshot_5000.json");
1097 let msg: BookSnapshotMsg = serde_json::from_str(&json_data).unwrap();
1098 let ws_msg = WsMessage::BookSnapshot(msg);
1099
1100 let instrument_id = InstrumentId::from("BTCUSDT.MEXC");
1101 let info = Arc::new(TardisInstrumentMiniInfo::new(
1102 instrument_id,
1103 None,
1104 TardisExchange::Mexc,
1105 1,
1106 1,
1107 ));
1108
1109 let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Deltas);
1110
1111 let Some(Data::BookDeltas(deltas)) = result else {
1112 panic!("Expected Data::BookDeltas, was {result:?}");
1113 };
1114 assert_eq!(deltas.instrument_id, instrument_id);
1115 assert_eq!(deltas.deltas.len(), 7);
1116 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1117 assert_eq!(deltas.deltas[1].order.price, Price::from("100.0"));
1118 assert_eq!(deltas.deltas[4].order.price, Price::from("101.0"));
1119 assert_eq!(
1120 deltas.deltas[6].flags,
1121 RecordFlag::F_LAST as u8 + RecordFlag::F_SNAPSHOT as u8
1122 );
1123 }
1124
1125 #[rstest]
1126 fn test_parse_tardis_ws_message_option_summary_routes_to_option_greeks() {
1127 let json_data = load_test_json("option_summary.json");
1128 let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1129 let ts_event = UnixNanos::from(msg.timestamp);
1130 let ts_init = UnixNanos::from(msg.local_timestamp);
1131 let ws_msg = WsMessage::OptionSummary(msg);
1132
1133 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1134 let info = Arc::new(TardisInstrumentMiniInfo::new(
1135 instrument_id,
1136 None,
1137 TardisExchange::Deribit,
1138 4,
1139 1,
1140 ));
1141
1142 let result = parse_tardis_ws_message(ws_msg, &info, &BookSnapshotOutput::Deltas);
1143
1144 let Some(Data::OptionGreeks(greeks)) = result else {
1145 panic!("Expected Data::OptionGreeks, was {result:?}");
1146 };
1147 assert_eq!(greeks.instrument_id, instrument_id);
1148 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
1149 assert_eq!(greeks.greeks.delta, 0.25);
1150 assert_eq!(greeks.greeks.gamma, 0.00002);
1151 assert_eq!(greeks.greeks.vega, 45.5);
1152 assert_eq!(greeks.greeks.theta, -15.2);
1153 assert_eq!(greeks.greeks.rho, 0.05);
1154 assert_eq!(greeks.mark_iv, Some(0.565));
1155 assert_eq!(greeks.bid_iv, Some(0.55));
1156 assert_eq!(greeks.ask_iv, Some(0.58));
1157 assert_eq!(greeks.underlying_price, Some(63_500.0));
1158 assert_eq!(greeks.open_interest, Some(150.0));
1159 assert_eq!(greeks.ts_event, ts_event);
1160 assert_eq!(greeks.ts_init, ts_init);
1161 }
1162
1163 #[rstest]
1164 fn test_parse_tardis_ws_message_data_extracts_option_summary_quote_when_enabled() {
1165 let json_data = load_test_json("option_summary.json");
1166 let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1167 let ts_event = UnixNanos::from(msg.timestamp);
1168 let ts_init = UnixNanos::from(msg.local_timestamp);
1169 let ws_msg = WsMessage::OptionSummary(msg);
1170
1171 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1172 let info = Arc::new(TardisInstrumentMiniInfo::new(
1173 instrument_id,
1174 None,
1175 TardisExchange::Deribit,
1176 4,
1177 1,
1178 ));
1179
1180 let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1181
1182 assert_eq!(data.len(), 2);
1183 let Data::Quote(quote) = &data[0] else {
1184 panic!("Expected first data item to be QuoteTick");
1185 };
1186 let Data::OptionGreeks(greeks) = &data[1] else {
1187 panic!("Expected second data item to be OptionGreeks");
1188 };
1189 assert_eq!(quote.instrument_id, instrument_id);
1190 assert_eq!(quote.bid_price, Price::from("0.035"));
1191 assert_eq!(quote.bid_size, Quantity::from("5"));
1192 assert_eq!(quote.ask_price, Price::from("0.04"));
1193 assert_eq!(quote.ask_size, Quantity::from("10"));
1194 assert_eq!(quote.ts_event, ts_event);
1195 assert_eq!(quote.ts_init, ts_init);
1196 assert_eq!(greeks.instrument_id, instrument_id);
1197 assert_eq!(greeks.ts_event, ts_event);
1198 assert_eq!(greeks.ts_init, ts_init);
1199 }
1200
1201 #[rstest]
1202 fn test_parse_tardis_ws_message_data_skips_option_summary_quote_when_disabled() {
1203 let json_data = load_test_json("option_summary.json");
1204 let msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1205 let ws_msg = WsMessage::OptionSummary(msg);
1206
1207 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1208 let info = Arc::new(TardisInstrumentMiniInfo::new(
1209 instrument_id,
1210 None,
1211 TardisExchange::Deribit,
1212 4,
1213 1,
1214 ));
1215
1216 let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, false);
1217
1218 assert_eq!(data.len(), 1);
1219 assert!(
1220 matches!(data[0], Data::OptionGreeks(_)),
1221 "Expected OptionGreeks, was {:?}",
1222 data[0]
1223 );
1224 }
1225
1226 #[rstest]
1227 fn test_parse_tardis_ws_message_data_skips_option_summary_quote_when_bbo_missing() {
1228 let json_data = load_test_json("option_summary.json");
1229 let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1230 msg.best_ask_price = None;
1231 let ws_msg = WsMessage::OptionSummary(msg);
1232
1233 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1234 let info = Arc::new(TardisInstrumentMiniInfo::new(
1235 instrument_id,
1236 None,
1237 TardisExchange::Deribit,
1238 4,
1239 1,
1240 ));
1241
1242 let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1243
1244 assert_eq!(data.len(), 1);
1245 assert!(
1246 matches!(data[0], Data::OptionGreeks(_)),
1247 "Expected OptionGreeks, was {:?}",
1248 data[0]
1249 );
1250 }
1251
1252 #[rstest]
1253 fn test_parse_tardis_ws_message_data_keeps_option_summary_when_bbo_size_invalid() {
1254 let json_data = load_test_json("option_summary.json");
1255 let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1256 let ts_event = UnixNanos::from(msg.timestamp);
1257 let ts_init = UnixNanos::from(msg.local_timestamp);
1258 msg.best_bid_amount = Some(Decimal::ZERO);
1259 let ws_msg = WsMessage::OptionSummary(msg);
1260
1261 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1262 let info = Arc::new(TardisInstrumentMiniInfo::new(
1263 instrument_id,
1264 None,
1265 TardisExchange::Deribit,
1266 4,
1267 1,
1268 ));
1269
1270 let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1271
1272 assert_eq!(data.len(), 1);
1273 let Data::OptionGreeks(greeks) = &data[0] else {
1274 panic!("Expected OptionGreeks, was {:?}", data[0]);
1275 };
1276 assert_eq!(greeks.instrument_id, instrument_id);
1277 assert_eq!(greeks.ts_event, ts_event);
1278 assert_eq!(greeks.ts_init, ts_init);
1279 }
1280
1281 #[rstest]
1282 fn test_parse_tardis_ws_message_data_keeps_option_summary_when_bbo_price_invalid() {
1283 let json_data = load_test_json("option_summary.json");
1284 let mut msg: OptionSummaryMsg = serde_json::from_str(&json_data).unwrap();
1285 let ts_event = UnixNanos::from(msg.timestamp);
1286 let ts_init = UnixNanos::from(msg.local_timestamp);
1287 msg.best_bid_price = Some(Decimal::MAX);
1288 let ws_msg = WsMessage::OptionSummary(msg);
1289
1290 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1291 let info = Arc::new(TardisInstrumentMiniInfo::new(
1292 instrument_id,
1293 None,
1294 TardisExchange::Deribit,
1295 4,
1296 1,
1297 ));
1298
1299 let data = parse_tardis_ws_message_data(ws_msg, &info, &BookSnapshotOutput::Deltas, true);
1300
1301 assert_eq!(data.len(), 1);
1302 let Data::OptionGreeks(greeks) = &data[0] else {
1303 panic!("Expected OptionGreeks, was {:?}", data[0]);
1304 };
1305 assert_eq!(greeks.instrument_id, instrument_id);
1306 assert_eq!(greeks.ts_event, ts_event);
1307 assert_eq!(greeks.ts_init, ts_init);
1308 }
1309
1310 #[rstest]
1311 fn test_parse_option_summary_msg_defaults_absent_fields() {
1312 let ts = "2024-01-15T10:30:00.123Z".parse::<Timestamp>().unwrap();
1313 let msg = OptionSummaryMsg {
1314 symbol: ustr::Ustr::from("BTC-28JUN24-70000-C"),
1315 exchange: TardisExchange::Deribit,
1316 option_type: "call".to_string(),
1317 strike_price: 70_000.0,
1318 expiration_date: ts,
1319 best_bid_price: None,
1320 best_bid_amount: None,
1321 best_bid_iv: None,
1322 best_ask_price: None,
1323 best_ask_amount: None,
1324 best_ask_iv: None,
1325 last_price: None,
1326 open_interest: None,
1327 mark_price: None,
1328 mark_iv: None,
1329 delta: None,
1330 gamma: None,
1331 vega: None,
1332 theta: None,
1333 rho: None,
1334 underlying_price: None,
1335 underlying_index: "BTC-USD".to_string(),
1336 timestamp: ts,
1337 local_timestamp: ts,
1338 };
1339
1340 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
1341 let greeks = parse_option_summary_msg(&msg, instrument_id);
1342
1343 assert_eq!(greeks.greeks.delta, 0.0);
1345 assert_eq!(greeks.greeks.gamma, 0.0);
1346 assert_eq!(greeks.greeks.vega, 0.0);
1347 assert_eq!(greeks.greeks.theta, 0.0);
1348 assert_eq!(greeks.greeks.rho, 0.0);
1349 assert_eq!(greeks.mark_iv, None);
1350 assert_eq!(greeks.bid_iv, None);
1351 assert_eq!(greeks.ask_iv, None);
1352 assert_eq!(greeks.underlying_price, None);
1353 assert_eq!(greeks.open_interest, None);
1354 }
1355
1356 #[rstest]
1357 fn test_parse_derivative_ticker_funding_rate() {
1358 let json_data = load_test_json("derivative_ticker.json");
1359 let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1360
1361 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1362
1363 let result = parse_derivative_ticker_msg(&msg, instrument_id).unwrap();
1364 assert!(result.is_some());
1365
1366 let funding = result.unwrap();
1367 assert_eq!(funding.instrument_id, instrument_id);
1368 assert_eq!(funding.rate.to_string(), "-0.00001568");
1369 assert!(funding.ts_event.as_u64() > 0);
1370 assert!(funding.ts_init.as_u64() > 0);
1371 }
1372
1373 #[rstest]
1374 fn test_parse_derivative_ticker_mark_price() {
1375 let json_data = load_test_json("derivative_ticker.json");
1376 let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1377
1378 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1379 let price_precision = 2;
1380
1381 let result =
1382 parse_derivative_ticker_mark_price(&msg, instrument_id, price_precision).unwrap();
1383 assert!(result.is_some());
1384
1385 let mark = result.unwrap();
1386 assert_eq!(mark.instrument_id, instrument_id);
1387 assert_eq!(mark.value, Price::new(7987.56, price_precision));
1388 assert!(mark.ts_event.as_u64() > 0);
1389 assert!(mark.ts_init.as_u64() > 0);
1390 }
1391
1392 #[rstest]
1393 fn test_parse_derivative_ticker_index_price() {
1394 let json_data = load_test_json("derivative_ticker.json");
1395 let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1396
1397 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1398 let price_precision = 2;
1399
1400 let result =
1401 parse_derivative_ticker_index_price(&msg, instrument_id, price_precision).unwrap();
1402 assert!(result.is_some());
1403
1404 let index = result.unwrap();
1405 assert_eq!(index.instrument_id, instrument_id);
1406 assert_eq!(index.value, Price::new(7989.28, price_precision));
1407 assert!(index.ts_event.as_u64() > 0);
1408 assert!(index.ts_init.as_u64() > 0);
1409 }
1410
1411 #[rstest]
1412 fn test_parse_derivative_ticker_missing_fields() {
1413 let json = r#"{
1415 "type": "derivative_ticker",
1416 "symbol": "BTCUSD",
1417 "exchange": "bitmex",
1418 "lastPrice": null,
1419 "openInterest": null,
1420 "fundingRate": 0.0001,
1421 "indexPrice": null,
1422 "markPrice": null,
1423 "timestamp": "2024-01-01T00:00:00.000Z",
1424 "localTimestamp": "2024-01-01T00:00:00.100Z"
1425 }"#;
1426 let msg: DerivativeTickerMsg = serde_json::from_str(json).unwrap();
1427
1428 let instrument_id = InstrumentId::from("BTCUSD.BITMEX");
1429
1430 let funding = parse_derivative_ticker_msg(&msg, instrument_id).unwrap();
1431 assert_eq!(funding.unwrap().next_funding_ns, None);
1432
1433 let mark = parse_derivative_ticker_mark_price(&msg, instrument_id, 1).unwrap();
1434 assert!(mark.is_none());
1435
1436 let index = parse_derivative_ticker_index_price(&msg, instrument_id, 1).unwrap();
1437 assert!(index.is_none());
1438 }
1439
1440 #[rstest]
1441 fn test_parse_mexc_futures_derivative_ticker() {
1442 let json_data = load_test_json("mexc_futures_derivative_ticker.json");
1443 let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1444
1445 let instrument_id = InstrumentId::from("BTC_USDT-PERP.MEXC");
1446
1447 let funding = parse_derivative_ticker_msg(&msg, instrument_id).unwrap();
1448 let mark = parse_derivative_ticker_mark_price(&msg, instrument_id, 1).unwrap();
1449 let index = parse_derivative_ticker_index_price(&msg, instrument_id, 1).unwrap();
1450
1451 assert_eq!(msg.exchange, TardisExchange::MexcFutures);
1452 assert_eq!(funding.unwrap().rate.to_string(), "0.0001");
1453 assert_eq!(mark.unwrap().value, Price::new(61235.2, 1));
1454 assert_eq!(index.unwrap().value, Price::new(61230.1, 1));
1455 }
1456
1457 #[rstest]
1458 fn test_parse_okex_xperp_derivative_ticker() {
1459 let json_data = load_test_json("okex_futures_xperp_derivative_ticker.json");
1460 let msg: DerivativeTickerMsg = serde_json::from_str(&json_data).unwrap();
1461
1462 let instrument_id = parse_instrument_id(&msg.exchange, msg.symbol);
1463 let funding = parse_derivative_ticker_msg(&msg, instrument_id)
1464 .unwrap()
1465 .unwrap();
1466 let mark = parse_derivative_ticker_mark_price(&msg, instrument_id, 1)
1467 .unwrap()
1468 .unwrap();
1469 let index = parse_derivative_ticker_index_price(&msg, instrument_id, 1)
1470 .unwrap()
1471 .unwrap();
1472
1473 assert_eq!(msg.exchange, TardisExchange::OkexFutures);
1476 assert_eq!(
1477 instrument_id,
1478 InstrumentId::from("BTC-USD_UM_XPERP-310404.OKEX")
1479 );
1480 assert_eq!(funding.rate, dec!(-0.0004050736802759));
1481 assert_eq!(
1482 funding.next_funding_ns,
1483 Some(UnixNanos::from("2026-08-10T16:00:00Z"))
1484 );
1485 assert_eq!(funding.interval, None);
1486 assert_eq!(
1487 funding.ts_event,
1488 UnixNanos::from("2026-08-10T12:00:13.061Z")
1489 );
1490 assert_eq!(funding.ts_init, UnixNanos::from("2026-08-10T12:00:13.093Z"));
1491 assert_eq!(mark.value, Price::new(65014.7, 1));
1492 assert_eq!(index.value, Price::new(65066.1, 1));
1493 }
1494
1495 #[rstest]
1496 fn test_parse_okex_usdc_derivative_ticker_across_index_migration() {
1497 let pre_json = load_test_json("okex_swap_derivative_ticker_pre_index_migration.json");
1498 let post_json = load_test_json("okex_swap_derivative_ticker_post_index_migration.json");
1499 let pre: DerivativeTickerMsg = serde_json::from_str(&pre_json).unwrap();
1500 let post: DerivativeTickerMsg = serde_json::from_str(&post_json).unwrap();
1501
1502 let pre_id = parse_instrument_id(&pre.exchange, pre.symbol);
1503 let post_id = parse_instrument_id(&post.exchange, post.symbol);
1504 let pre_index = parse_derivative_ticker_index_price(&pre, pre_id, 1)
1505 .unwrap()
1506 .unwrap();
1507 let post_index = parse_derivative_ticker_index_price(&post, post_id, 1)
1508 .unwrap()
1509 .unwrap();
1510 let pre_funding = parse_derivative_ticker_msg(&pre, pre_id).unwrap().unwrap();
1511 let post_funding = parse_derivative_ticker_msg(&post, post_id)
1512 .unwrap()
1513 .unwrap();
1514
1515 assert_eq!(pre.exchange, TardisExchange::OkexSwap);
1520 assert_eq!(post.exchange, TardisExchange::OkexSwap);
1521 assert_eq!(pre_id, InstrumentId::from("BTC-USDC-SWAP.OKEX"));
1522 assert_eq!(post_id, pre_id);
1523 assert_eq!(pre_index.value, Price::new(28379.9, 1));
1524 assert_eq!(post_index.value, Price::new(28525.1, 1));
1525 assert_eq!(pre_funding.rate, dec!(-0.0001546799749051));
1526 assert_eq!(post_funding.rate, dec!(0.0001309027042856));
1527 assert_eq!(
1528 pre_funding.next_funding_ns,
1529 Some(UnixNanos::from("2023-04-01T16:00:00Z"))
1530 );
1531 assert_eq!(
1532 post_funding.next_funding_ns,
1533 Some(UnixNanos::from("2023-05-01T16:00:00Z"))
1534 );
1535 }
1536
1537 fn load_exact_decimal_messages() -> Vec<WsMessage> {
1538 let json_data = load_test_json("exact_decimals.json");
1539 serde_json::from_str(&json_data).unwrap()
1540 }
1541
1542 #[rstest]
1543 fn test_parse_trade_msg_keeps_decimals_that_collapse_in_f64() {
1544 let messages = load_exact_decimal_messages();
1545
1546 let (WsMessage::Trade(first), WsMessage::Trade(second)) = (&messages[0], &messages[1])
1547 else {
1548 panic!("Expected trade messages, was {messages:?}");
1549 };
1550
1551 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1552
1553 let first_trade = parse_trade_msg(first, 9, 9, instrument_id).unwrap();
1554 let second_trade = parse_trade_msg(second, 9, 9, instrument_id).unwrap();
1555
1556 let first_f64 = "100000000.123456789".parse::<f64>().unwrap();
1557 let second_f64 = "100000000.123456788".parse::<f64>().unwrap();
1558 assert_eq!(first_f64.to_bits(), second_f64.to_bits());
1559 assert_eq!(first_trade.price, Price::from("100000000.123456789"));
1560 assert_eq!(first_trade.size, Quantity::from("100000000.123456789"));
1561 assert_eq!(second_trade.price, Price::from("100000000.123456788"));
1562 assert_eq!(second_trade.size, Quantity::from("100000000.123456788"));
1563 assert_ne!(first_trade.trade_id, second_trade.trade_id);
1564 }
1565
1566 #[rstest]
1567 fn test_parse_trade_msg_rounds_exact_token_half_even() {
1568 let messages = load_exact_decimal_messages();
1569
1570 let WsMessage::Trade(msg) = &messages[2] else {
1571 panic!("Expected trade message, was {:?}", messages[2]);
1572 };
1573
1574 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1575
1576 let trade = parse_trade_msg(msg, 2, 2, instrument_id).unwrap();
1577
1578 let token_f64 = "100000000.005000001".parse::<f64>().unwrap();
1579 let via_f64 = Price::from_decimal_dp(Decimal::try_from(token_f64).unwrap(), 2).unwrap();
1580 assert_eq!(via_f64, Price::from("100000000.00"));
1581 assert_eq!(trade.price, Price::from("100000000.01"));
1582 assert_eq!(trade.size, Quantity::from("100000000.01"));
1583 }
1584
1585 #[rstest]
1586 fn test_parse_book_change_msg_keeps_decimals_that_collapse_in_f64() {
1587 let messages = load_exact_decimal_messages();
1588
1589 let WsMessage::BookChange(msg) = &messages[3] else {
1590 panic!("Expected book change message, was {:?}", messages[3]);
1591 };
1592
1593 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1594
1595 let deltas = parse_book_change_msg_as_deltas(msg, 9, 9, instrument_id).unwrap();
1596
1597 assert_eq!(deltas.deltas.len(), 2);
1598 assert_eq!(deltas.deltas[0].order.side, Some(OrderSide::Buy));
1599 assert_eq!(
1600 deltas.deltas[0].order.price,
1601 Price::from("100000000.123456789")
1602 );
1603 assert_eq!(
1604 deltas.deltas[0].order.size,
1605 Quantity::from("100000000.123456789")
1606 );
1607 assert_eq!(deltas.deltas[1].order.side, Some(OrderSide::Sell));
1608 assert_eq!(
1609 deltas.deltas[1].order.price,
1610 Price::from("100000000.123456788")
1611 );
1612 assert_eq!(
1613 deltas.deltas[1].order.size,
1614 Quantity::from("100000000.123456788")
1615 );
1616 }
1617
1618 #[rstest]
1619 fn test_parse_derivative_ticker_msg_keeps_exact_tokens() {
1620 let messages = load_exact_decimal_messages();
1621
1622 let WsMessage::DerivativeTicker(msg) = &messages[4] else {
1623 panic!("Expected derivative ticker message, was {:?}", messages[4]);
1624 };
1625
1626 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1627
1628 let funding = parse_derivative_ticker_msg(msg, instrument_id)
1629 .unwrap()
1630 .unwrap();
1631 let mark = parse_derivative_ticker_mark_price(msg, instrument_id, 9)
1632 .unwrap()
1633 .unwrap();
1634 let index = parse_derivative_ticker_index_price(msg, instrument_id, 9)
1635 .unwrap()
1636 .unwrap();
1637
1638 assert_eq!(funding.rate, dec!(0.30000000000000004));
1639 assert_eq!(mark.value, Price::from("100000000.123456788"));
1640 assert_eq!(index.value, Price::from("100000000.123456789"));
1641 }
1642
1643 #[rstest]
1644 fn test_parse_book_change_msg_normalizes_float_noise_amount_to_delete() {
1645 let messages = load_exact_decimal_messages();
1646
1647 let WsMessage::BookChange(msg) = &messages[5] else {
1648 panic!("Expected book change message, was {:?}", messages[5]);
1649 };
1650
1651 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1652
1653 let deltas = parse_book_change_msg_as_deltas(msg, 1, 0, instrument_id).unwrap();
1654
1655 assert_eq!(msg.bids[0].amount, dec!(0.0000000000000001110223024625));
1656 assert_eq!(deltas.deltas.len(), 1);
1657 assert_eq!(deltas.deltas[0].action, BookAction::Delete);
1658 assert_eq!(deltas.deltas[0].order.price, Price::from("7984.5"));
1659 assert_eq!(deltas.deltas[0].order.size, Quantity::from("0"));
1660 }
1661
1662 #[rstest]
1663 fn test_parse_trade_msg_derives_trade_id_from_decimal_value() {
1664 let messages = load_exact_decimal_messages();
1665
1666 let (WsMessage::Trade(padded), WsMessage::Trade(plain)) = (&messages[6], &messages[7])
1667 else {
1668 panic!("Expected trade messages, was {messages:?}");
1669 };
1670
1671 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1672
1673 let padded_trade = parse_trade_msg(padded, 1, 0, instrument_id).unwrap();
1674 let plain_trade = parse_trade_msg(plain, 1, 0, instrument_id).unwrap();
1675 let csv_trade_id = derive_trade_id(
1676 ustr::Ustr::from("XBTUSD"),
1677 padded_trade.ts_event.as_u64(),
1678 &7996.5_f64.to_string(),
1679 &50.0_f64.to_string(),
1680 "sell",
1681 );
1682
1683 assert_eq!(padded.price.to_string(), "7996.50");
1684 assert_eq!(padded.amount.to_string(), "50.0");
1685 assert_eq!(padded_trade.trade_id, plain_trade.trade_id);
1686 assert_eq!(padded_trade.trade_id, csv_trade_id);
1687 }
1688
1689 #[rstest]
1690 fn test_parse_book_change_msg_truncates_amounts_and_skips_invalid_price() {
1691 let messages = load_exact_decimal_messages();
1692
1693 let WsMessage::BookChange(msg) = &messages[8] else {
1694 panic!("Expected book change message, was {:?}", messages[8]);
1695 };
1696
1697 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
1698
1699 let deltas = parse_book_change_msg_as_deltas(msg, 1, 2, instrument_id).unwrap();
1700
1701 assert_eq!(deltas.deltas.len(), 2);
1702 assert_eq!(deltas.deltas[0].order.price, Price::from("7984.5"));
1703 assert_eq!(deltas.deltas[0].order.size, Quantity::from("0.12"));
1704 assert_eq!(deltas.deltas[1].order.price, Price::from("7984.0"));
1705 assert_eq!(deltas.deltas[1].order.size, Quantity::from("3.00"));
1706 assert_eq!(deltas.deltas[1].flags, RecordFlag::F_LAST as u8);
1707 }
1708
1709 #[rstest]
1710 #[case(dec!(0.015), "0.02")]
1711 #[case(dec!(0.006), "0.01")]
1712 fn test_parse_non_zero_quantity_rounds_half_even(
1713 #[case] amount: Decimal,
1714 #[case] expected: &str,
1715 ) {
1716 let quantity = parse_non_zero_quantity(amount, 2).unwrap();
1717
1718 assert_eq!(quantity, Quantity::from(expected));
1719 }
1720
1721 #[rstest]
1722 #[case(dec!(0), "value was zero")]
1723 #[case(dec!(0.004), "value 0.004 was zero after rounding to precision 2")]
1724 #[case(dec!(0.005), "value 0.005 was zero after rounding to precision 2")]
1725 fn test_parse_non_zero_quantity_rejects_zero(#[case] amount: Decimal, #[case] expected: &str) {
1726 let error = parse_non_zero_quantity(amount, 2).unwrap_err();
1727
1728 assert_eq!(error.to_string(), expected);
1729 }
1730}