1use anyhow::Context;
19use nautilus_core::{nanos::UnixNanos, uuid::UUID4};
20use nautilus_model::{
21 data::{
22 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
23 OrderBookDelta, OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick,
24 depth::DEPTH10_LEN,
25 },
26 enums::{
27 AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus, OrderType, RecordFlag,
28 TimeInForce,
29 },
30 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
31 instruments::{Instrument, InstrumentAny},
32 reports::{FillReport, OrderStatusReport},
33 types::{Money, Price, Quantity},
34};
35use rust_decimal::Decimal;
36
37use super::messages::{
38 CandleData, WsActiveAssetCtxData, WsBboData, WsBookData, WsFillData, WsOrderData, WsTradeData,
39};
40use crate::{
41 common::{
42 enums::{HyperliquidFillDirection, HyperliquidTimeInForce},
43 parse::{
44 is_conditional_order_data, make_fill_trade_id, millis_to_nanos,
45 parse_trigger_order_type,
46 },
47 },
48 data_types::HyperliquidOpenInterest,
49};
50
51fn parse_price(
52 value: Decimal,
53 instrument: &InstrumentAny,
54 field_name: &str,
55) -> anyhow::Result<Price> {
56 Price::from_decimal_dp(value, instrument.price_precision())
57 .with_context(|| format!("Failed to create price from '{value}' for {field_name}"))
58}
59
60fn parse_quantity(
61 value: Decimal,
62 instrument: &InstrumentAny,
63 field_name: &str,
64) -> anyhow::Result<Quantity> {
65 Quantity::from_decimal_dp(value.abs(), instrument.size_precision())
66 .with_context(|| format!("Failed to create quantity from '{value}' for {field_name}"))
67}
68
69pub fn parse_ws_trade_tick(
71 trade: &WsTradeData,
72 instrument: &InstrumentAny,
73 ts_init: UnixNanos,
74) -> anyhow::Result<TradeTick> {
75 let price = parse_price(trade.px, instrument, "trade.px")?;
76 let size = parse_quantity(trade.sz, instrument, "trade.sz")?;
77 let aggressor = AggressorSide::from(trade.side);
78 let trade_id = TradeId::new_checked(trade.tid.to_string())
79 .context("invalid trade identifier in Hyperliquid trade message")?;
80 let ts_event = millis_to_nanos(trade.time)?;
81
82 TradeTick::new_checked(
83 instrument.id(),
84 price,
85 size,
86 aggressor,
87 trade_id,
88 ts_event,
89 ts_init,
90 )
91 .context("failed to construct TradeTick from Hyperliquid trade message")
92}
93
94pub fn parse_ws_order_book_deltas(
96 book: &WsBookData,
97 instrument: &InstrumentAny,
98 ts_init: UnixNanos,
99) -> anyhow::Result<OrderBookDeltas> {
100 let ts_event = millis_to_nanos(book.time)?;
101 let bids = &book.levels[0];
102 let asks = &book.levels[1];
103 let mut deltas = Vec::with_capacity(1 + bids.len() + asks.len());
104
105 deltas.push(OrderBookDelta::clear(instrument.id(), 0, ts_event, ts_init));
107
108 for level in bids {
109 let price = parse_price(level.px, instrument, "book.bid.px")?;
110 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
111
112 if !size.is_positive() {
113 continue;
114 }
115
116 let order = BookOrder::new(OrderSide::Buy, price, size, 0);
117
118 let delta = OrderBookDelta::new(
119 instrument.id(),
120 BookAction::Add,
121 order,
122 RecordFlag::F_LAST as u8,
123 0, ts_event,
125 ts_init,
126 );
127
128 deltas.push(delta);
129 }
130
131 for level in asks {
132 let price = parse_price(level.px, instrument, "book.ask.px")?;
133 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
134
135 if !size.is_positive() {
136 continue;
137 }
138
139 let order = BookOrder::new(OrderSide::Sell, price, size, 0);
140
141 let delta = OrderBookDelta::new(
142 instrument.id(),
143 BookAction::Add,
144 order,
145 RecordFlag::F_LAST as u8,
146 0, ts_event,
148 ts_init,
149 );
150
151 deltas.push(delta);
152 }
153
154 Ok(OrderBookDeltas::new(instrument.id(), deltas))
155}
156
157pub fn parse_ws_order_book_depth10(
164 book: &WsBookData,
165 instrument: &InstrumentAny,
166 ts_init: UnixNanos,
167) -> anyhow::Result<OrderBookDepth10> {
168 let ts_event = millis_to_nanos(book.time)?;
169 let price_precision = instrument.price_precision();
170 let size_precision = instrument.size_precision();
171
172 let mut bids: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
173 let mut asks: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
174 let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
175 let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
176
177 let raw_bids = book.levels.first().map_or(&[][..], |v| v.as_slice());
178 let raw_asks = book.levels.get(1).map_or(&[][..], |v| v.as_slice());
179
180 for (i, level) in raw_bids.iter().take(DEPTH10_LEN).enumerate() {
181 let price = parse_price(level.px, instrument, "book.bid.px")?;
182 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
183 bids[i] = BookOrder::new(OrderSide::Buy, price, size, 0);
184 bid_counts[i] = level.n;
185 }
186
187 for bid in bids.iter_mut().skip(raw_bids.len().min(DEPTH10_LEN)) {
188 *bid = BookOrder::new(
189 OrderSide::Buy,
190 Price::zero(price_precision),
191 Quantity::zero(size_precision),
192 0,
193 );
194 }
195
196 for (i, level) in raw_asks.iter().take(DEPTH10_LEN).enumerate() {
197 let price = parse_price(level.px, instrument, "book.ask.px")?;
198 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
199 asks[i] = BookOrder::new(OrderSide::Sell, price, size, 0);
200 ask_counts[i] = level.n;
201 }
202
203 for ask in asks.iter_mut().skip(raw_asks.len().min(DEPTH10_LEN)) {
204 *ask = BookOrder::new(
205 OrderSide::Sell,
206 Price::zero(price_precision),
207 Quantity::zero(size_precision),
208 0,
209 );
210 }
211
212 Ok(OrderBookDepth10::new(
213 instrument.id(),
214 bids,
215 asks,
216 bid_counts,
217 ask_counts,
218 RecordFlag::F_SNAPSHOT as u8,
219 0,
220 ts_event,
221 ts_init,
222 ))
223}
224
225pub fn parse_ws_quote_tick(
227 bbo: &WsBboData,
228 instrument: &InstrumentAny,
229 ts_init: UnixNanos,
230) -> anyhow::Result<QuoteTick> {
231 let bid_level = bbo.bbo[0]
232 .as_ref()
233 .context("BBO message missing bid level")?;
234 let ask_level = bbo.bbo[1]
235 .as_ref()
236 .context("BBO message missing ask level")?;
237
238 let bid_price = parse_price(bid_level.px, instrument, "bbo.bid.px")?;
239 let ask_price = parse_price(ask_level.px, instrument, "bbo.ask.px")?;
240 let bid_size = parse_quantity(bid_level.sz, instrument, "bbo.bid.sz")?;
241 let ask_size = parse_quantity(ask_level.sz, instrument, "bbo.ask.sz")?;
242
243 let ts_event = millis_to_nanos(bbo.time)?;
244
245 QuoteTick::new_checked(
246 instrument.id(),
247 bid_price,
248 ask_price,
249 bid_size,
250 ask_size,
251 ts_event,
252 ts_init,
253 )
254 .context("failed to construct QuoteTick from Hyperliquid BBO message")
255}
256
257pub fn parse_ws_candle(
259 candle: &CandleData,
260 instrument: &InstrumentAny,
261 bar_type: &BarType,
262 ts_init: UnixNanos,
263) -> anyhow::Result<Bar> {
264 let open = parse_price(candle.o, instrument, "candle.o")?;
265 let high = parse_price(candle.h, instrument, "candle.h")?;
266 let low = parse_price(candle.l, instrument, "candle.l")?;
267 let close = parse_price(candle.c, instrument, "candle.c")?;
268 let volume = parse_quantity(candle.v, instrument, "candle.v")?;
269
270 let ts_event = millis_to_nanos(candle.t)?;
271
272 Ok(Bar::new(
273 *bar_type, open, high, low, close, volume, ts_event, ts_init,
274 ))
275}
276
277pub fn parse_ws_order_status_report(
282 order: &WsOrderData,
283 instrument: &InstrumentAny,
284 account_id: AccountId,
285 ts_init: UnixNanos,
286) -> anyhow::Result<OrderStatusReport> {
287 let instrument_id = instrument.id();
288 let venue_order_id = VenueOrderId::new(order.order.oid.to_string());
289 let order_side = OrderSide::from(order.order.side);
290
291 let is_conditional =
293 is_conditional_order_data(order.order.trigger_px, order.order.tpsl.as_ref());
294 let order_type = if is_conditional {
295 if let (Some(is_market), Some(tpsl)) = (order.order.is_market, order.order.tpsl.as_ref()) {
296 parse_trigger_order_type(is_market, tpsl)
297 } else {
298 OrderType::Limit }
300 } else {
301 OrderType::Limit };
303
304 let time_in_force = match order.order.tif {
305 Some(HyperliquidTimeInForce::Ioc) => TimeInForce::Ioc,
306 _ => TimeInForce::Gtc,
307 };
308 let order_status = OrderStatus::from(order.status);
309
310 let orig_qty = parse_quantity(order.order.orig_sz, instrument, "order.orig_sz")?;
312 let remaining_qty = parse_quantity(order.order.sz, instrument, "order.sz")?;
313 let filled_qty = Quantity::from_raw(
314 orig_qty.raw.saturating_sub(remaining_qty.raw),
315 instrument.size_precision(),
316 );
317
318 let price = parse_price(order.order.limit_px, instrument, "order.limitPx")?;
319
320 let ts_accepted = millis_to_nanos(order.order.timestamp)?;
321 let ts_last = millis_to_nanos(order.status_timestamp)?;
322
323 let mut report = OrderStatusReport::new(
324 account_id,
325 instrument_id,
326 None, venue_order_id,
328 order_side,
329 order_type,
330 time_in_force,
331 order_status,
332 orig_qty, filled_qty,
334 ts_accepted,
335 ts_last,
336 ts_init,
337 Some(UUID4::new()),
338 );
339
340 if let Some(ref cloid) = order.order.cloid {
341 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
342 }
343
344 if matches!(order.order.tif, Some(HyperliquidTimeInForce::Alo)) {
345 report = report.with_post_only(true);
346 }
347
348 if let Some(reduce_only) = order.order.reduce_only {
349 report = report.with_reduce_only(reduce_only);
350 }
351
352 report = report.with_price(price);
353
354 if is_conditional && let Some(trigger_px) = order.order.trigger_px {
355 let trigger_price = parse_price(trigger_px, instrument, "order.triggerPx")?;
356 report = report.with_trigger_price(trigger_price);
357 }
358
359 Ok(report)
360}
361
362pub fn parse_ws_fill_report(
366 fill: &WsFillData,
367 instrument: &InstrumentAny,
368 account_id: AccountId,
369 ts_init: UnixNanos,
370) -> anyhow::Result<FillReport> {
371 let instrument_id = instrument.id();
372
373 if let Some(liquidation) = fill.liquidation.as_ref() {
374 log::warn!(
375 "Liquidation fill: {} oid={} method={:?} mark_px={} liquidated_user={}",
376 instrument_id,
377 fill.oid,
378 liquidation.method,
379 liquidation.mark_px,
380 liquidation
381 .liquidated_user
382 .as_deref()
383 .unwrap_or("<unknown>"),
384 );
385 } else if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
386 log::warn!(
387 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
388 fill.oid,
389 fill.px,
390 fill.sz,
391 );
392 }
393
394 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
395 let trade_id = make_fill_trade_id(
396 &fill.hash,
397 fill.oid,
398 fill.px,
399 fill.sz,
400 fill.time,
401 fill.start_position,
402 );
403
404 let order_side = OrderSide::from(fill.side);
405 let last_qty = parse_quantity(fill.sz, instrument, "fill.sz")?;
406 let last_px = parse_price(fill.px, instrument, "fill.px")?;
407 let liquidity_side = if fill.crossed {
408 LiquiditySide::Taker
409 } else {
410 LiquiditySide::Maker
411 };
412
413 let fee_amount = fill.fee;
414
415 let commission_currency =
416 crate::http::parse::resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
417
418 let commission = Money::from_decimal(fee_amount, commission_currency)
419 .with_context(|| format!("Failed to create commission from fee='{}'", fill.fee))?;
420 let ts_event = millis_to_nanos(fill.time)?;
421
422 let client_order_id = None;
424
425 Ok(FillReport::new(
426 account_id,
427 instrument_id,
428 venue_order_id,
429 trade_id,
430 order_side,
431 last_qty,
432 last_px,
433 commission,
434 liquidity_side,
435 client_order_id,
436 None, ts_event,
438 ts_init,
439 None, ))
441}
442
443pub fn parse_ws_asset_context(
449 ctx: &WsActiveAssetCtxData,
450 instrument: &InstrumentAny,
451 ts_init: UnixNanos,
452) -> anyhow::Result<(
453 MarkPriceUpdate,
454 Option<IndexPriceUpdate>,
455 Option<FundingRateUpdate>,
456)> {
457 let instrument_id = instrument.id();
458
459 match ctx {
460 WsActiveAssetCtxData::Perp { coin: _, ctx } => {
461 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
462 let mark_price_update =
463 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
464
465 let index_price = parse_price(ctx.oracle_px, instrument, "ctx.oracle_px")?;
466 let index_price_update =
467 IndexPriceUpdate::new(instrument_id, index_price, ts_init, ts_init);
468
469 let funding_rate_update = FundingRateUpdate::new(
470 instrument_id,
471 ctx.funding,
472 Some(60), None, ts_init,
475 ts_init,
476 );
477
478 Ok((
479 mark_price_update,
480 Some(index_price_update),
481 Some(funding_rate_update),
482 ))
483 }
484 WsActiveAssetCtxData::Spot { coin: _, ctx } => {
485 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
486 let mark_price_update =
487 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
488
489 Ok((mark_price_update, None, None))
490 }
491 }
492}
493
494pub fn parse_ws_open_interest(
499 open_interest: Decimal,
500 instrument: &InstrumentAny,
501 ts_init: UnixNanos,
502) -> anyhow::Result<HyperliquidOpenInterest> {
503 Ok(HyperliquidOpenInterest::new(
504 instrument.id(),
505 open_interest,
506 ts_init,
507 ts_init,
508 ))
509}
510
511#[cfg(test)]
512mod tests {
513 use std::str::FromStr;
514
515 use nautilus_model::{
516 identifiers::{InstrumentId, Symbol},
517 instruments::CryptoPerpetual,
518 types::currency::Currency,
519 };
520 use rstest::rstest;
521 use rust_decimal_macros::dec;
522 use ustr::Ustr;
523
524 use super::*;
525 use crate::{
526 common::{
527 consts::HYPERLIQUID_VENUE,
528 enums::{
529 HyperliquidFillDirection, HyperliquidLiquidationMethod,
530 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
531 HyperliquidTimeInForce,
532 },
533 },
534 websocket::messages::{
535 FillLiquidationData, PerpsAssetCtx, SharedAssetCtx, SpotAssetCtx, WsBasicOrderData,
536 WsBookData, WsLevelData,
537 },
538 };
539
540 fn create_test_instrument() -> InstrumentAny {
541 let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE);
542
543 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
544 instrument_id,
545 Symbol::new("BTC-PERP"),
546 Currency::from("BTC"),
547 Currency::from("USDC"),
548 Currency::from("USDC"),
549 false, 2, 3, Price::from("0.01"),
553 Quantity::from("0.001"),
554 None, None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::default(),
569 UnixNanos::default(),
570 ))
571 }
572
573 #[rstest]
574 fn test_parse_ws_order_status_report_basic() {
575 let instrument = create_test_instrument();
576 let account_id = AccountId::new("HYPERLIQUID-001");
577 let ts_init = UnixNanos::default();
578
579 let order_data = WsOrderData {
580 order: WsBasicOrderData {
581 coin: Ustr::from("BTC"),
582 side: HyperliquidSide::Buy,
583 limit_px: dec!(50000.0),
584 sz: dec!(0.5),
585 oid: 12345,
586 timestamp: 1704470400000,
587 orig_sz: dec!(1.0),
588 cloid: Some("test-order-1".to_string()),
589 tif: Some(HyperliquidTimeInForce::Alo),
590 reduce_only: Some(true),
591 trigger_px: Some(dec!(0.0)),
592 is_market: None,
593 tpsl: None,
594 trigger_activated: None,
595 trailing_stop: None,
596 },
597 status: HyperliquidOrderStatusEnum::Open,
598 status_timestamp: 1704470400000,
599 };
600
601 let result = parse_ws_order_status_report(&order_data, &instrument, account_id, ts_init);
602 assert!(result.is_ok());
603
604 let report = result.unwrap();
605 assert_eq!(report.order_side, OrderSide::Buy);
606 assert_eq!(report.order_type, OrderType::Limit);
607 assert_eq!(report.order_status, OrderStatus::Accepted);
608 assert_eq!(report.time_in_force, TimeInForce::Gtc);
609 assert!(report.post_only);
610 assert!(report.reduce_only);
611 assert!(report.trigger_price.is_none());
612 }
613
614 #[rstest]
615 fn test_parse_ws_fill_report_basic() {
616 let instrument = create_test_instrument();
617 let account_id = AccountId::new("HYPERLIQUID-001");
618 let ts_init = UnixNanos::default();
619
620 let fill_data = WsFillData {
621 coin: Ustr::from("BTC"),
622 px: dec!(50000.0),
623 sz: dec!(0.1),
624 side: HyperliquidSide::Buy,
625 time: 1704470400000,
626 start_position: dec!(0.0),
627 dir: HyperliquidFillDirection::OpenLong,
628 closed_pnl: dec!(0.0),
629 hash: "0xabc123".to_string(),
630 oid: 12345,
631 crossed: true,
632 fee: dec!(0.05),
633 tid: 98765,
634 liquidation: None,
635 fee_token: Ustr::from("USDC"),
636 builder_fee: None,
637 cloid: Some("0xd211f1c27288259290850338d22132a0".to_string()),
638 twap_id: None,
639 };
640
641 let result = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init);
642 assert!(result.is_ok());
643
644 let report = result.unwrap();
645 assert_eq!(report.order_side, OrderSide::Buy);
646 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
647 }
648
649 #[rstest]
650 fn test_parse_ws_fill_report_with_liquidation() {
651 let instrument = create_test_instrument();
652 let account_id = AccountId::new("HYPERLIQUID-001");
653 let ts_init = UnixNanos::default();
654
655 let fill_data = WsFillData {
656 coin: Ustr::from("BTC"),
657 px: dec!(50000.0),
658 sz: dec!(0.1),
659 side: HyperliquidSide::Sell,
660 time: 1704470400000,
661 start_position: dec!(0.1),
662 dir: HyperliquidFillDirection::CloseLong,
663 closed_pnl: dec!(-25.0),
664 hash: "0xdef456".to_string(),
665 oid: 54321,
666 crossed: true,
667 fee: dec!(0.0),
668 tid: 12345,
669 liquidation: Some(FillLiquidationData {
670 liquidated_user: Some("0xuser".to_string()),
671 mark_px: dec!(50000.0),
672 method: HyperliquidLiquidationMethod::Market,
673 }),
674 fee_token: Ustr::from("USDC"),
675 builder_fee: None,
676 cloid: None,
677 twap_id: None,
678 };
679
680 let report = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init).unwrap();
681
682 assert_eq!(report.order_side, OrderSide::Sell);
685 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
686 assert_eq!(report.venue_order_id.to_string(), "54321");
687 }
688
689 #[rstest]
690 fn test_parse_ws_fill_report_outcome_round_trip() {
691 use crate::http::{
692 models::{OutcomeMarket, OutcomeMeta},
693 parse::{create_instrument_from_def, parse_outcome_instruments},
694 };
695
696 let meta = OutcomeMeta {
697 outcomes: vec![OutcomeMarket {
698 outcome: 99,
699 name: "BTC daily".to_string(),
700 description: String::new(),
701 side_specs: vec![],
702 }],
703 questions: vec![],
704 };
705
706 let defs = parse_outcome_instruments(&meta).unwrap();
707 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
708 assert_eq!(instrument.id().symbol.as_str(), "99-YES-OUTCOME");
709
710 let fill_data = WsFillData {
711 coin: Ustr::from("#990"),
712 px: dec!(0.4500),
713 sz: dec!(1500.00),
714 side: HyperliquidSide::Buy,
715 time: 1_704_470_400_000,
716 start_position: dec!(0.00),
717 dir: HyperliquidFillDirection::OpenLong,
718 closed_pnl: dec!(0.0),
719 hash: "0xabc789".to_string(),
720 oid: 42_42,
721 crossed: true,
722 fee: dec!(0.0),
723 tid: 7777,
724 liquidation: None,
725 fee_token: Ustr::from("+990"),
726 builder_fee: None,
727 cloid: None,
728 twap_id: None,
729 };
730
731 let report = parse_ws_fill_report(
732 &fill_data,
733 &instrument,
734 AccountId::new("HYPERLIQUID-001"),
735 UnixNanos::default(),
736 )
737 .unwrap();
738
739 assert_eq!(report.commission.currency.code.as_str(), "USDH");
743 assert!(report.commission.as_decimal().is_zero());
744 assert_eq!(report.order_side, OrderSide::Buy);
745 }
746
747 #[rstest]
748 fn test_parse_ws_order_book_deltas_snapshot_behavior() {
749 let instrument = create_test_instrument();
750 let ts_init = UnixNanos::default();
751
752 let book = WsBookData {
753 coin: Ustr::from("BTC"),
754 levels: [
755 vec![WsLevelData {
756 px: dec!(50000.0),
757 sz: dec!(1.0),
758 n: 1,
759 }],
760 vec![WsLevelData {
761 px: dec!(50001.0),
762 sz: dec!(2.0),
763 n: 1,
764 }],
765 ],
766 time: 1_704_470_400_000,
767 };
768
769 let deltas = parse_ws_order_book_deltas(&book, &instrument, ts_init).unwrap();
770
771 assert_eq!(deltas.deltas.len(), 3); assert_eq!(deltas.deltas[0].action, BookAction::Clear);
773
774 let bid_delta = &deltas.deltas[1];
775 assert_eq!(bid_delta.action, BookAction::Add);
776 assert_eq!(bid_delta.order.side, OrderSide::Buy);
777 assert!(bid_delta.order.size.is_positive());
778 assert_eq!(bid_delta.order.order_id, 0);
779
780 let ask_delta = &deltas.deltas[2];
781 assert_eq!(ask_delta.action, BookAction::Add);
782 assert_eq!(ask_delta.order.side, OrderSide::Sell);
783 assert!(ask_delta.order.size.is_positive());
784 assert_eq!(ask_delta.order.order_id, 0);
785 }
786
787 #[rstest]
788 fn test_parse_ws_order_book_depth10_pads_sparse_book() {
789 let instrument = create_test_instrument();
790 let ts_init = UnixNanos::default();
791
792 let book = WsBookData {
794 coin: Ustr::from("BTC"),
795 levels: [
796 vec![
797 WsLevelData {
798 px: dec!(100.00),
799 sz: dec!(1.0),
800 n: 2,
801 },
802 WsLevelData {
803 px: dec!(99.99),
804 sz: dec!(2.0),
805 n: 3,
806 },
807 WsLevelData {
808 px: dec!(99.98),
809 sz: dec!(3.0),
810 n: 1,
811 },
812 ],
813 vec![
814 WsLevelData {
815 px: dec!(100.01),
816 sz: dec!(1.5),
817 n: 1,
818 },
819 WsLevelData {
820 px: dec!(100.02),
821 sz: dec!(2.5),
822 n: 4,
823 },
824 ],
825 ],
826 time: 1_704_470_400_000,
827 };
828
829 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
830
831 assert_eq!(depth.instrument_id, instrument.id());
832 assert_eq!(depth.bids.len(), 10);
833 assert_eq!(depth.asks.len(), 10);
834
835 assert_eq!(depth.bids[0].price.as_f64(), 100.00);
836 assert_eq!(depth.bids[0].side, OrderSide::Buy);
837 assert_eq!(depth.bid_counts[0], 2);
838 assert_eq!(depth.bids[2].price.as_f64(), 99.98);
839 assert_eq!(depth.bid_counts[2], 1);
840
841 for i in 3..10 {
843 assert_eq!(depth.bids[i].side, OrderSide::Buy);
844 assert!(depth.bids[i].size.is_zero());
845 assert_eq!(depth.bid_counts[i], 0);
846 }
847
848 assert_eq!(depth.asks[0].price.as_f64(), 100.01);
849 assert_eq!(depth.asks[0].side, OrderSide::Sell);
850 assert_eq!(depth.ask_counts[0], 1);
851 assert_eq!(depth.asks[1].price.as_f64(), 100.02);
852 assert_eq!(depth.ask_counts[1], 4);
853
854 for i in 2..10 {
855 assert_eq!(depth.asks[i].side, OrderSide::Sell);
856 assert!(depth.asks[i].size.is_zero());
857 assert_eq!(depth.ask_counts[i], 0);
858 }
859
860 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
862 assert_eq!(
863 depth.ts_event,
864 UnixNanos::from(1_704_470_400_000 * 1_000_000)
865 );
866 }
867
868 #[rstest]
869 fn test_parse_ws_order_book_depth10_truncates_beyond_10() {
870 let instrument = create_test_instrument();
871 let ts_init = UnixNanos::default();
872
873 let mk_levels = |base: f64, n: usize| -> Vec<WsLevelData> {
874 (0..n)
875 .map(|i| WsLevelData {
876 px: Decimal::from_str(&format!("{:.2}", base - i as f64 * 0.01)).unwrap(),
877 sz: dec!(1.0),
878 n: 1,
879 })
880 .collect()
881 };
882
883 let book = WsBookData {
884 coin: Ustr::from("BTC"),
885 levels: [mk_levels(100.00, 15), mk_levels(100.50, 12)],
886 time: 1_704_470_400_000,
887 };
888
889 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
890
891 for i in 0..10 {
893 assert!(
894 !depth.bids[i].size.is_zero(),
895 "bid slot {i} unexpectedly empty"
896 );
897 assert!(
898 !depth.asks[i].size.is_zero(),
899 "ask slot {i} unexpectedly empty"
900 );
901 }
902 }
903
904 #[rstest]
905 fn test_parse_ws_asset_context_perp() {
906 let instrument = create_test_instrument();
907 let ts_init = UnixNanos::default();
908
909 let ctx_data = WsActiveAssetCtxData::Perp {
910 coin: Ustr::from("BTC"),
911 ctx: PerpsAssetCtx {
912 shared: SharedAssetCtx {
913 day_ntl_vlm: dec!(1000000.0),
914 prev_day_px: dec!(49000.0),
915 mark_px: dec!(50000.0),
916 mid_px: Some(dec!(50001.0)),
917 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
918 day_base_vlm: Some(dec!(100.0)),
919 },
920 funding: dec!(0.0001),
921 open_interest: dec!(100000.0),
922 oracle_px: dec!(50005.0),
923 premium: Some(dec!(-0.0001)),
924 },
925 };
926
927 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
928 assert!(result.is_ok());
929
930 let (mark_price, index_price, funding_rate) = result.unwrap();
931
932 assert_eq!(mark_price.instrument_id, instrument.id());
933 assert_eq!(mark_price.value.as_f64(), 50_000.0);
934
935 assert!(index_price.is_some());
936 let index = index_price.unwrap();
937 assert_eq!(index.instrument_id, instrument.id());
938 assert_eq!(index.value.as_f64(), 50_005.0);
939
940 assert!(funding_rate.is_some());
941 let funding = funding_rate.unwrap();
942 assert_eq!(funding.instrument_id, instrument.id());
943 assert_eq!(funding.rate.to_string(), "0.0001");
944 assert_eq!(funding.interval, Some(60));
945 }
946
947 #[rstest]
948 fn test_parse_ws_asset_context_spot() {
949 let instrument = create_test_instrument();
950 let ts_init = UnixNanos::default();
951
952 let ctx_data = WsActiveAssetCtxData::Spot {
953 coin: Ustr::from("BTC"),
954 ctx: SpotAssetCtx {
955 shared: SharedAssetCtx {
956 day_ntl_vlm: dec!(1000000.0),
957 prev_day_px: dec!(49000.0),
958 mark_px: dec!(50000.0),
959 mid_px: Some(dec!(50001.0)),
960 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
961 day_base_vlm: Some(dec!(100.0)),
962 },
963 circulating_supply: dec!(19000000.0),
964 },
965 };
966
967 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
968 assert!(result.is_ok());
969
970 let (mark_price, index_price, funding_rate) = result.unwrap();
971
972 assert_eq!(mark_price.instrument_id, instrument.id());
973 assert_eq!(mark_price.value.as_f64(), 50_000.0);
974 assert!(index_price.is_none());
975 assert!(funding_rate.is_none());
976 }
977
978 #[rstest]
982 #[case::positive_high_precision("0.0001234567890123456")]
983 #[case::negative_high_precision("-0.0001234567890123456")]
984 fn test_parse_ws_asset_context_perp_preserves_funding_precision(#[case] funding_str: &str) {
985 let instrument = create_test_instrument();
986 let ts_init = UnixNanos::default();
987
988 let expected = Decimal::from_str(funding_str).unwrap();
989
990 let ctx_data = WsActiveAssetCtxData::Perp {
991 coin: Ustr::from("BTC"),
992 ctx: PerpsAssetCtx {
993 shared: SharedAssetCtx {
994 day_ntl_vlm: dec!(1000000.0),
995 prev_day_px: dec!(49000.0),
996 mark_px: dec!(50000.0),
997 mid_px: None,
998 impact_pxs: None,
999 day_base_vlm: None,
1000 },
1001 funding: Decimal::from_str(funding_str).unwrap(),
1002 open_interest: dec!(100000.0),
1003 oracle_px: dec!(50005.0),
1004 premium: None,
1005 },
1006 };
1007
1008 let (_, _, funding_rate) = parse_ws_asset_context(&ctx_data, &instrument, ts_init).unwrap();
1009
1010 let funding = funding_rate.expect("perp ctx must yield funding rate");
1011 assert_eq!(funding.rate, expected);
1012 }
1013
1014 #[rstest]
1015 fn test_parse_ws_open_interest_perp() {
1016 let instrument = create_test_instrument();
1017 let ts_init = UnixNanos::default();
1018
1019 let open_interest = parse_ws_open_interest(dec!(100000.0), &instrument, ts_init).unwrap();
1020
1021 assert_eq!(open_interest.instrument_id, instrument.id());
1022 assert_eq!(open_interest.open_interest.to_string(), "100000.0");
1023 assert_eq!(open_interest.ts_event, ts_init);
1024 assert_eq!(open_interest.ts_init, ts_init);
1025 }
1026
1027 #[rstest]
1028 #[case::round("100000.0")]
1029 #[case::precise("100000.123456789")]
1030 fn test_parse_ws_open_interest_preserves_precision(#[case] open_interest_str: &str) {
1031 let instrument = create_test_instrument();
1032 let ts_init = UnixNanos::default();
1033
1034 let expected = Decimal::from_str(open_interest_str).unwrap();
1035
1036 let open_interest = parse_ws_open_interest(
1037 Decimal::from_str(open_interest_str).unwrap(),
1038 &instrument,
1039 ts_init,
1040 )
1041 .unwrap();
1042
1043 assert_eq!(open_interest.open_interest, expected);
1044 }
1045}