1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23 data::{
24 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
25 OrderBookDelta, OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick,
26 depth::DEPTH10_LEN,
27 },
28 enums::{
29 AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType, RecordFlag,
30 TimeInForce,
31 },
32 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
33 instruments::{Instrument, InstrumentAny},
34 reports::{FillReport, OrderStatusReport},
35 types::{Currency, Money, Price, Quantity},
36};
37use rust_decimal::{Decimal, prelude::FromPrimitive};
38
39use super::messages::{
40 CandleData, WsActiveAssetCtxData, WsBboData, WsBookData, WsFillData, WsOrderData, WsTradeData,
41};
42use crate::common::{
43 enums::HyperliquidFillDirection,
44 parse::{
45 is_conditional_order_data, make_fill_trade_id, millis_to_nanos, parse_trigger_order_type,
46 },
47};
48
49fn parse_price(
50 price_str: &str,
51 instrument: &InstrumentAny,
52 field_name: &str,
53) -> anyhow::Result<Price> {
54 let decimal = Decimal::from_str(price_str)
55 .with_context(|| format!("Failed to parse price from '{price_str}' for {field_name}"))?;
56
57 Price::from_decimal_dp(decimal, instrument.price_precision())
58 .with_context(|| format!("Failed to create price from '{price_str}' for {field_name}"))
59}
60
61fn parse_quantity(
62 quantity_str: &str,
63 instrument: &InstrumentAny,
64 field_name: &str,
65) -> anyhow::Result<Quantity> {
66 let decimal = Decimal::from_str(quantity_str).with_context(|| {
67 format!("Failed to parse quantity from '{quantity_str}' for {field_name}")
68 })?;
69
70 Quantity::from_decimal_dp(decimal.abs(), instrument.size_precision()).with_context(|| {
71 format!("Failed to create quantity from '{quantity_str}' for {field_name}")
72 })
73}
74
75pub fn parse_ws_trade_tick(
77 trade: &WsTradeData,
78 instrument: &InstrumentAny,
79 ts_init: UnixNanos,
80) -> anyhow::Result<TradeTick> {
81 let price = parse_price(&trade.px, instrument, "trade.px")?;
82 let size = parse_quantity(&trade.sz, instrument, "trade.sz")?;
83 let aggressor = AggressorSide::from(trade.side);
84 let trade_id = TradeId::new_checked(trade.tid.to_string())
85 .context("invalid trade identifier in Hyperliquid trade message")?;
86 let ts_event = millis_to_nanos(trade.time)?;
87
88 TradeTick::new_checked(
89 instrument.id(),
90 price,
91 size,
92 aggressor,
93 trade_id,
94 ts_event,
95 ts_init,
96 )
97 .context("failed to construct TradeTick from Hyperliquid trade message")
98}
99
100pub fn parse_ws_order_book_deltas(
102 book: &WsBookData,
103 instrument: &InstrumentAny,
104 ts_init: UnixNanos,
105) -> anyhow::Result<OrderBookDeltas> {
106 let ts_event = millis_to_nanos(book.time)?;
107 let mut deltas = Vec::new();
108
109 deltas.push(OrderBookDelta::clear(instrument.id(), 0, ts_event, ts_init));
111
112 for level in &book.levels[0] {
113 let price = parse_price(&level.px, instrument, "book.bid.px")?;
114 let size = parse_quantity(&level.sz, instrument, "book.bid.sz")?;
115
116 if !size.is_positive() {
117 continue;
118 }
119
120 let order = BookOrder::new(OrderSide::Buy, price, size, 0);
121
122 let delta = OrderBookDelta::new(
123 instrument.id(),
124 BookAction::Add,
125 order,
126 RecordFlag::F_LAST as u8,
127 0, ts_event,
129 ts_init,
130 );
131
132 deltas.push(delta);
133 }
134
135 for level in &book.levels[1] {
136 let price = parse_price(&level.px, instrument, "book.ask.px")?;
137 let size = parse_quantity(&level.sz, instrument, "book.ask.sz")?;
138
139 if !size.is_positive() {
140 continue;
141 }
142
143 let order = BookOrder::new(OrderSide::Sell, price, size, 0);
144
145 let delta = OrderBookDelta::new(
146 instrument.id(),
147 BookAction::Add,
148 order,
149 RecordFlag::F_LAST as u8,
150 0, ts_event,
152 ts_init,
153 );
154
155 deltas.push(delta);
156 }
157
158 Ok(OrderBookDeltas::new(instrument.id(), deltas))
159}
160
161pub fn parse_ws_order_book_depth10(
168 book: &WsBookData,
169 instrument: &InstrumentAny,
170 ts_init: UnixNanos,
171) -> anyhow::Result<OrderBookDepth10> {
172 let ts_event = millis_to_nanos(book.time)?;
173 let price_precision = instrument.price_precision();
174 let size_precision = instrument.size_precision();
175
176 let mut bids: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
177 let mut asks: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
178 let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
179 let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
180
181 let raw_bids = book.levels.first().map_or(&[][..], |v| v.as_slice());
182 let raw_asks = book.levels.get(1).map_or(&[][..], |v| v.as_slice());
183
184 for (i, level) in raw_bids.iter().take(DEPTH10_LEN).enumerate() {
185 let price = parse_price(&level.px, instrument, "book.bid.px")?;
186 let size = parse_quantity(&level.sz, instrument, "book.bid.sz")?;
187 bids[i] = BookOrder::new(OrderSide::Buy, price, size, 0);
188 bid_counts[i] = level.n;
189 }
190
191 for bid in bids.iter_mut().skip(raw_bids.len().min(DEPTH10_LEN)) {
192 *bid = BookOrder::new(
193 OrderSide::Buy,
194 Price::zero(price_precision),
195 Quantity::zero(size_precision),
196 0,
197 );
198 }
199
200 for (i, level) in raw_asks.iter().take(DEPTH10_LEN).enumerate() {
201 let price = parse_price(&level.px, instrument, "book.ask.px")?;
202 let size = parse_quantity(&level.sz, instrument, "book.ask.sz")?;
203 asks[i] = BookOrder::new(OrderSide::Sell, price, size, 0);
204 ask_counts[i] = level.n;
205 }
206
207 for ask in asks.iter_mut().skip(raw_asks.len().min(DEPTH10_LEN)) {
208 *ask = BookOrder::new(
209 OrderSide::Sell,
210 Price::zero(price_precision),
211 Quantity::zero(size_precision),
212 0,
213 );
214 }
215
216 Ok(OrderBookDepth10::new(
217 instrument.id(),
218 bids,
219 asks,
220 bid_counts,
221 ask_counts,
222 RecordFlag::F_SNAPSHOT as u8,
223 0,
224 ts_event,
225 ts_init,
226 ))
227}
228
229pub fn parse_ws_quote_tick(
231 bbo: &WsBboData,
232 instrument: &InstrumentAny,
233 ts_init: UnixNanos,
234) -> anyhow::Result<QuoteTick> {
235 let bid_level = bbo.bbo[0]
236 .as_ref()
237 .context("BBO message missing bid level")?;
238 let ask_level = bbo.bbo[1]
239 .as_ref()
240 .context("BBO message missing ask level")?;
241
242 let bid_price = parse_price(&bid_level.px, instrument, "bbo.bid.px")?;
243 let ask_price = parse_price(&ask_level.px, instrument, "bbo.ask.px")?;
244 let bid_size = parse_quantity(&bid_level.sz, instrument, "bbo.bid.sz")?;
245 let ask_size = parse_quantity(&ask_level.sz, instrument, "bbo.ask.sz")?;
246
247 let ts_event = millis_to_nanos(bbo.time)?;
248
249 QuoteTick::new_checked(
250 instrument.id(),
251 bid_price,
252 ask_price,
253 bid_size,
254 ask_size,
255 ts_event,
256 ts_init,
257 )
258 .context("failed to construct QuoteTick from Hyperliquid BBO message")
259}
260
261pub fn parse_ws_candle(
263 candle: &CandleData,
264 instrument: &InstrumentAny,
265 bar_type: &BarType,
266 ts_init: UnixNanos,
267) -> anyhow::Result<Bar> {
268 let open = parse_price(&candle.o, instrument, "candle.o")?;
269 let high = parse_price(&candle.h, instrument, "candle.h")?;
270 let low = parse_price(&candle.l, instrument, "candle.l")?;
271 let close = parse_price(&candle.c, instrument, "candle.c")?;
272 let volume = parse_quantity(&candle.v, instrument, "candle.v")?;
273
274 let ts_event = millis_to_nanos(candle.t)?;
275
276 Ok(Bar::new(
277 *bar_type, open, high, low, close, volume, ts_event, ts_init,
278 ))
279}
280
281pub fn parse_ws_order_status_report(
286 order: &WsOrderData,
287 instrument: &InstrumentAny,
288 account_id: AccountId,
289 ts_init: UnixNanos,
290) -> anyhow::Result<OrderStatusReport> {
291 let instrument_id = instrument.id();
292 let venue_order_id = VenueOrderId::new(order.order.oid.to_string());
293 let order_side = OrderSide::from(order.order.side);
294
295 let order_type = if is_conditional_order_data(
297 order.order.trigger_px.as_deref(),
298 order.order.tpsl.as_ref(),
299 ) {
300 if let (Some(is_market), Some(tpsl)) = (order.order.is_market, order.order.tpsl.as_ref()) {
301 parse_trigger_order_type(is_market, tpsl)
302 } else {
303 OrderType::Limit }
305 } else {
306 OrderType::Limit };
308
309 let time_in_force = TimeInForce::Gtc;
310 let order_status = OrderStatus::from(order.status);
311
312 let orig_qty = parse_quantity(&order.order.orig_sz, instrument, "order.orig_sz")?;
314 let remaining_qty = parse_quantity(&order.order.sz, instrument, "order.sz")?;
315 let filled_qty = Quantity::from_raw(
316 orig_qty.raw.saturating_sub(remaining_qty.raw),
317 instrument.size_precision(),
318 );
319
320 let price = parse_price(&order.order.limit_px, instrument, "order.limitPx")?;
321
322 let ts_accepted = millis_to_nanos(order.order.timestamp)?;
323 let ts_last = millis_to_nanos(order.status_timestamp)?;
324
325 let mut report = OrderStatusReport::new(
326 account_id,
327 instrument_id,
328 None, venue_order_id,
330 order_side,
331 order_type,
332 time_in_force,
333 order_status,
334 orig_qty, filled_qty,
336 ts_accepted,
337 ts_last,
338 ts_init,
339 Some(UUID4::new()),
340 );
341
342 if let Some(ref cloid) = order.order.cloid {
343 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
344 }
345
346 report = report.with_price(price);
347
348 if let Some(ref trigger_px_str) = order.order.trigger_px {
349 let trigger_price = parse_price(trigger_px_str, instrument, "order.triggerPx")?;
350 report = report.with_trigger_price(trigger_price);
351 }
352
353 Ok(report)
354}
355
356pub fn parse_ws_fill_report(
360 fill: &WsFillData,
361 instrument: &InstrumentAny,
362 account_id: AccountId,
363 ts_init: UnixNanos,
364) -> anyhow::Result<FillReport> {
365 let instrument_id = instrument.id();
366
367 if let Some(liquidation) = fill.liquidation.as_ref() {
368 log::warn!(
369 "Liquidation fill: {} oid={} method={:?} mark_px={} liquidated_user={}",
370 instrument_id,
371 fill.oid,
372 liquidation.method,
373 liquidation.mark_px,
374 liquidation
375 .liquidated_user
376 .as_deref()
377 .unwrap_or("<unknown>"),
378 );
379 } else if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
380 log::warn!(
381 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
382 fill.oid,
383 fill.px,
384 fill.sz,
385 );
386 }
387
388 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
389 let trade_id = make_fill_trade_id(
390 &fill.hash,
391 fill.oid,
392 &fill.px,
393 &fill.sz,
394 fill.time,
395 &fill.start_position,
396 );
397
398 let order_side = OrderSide::from(fill.side);
399 let last_qty = parse_quantity(&fill.sz, instrument, "fill.sz")?;
400 let last_px = parse_price(&fill.px, instrument, "fill.px")?;
401 let liquidity_side = if fill.crossed {
402 LiquiditySide::Taker
403 } else {
404 LiquiditySide::Maker
405 };
406
407 let fee_amount = Decimal::from_str(&fill.fee)
408 .with_context(|| format!("Failed to parse fee='{}' as decimal", fill.fee))?;
409
410 let commission_currency = Currency::from_str(fill.fee_token.as_str())
411 .with_context(|| format!("Unknown fee token '{}'", fill.fee_token))?;
412
413 let commission = Money::from_decimal(fee_amount, commission_currency)
414 .with_context(|| format!("Failed to create commission from fee='{}'", fill.fee))?;
415 let ts_event = millis_to_nanos(fill.time)?;
416
417 let client_order_id = None;
419
420 Ok(FillReport::new(
421 account_id,
422 instrument_id,
423 venue_order_id,
424 trade_id,
425 order_side,
426 last_qty,
427 last_px,
428 commission,
429 liquidity_side,
430 client_order_id,
431 None, ts_event,
433 ts_init,
434 None, ))
436}
437
438pub fn parse_ws_asset_context(
444 ctx: &WsActiveAssetCtxData,
445 instrument: &InstrumentAny,
446 ts_init: UnixNanos,
447) -> anyhow::Result<(
448 MarkPriceUpdate,
449 Option<IndexPriceUpdate>,
450 Option<FundingRateUpdate>,
451)> {
452 let instrument_id = instrument.id();
453
454 match ctx {
455 WsActiveAssetCtxData::Perp { coin: _, ctx } => {
456 let mark_px_f64 = ctx
457 .shared
458 .mark_px
459 .parse::<f64>()
460 .context("Failed to parse mark_px as f64")?;
461 let mark_price = parse_f64_price(mark_px_f64, instrument, "ctx.mark_px")?;
462 let mark_price_update =
463 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
464
465 let oracle_px_f64 = ctx
466 .oracle_px
467 .parse::<f64>()
468 .context("Failed to parse oracle_px as f64")?;
469 let index_price = parse_f64_price(oracle_px_f64, instrument, "ctx.oracle_px")?;
470 let index_price_update =
471 IndexPriceUpdate::new(instrument_id, index_price, ts_init, ts_init);
472
473 let funding_f64 = ctx
474 .funding
475 .parse::<f64>()
476 .context("Failed to parse funding as f64")?;
477 let funding_rate_decimal = Decimal::from_f64(funding_f64)
478 .context("Failed to convert funding rate to Decimal")?;
479 let funding_rate_update = FundingRateUpdate::new(
480 instrument_id,
481 funding_rate_decimal,
482 Some(60), None, ts_init,
485 ts_init,
486 );
487
488 Ok((
489 mark_price_update,
490 Some(index_price_update),
491 Some(funding_rate_update),
492 ))
493 }
494 WsActiveAssetCtxData::Spot { coin: _, ctx } => {
495 let mark_px_f64 = ctx
496 .shared
497 .mark_px
498 .parse::<f64>()
499 .context("Failed to parse mark_px as f64")?;
500 let mark_price = parse_f64_price(mark_px_f64, instrument, "ctx.mark_px")?;
501 let mark_price_update =
502 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
503
504 Ok((mark_price_update, None, None))
505 }
506 }
507}
508
509fn parse_f64_price(
510 price: f64,
511 instrument: &InstrumentAny,
512 field_name: &str,
513) -> anyhow::Result<Price> {
514 if !price.is_finite() {
515 anyhow::bail!("Invalid price value for {field_name}: {price} (must be finite)");
516 }
517 Ok(Price::new(price, instrument.price_precision()))
518}
519
520#[cfg(test)]
521mod tests {
522 use nautilus_model::{
523 identifiers::{InstrumentId, Symbol, Venue},
524 instruments::CryptoPerpetual,
525 types::currency::Currency,
526 };
527 use rstest::rstest;
528 use ustr::Ustr;
529
530 use super::*;
531 use crate::{
532 common::enums::{
533 HyperliquidFillDirection, HyperliquidLiquidationMethod,
534 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
535 },
536 websocket::messages::{
537 FillLiquidationData, PerpsAssetCtx, SharedAssetCtx, SpotAssetCtx, WsBasicOrderData,
538 WsBookData, WsLevelData,
539 },
540 };
541
542 fn create_test_instrument() -> InstrumentAny {
543 let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), Venue::new("HYPERLIQUID"));
544
545 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
546 instrument_id,
547 Symbol::new("BTC-PERP"),
548 Currency::from("BTC"),
549 Currency::from("USDC"),
550 Currency::from("USDC"),
551 false, 2, 3, Price::from("0.01"),
555 Quantity::from("0.001"),
556 None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::default(),
570 UnixNanos::default(),
571 ))
572 }
573
574 #[rstest]
575 fn test_parse_ws_order_status_report_basic() {
576 let instrument = create_test_instrument();
577 let account_id = AccountId::new("HYPERLIQUID-001");
578 let ts_init = UnixNanos::default();
579
580 let order_data = WsOrderData {
581 order: WsBasicOrderData {
582 coin: Ustr::from("BTC"),
583 side: HyperliquidSide::Buy,
584 limit_px: "50000.0".to_string(),
585 sz: "0.5".to_string(),
586 oid: 12345,
587 timestamp: 1704470400000,
588 orig_sz: "1.0".to_string(),
589 cloid: Some("test-order-1".to_string()),
590 trigger_px: None,
591 is_market: None,
592 tpsl: None,
593 trigger_activated: None,
594 trailing_stop: None,
595 },
596 status: HyperliquidOrderStatusEnum::Open,
597 status_timestamp: 1704470400000,
598 };
599
600 let result = parse_ws_order_status_report(&order_data, &instrument, account_id, ts_init);
601 assert!(result.is_ok());
602
603 let report = result.unwrap();
604 assert_eq!(report.order_side, OrderSide::Buy);
605 assert_eq!(report.order_type, OrderType::Limit);
606 assert_eq!(report.order_status, OrderStatus::Accepted);
607 }
608
609 #[rstest]
610 fn test_parse_ws_fill_report_basic() {
611 let instrument = create_test_instrument();
612 let account_id = AccountId::new("HYPERLIQUID-001");
613 let ts_init = UnixNanos::default();
614
615 let fill_data = WsFillData {
616 coin: Ustr::from("BTC"),
617 px: "50000.0".to_string(),
618 sz: "0.1".to_string(),
619 side: HyperliquidSide::Buy,
620 time: 1704470400000,
621 start_position: "0.0".to_string(),
622 dir: HyperliquidFillDirection::OpenLong,
623 closed_pnl: "0.0".to_string(),
624 hash: "0xabc123".to_string(),
625 oid: 12345,
626 crossed: true,
627 fee: "0.05".to_string(),
628 tid: 98765,
629 liquidation: None,
630 fee_token: Ustr::from("USDC"),
631 builder_fee: None,
632 cloid: Some("0xd211f1c27288259290850338d22132a0".to_string()),
633 twap_id: None,
634 };
635
636 let result = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init);
637 assert!(result.is_ok());
638
639 let report = result.unwrap();
640 assert_eq!(report.order_side, OrderSide::Buy);
641 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
642 }
643
644 #[rstest]
645 fn test_parse_ws_fill_report_with_liquidation() {
646 let instrument = create_test_instrument();
647 let account_id = AccountId::new("HYPERLIQUID-001");
648 let ts_init = UnixNanos::default();
649
650 let fill_data = WsFillData {
651 coin: Ustr::from("BTC"),
652 px: "50000.0".to_string(),
653 sz: "0.1".to_string(),
654 side: HyperliquidSide::Sell,
655 time: 1704470400000,
656 start_position: "0.1".to_string(),
657 dir: HyperliquidFillDirection::CloseLong,
658 closed_pnl: "-25.0".to_string(),
659 hash: "0xdef456".to_string(),
660 oid: 54321,
661 crossed: true,
662 fee: "0.0".to_string(),
663 tid: 12345,
664 liquidation: Some(FillLiquidationData {
665 liquidated_user: Some("0xuser".to_string()),
666 mark_px: 50_000.0,
667 method: HyperliquidLiquidationMethod::Market,
668 }),
669 fee_token: Ustr::from("USDC"),
670 builder_fee: None,
671 cloid: None,
672 twap_id: None,
673 };
674
675 let report = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init).unwrap();
676
677 assert_eq!(report.order_side, OrderSide::Sell);
680 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
681 assert_eq!(report.venue_order_id.to_string(), "54321");
682 }
683
684 #[rstest]
685 fn test_parse_ws_order_book_deltas_snapshot_behavior() {
686 let instrument = create_test_instrument();
687 let ts_init = UnixNanos::default();
688
689 let book = WsBookData {
690 coin: Ustr::from("BTC"),
691 levels: [
692 vec![WsLevelData {
693 px: "50000.0".to_string(),
694 sz: "1.0".to_string(),
695 n: 1,
696 }],
697 vec![WsLevelData {
698 px: "50001.0".to_string(),
699 sz: "2.0".to_string(),
700 n: 1,
701 }],
702 ],
703 time: 1_704_470_400_000,
704 };
705
706 let deltas = parse_ws_order_book_deltas(&book, &instrument, ts_init).unwrap();
707
708 assert_eq!(deltas.deltas.len(), 3); assert_eq!(deltas.deltas[0].action, BookAction::Clear);
710
711 let bid_delta = &deltas.deltas[1];
712 assert_eq!(bid_delta.action, BookAction::Add);
713 assert_eq!(bid_delta.order.side, OrderSide::Buy);
714 assert!(bid_delta.order.size.is_positive());
715 assert_eq!(bid_delta.order.order_id, 0);
716
717 let ask_delta = &deltas.deltas[2];
718 assert_eq!(ask_delta.action, BookAction::Add);
719 assert_eq!(ask_delta.order.side, OrderSide::Sell);
720 assert!(ask_delta.order.size.is_positive());
721 assert_eq!(ask_delta.order.order_id, 0);
722 }
723
724 #[rstest]
725 fn test_parse_ws_order_book_depth10_pads_sparse_book() {
726 let instrument = create_test_instrument();
727 let ts_init = UnixNanos::default();
728
729 let book = WsBookData {
731 coin: Ustr::from("BTC"),
732 levels: [
733 vec![
734 WsLevelData {
735 px: "100.00".to_string(),
736 sz: "1.0".to_string(),
737 n: 2,
738 },
739 WsLevelData {
740 px: "99.99".to_string(),
741 sz: "2.0".to_string(),
742 n: 3,
743 },
744 WsLevelData {
745 px: "99.98".to_string(),
746 sz: "3.0".to_string(),
747 n: 1,
748 },
749 ],
750 vec![
751 WsLevelData {
752 px: "100.01".to_string(),
753 sz: "1.5".to_string(),
754 n: 1,
755 },
756 WsLevelData {
757 px: "100.02".to_string(),
758 sz: "2.5".to_string(),
759 n: 4,
760 },
761 ],
762 ],
763 time: 1_704_470_400_000,
764 };
765
766 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
767
768 assert_eq!(depth.instrument_id, instrument.id());
769 assert_eq!(depth.bids.len(), 10);
770 assert_eq!(depth.asks.len(), 10);
771
772 assert_eq!(depth.bids[0].price.as_f64(), 100.00);
773 assert_eq!(depth.bids[0].side, OrderSide::Buy);
774 assert_eq!(depth.bid_counts[0], 2);
775 assert_eq!(depth.bids[2].price.as_f64(), 99.98);
776 assert_eq!(depth.bid_counts[2], 1);
777
778 for i in 3..10 {
780 assert_eq!(depth.bids[i].side, OrderSide::Buy);
781 assert!(depth.bids[i].size.is_zero());
782 assert_eq!(depth.bid_counts[i], 0);
783 }
784
785 assert_eq!(depth.asks[0].price.as_f64(), 100.01);
786 assert_eq!(depth.asks[0].side, OrderSide::Sell);
787 assert_eq!(depth.ask_counts[0], 1);
788 assert_eq!(depth.asks[1].price.as_f64(), 100.02);
789 assert_eq!(depth.ask_counts[1], 4);
790
791 for i in 2..10 {
792 assert_eq!(depth.asks[i].side, OrderSide::Sell);
793 assert!(depth.asks[i].size.is_zero());
794 assert_eq!(depth.ask_counts[i], 0);
795 }
796
797 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
799 assert_eq!(
800 depth.ts_event,
801 UnixNanos::from(1_704_470_400_000 * 1_000_000)
802 );
803 }
804
805 #[rstest]
806 fn test_parse_ws_order_book_depth10_truncates_beyond_10() {
807 let instrument = create_test_instrument();
808 let ts_init = UnixNanos::default();
809
810 let mk_levels = |base: f64, n: usize| -> Vec<WsLevelData> {
811 (0..n)
812 .map(|i| WsLevelData {
813 px: format!("{:.2}", base - i as f64 * 0.01),
814 sz: "1.0".to_string(),
815 n: 1,
816 })
817 .collect()
818 };
819
820 let book = WsBookData {
821 coin: Ustr::from("BTC"),
822 levels: [mk_levels(100.00, 15), mk_levels(100.50, 12)],
823 time: 1_704_470_400_000,
824 };
825
826 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
827
828 for i in 0..10 {
830 assert!(
831 !depth.bids[i].size.is_zero(),
832 "bid slot {i} unexpectedly empty"
833 );
834 assert!(
835 !depth.asks[i].size.is_zero(),
836 "ask slot {i} unexpectedly empty"
837 );
838 }
839 }
840
841 #[rstest]
842 fn test_parse_ws_asset_context_perp() {
843 let instrument = create_test_instrument();
844 let ts_init = UnixNanos::default();
845
846 let ctx_data = WsActiveAssetCtxData::Perp {
847 coin: Ustr::from("BTC"),
848 ctx: PerpsAssetCtx {
849 shared: SharedAssetCtx {
850 day_ntl_vlm: "1000000.0".to_string(),
851 prev_day_px: "49000.0".to_string(),
852 mark_px: "50000.0".to_string(),
853 mid_px: Some("50001.0".to_string()),
854 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
855 day_base_vlm: Some("100.0".to_string()),
856 },
857 funding: "0.0001".to_string(),
858 open_interest: "100000.0".to_string(),
859 oracle_px: "50005.0".to_string(),
860 premium: Some("-0.0001".to_string()),
861 },
862 };
863
864 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
865 assert!(result.is_ok());
866
867 let (mark_price, index_price, funding_rate) = result.unwrap();
868
869 assert_eq!(mark_price.instrument_id, instrument.id());
870 assert_eq!(mark_price.value.as_f64(), 50_000.0);
871
872 assert!(index_price.is_some());
873 let index = index_price.unwrap();
874 assert_eq!(index.instrument_id, instrument.id());
875 assert_eq!(index.value.as_f64(), 50_005.0);
876
877 assert!(funding_rate.is_some());
878 let funding = funding_rate.unwrap();
879 assert_eq!(funding.instrument_id, instrument.id());
880 assert_eq!(funding.rate.to_string(), "0.0001");
881 assert_eq!(funding.interval, Some(60));
882 }
883
884 #[rstest]
885 fn test_parse_ws_asset_context_spot() {
886 let instrument = create_test_instrument();
887 let ts_init = UnixNanos::default();
888
889 let ctx_data = WsActiveAssetCtxData::Spot {
890 coin: Ustr::from("BTC"),
891 ctx: SpotAssetCtx {
892 shared: SharedAssetCtx {
893 day_ntl_vlm: "1000000.0".to_string(),
894 prev_day_px: "49000.0".to_string(),
895 mark_px: "50000.0".to_string(),
896 mid_px: Some("50001.0".to_string()),
897 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
898 day_base_vlm: Some("100.0".to_string()),
899 },
900 circulating_supply: "19000000.0".to_string(),
901 },
902 };
903
904 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
905 assert!(result.is_ok());
906
907 let (mark_price, index_price, funding_rate) = result.unwrap();
908
909 assert_eq!(mark_price.instrument_id, instrument.id());
910 assert_eq!(mark_price.value.as_f64(), 50_000.0);
911 assert!(index_price.is_none());
912 assert!(funding_rate.is_none());
913 }
914}