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, TwapStateData, WsActiveAssetCtxData, WsBboData, WsBookData, WsFillData,
39 WsOrderData, WsTradeData, WsTwapHistoryData, WsTwapSliceFillData,
40};
41use crate::{
42 common::{
43 converters::hyperliquid_time_in_force_to_nautilus,
44 enums::{HyperliquidFillDirection, HyperliquidTimeInForce},
45 parse::{
46 is_conditional_order_data, make_fill_trade_id, millis_to_nanos,
47 parse_trigger_order_type,
48 },
49 },
50 data_types::{
51 HyperliquidOpenInterest, HyperliquidPublicTrade, HyperliquidTwapHistory,
52 HyperliquidTwapSliceFill,
53 },
54};
55
56fn parse_price(
57 value: Decimal,
58 instrument: &InstrumentAny,
59 field_name: &str,
60) -> anyhow::Result<Price> {
61 Price::from_decimal_dp(value, instrument.price_precision())
62 .with_context(|| format!("Failed to create price from '{value}' for {field_name}"))
63}
64
65fn parse_quantity(
66 value: Decimal,
67 instrument: &InstrumentAny,
68 field_name: &str,
69) -> anyhow::Result<Quantity> {
70 Quantity::from_decimal_dp(value.abs(), instrument.size_precision())
71 .with_context(|| format!("Failed to create quantity from '{value}' for {field_name}"))
72}
73
74pub fn parse_ws_trade_tick(
76 trade: &WsTradeData,
77 instrument: &InstrumentAny,
78 ts_init: UnixNanos,
79) -> anyhow::Result<TradeTick> {
80 let price = parse_price(trade.px, instrument, "trade.px")?;
81 let size = parse_quantity(trade.sz, instrument, "trade.sz")?;
82 let aggressor = AggressorSide::from(trade.side);
83 let trade_id = TradeId::new_checked(trade.tid.to_string())
84 .context("invalid trade identifier in Hyperliquid trade message")?;
85 let ts_event = millis_to_nanos(trade.time)?;
86
87 TradeTick::new_checked(
88 instrument.id(),
89 price,
90 size,
91 aggressor,
92 trade_id,
93 ts_event,
94 ts_init,
95 )
96 .context("failed to construct TradeTick from Hyperliquid trade message")
97}
98
99pub fn parse_ws_public_trade(
101 trade: &WsTradeData,
102 instrument: &InstrumentAny,
103 ts_init: UnixNanos,
104) -> anyhow::Result<HyperliquidPublicTrade> {
105 let price = parse_price(trade.px, instrument, "trade.px")?;
106 let size = parse_quantity(trade.sz, instrument, "trade.sz")?;
107 let ts_event = millis_to_nanos(trade.time)?;
108
109 Ok(HyperliquidPublicTrade::new(
110 instrument.id(),
111 price,
112 size,
113 AggressorSide::from(trade.side),
114 trade.tid.to_string(),
115 trade.users[0].clone(),
116 trade.users[1].clone(),
117 trade.hash.clone(),
118 ts_event,
119 ts_init,
120 ))
121}
122
123pub fn parse_ws_order_book_deltas(
125 book: &WsBookData,
126 instrument: &InstrumentAny,
127 ts_init: UnixNanos,
128) -> anyhow::Result<OrderBookDeltas> {
129 let ts_event = millis_to_nanos(book.time)?;
130 let bids = &book.levels[0];
131 let asks = &book.levels[1];
132 let mut deltas = Vec::with_capacity(1 + bids.len() + asks.len());
133
134 deltas.push(OrderBookDelta::clear(instrument.id(), 0, ts_event, ts_init));
136
137 for level in bids {
138 let price = parse_price(level.px, instrument, "book.bid.px")?;
139 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
140
141 if !size.is_positive() {
142 continue;
143 }
144
145 let order = BookOrder::new(OrderSide::Buy, price, size, 0);
146
147 let delta = OrderBookDelta::new(
148 instrument.id(),
149 BookAction::Add,
150 order,
151 RecordFlag::F_LAST as u8,
152 0, ts_event,
154 ts_init,
155 );
156
157 deltas.push(delta);
158 }
159
160 for level in asks {
161 let price = parse_price(level.px, instrument, "book.ask.px")?;
162 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
163
164 if !size.is_positive() {
165 continue;
166 }
167
168 let order = BookOrder::new(OrderSide::Sell, price, size, 0);
169
170 let delta = OrderBookDelta::new(
171 instrument.id(),
172 BookAction::Add,
173 order,
174 RecordFlag::F_LAST as u8,
175 0, ts_event,
177 ts_init,
178 );
179
180 deltas.push(delta);
181 }
182
183 Ok(OrderBookDeltas::new(instrument.id(), deltas))
184}
185
186pub fn parse_ws_order_book_depth10(
193 book: &WsBookData,
194 instrument: &InstrumentAny,
195 ts_init: UnixNanos,
196) -> anyhow::Result<OrderBookDepth10> {
197 let ts_event = millis_to_nanos(book.time)?;
198 let price_precision = instrument.price_precision();
199 let size_precision = instrument.size_precision();
200
201 let mut bids: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
202 let mut asks: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
203 let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
204 let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
205
206 let raw_bids = book.levels.first().map_or(&[][..], |v| v.as_slice());
207 let raw_asks = book.levels.get(1).map_or(&[][..], |v| v.as_slice());
208
209 for (i, level) in raw_bids.iter().take(DEPTH10_LEN).enumerate() {
210 let price = parse_price(level.px, instrument, "book.bid.px")?;
211 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
212 bids[i] = BookOrder::new(OrderSide::Buy, price, size, 0);
213 bid_counts[i] = level.n;
214 }
215
216 for bid in bids.iter_mut().skip(raw_bids.len().min(DEPTH10_LEN)) {
217 *bid = BookOrder::new(
218 OrderSide::Buy,
219 Price::zero(price_precision),
220 Quantity::zero(size_precision),
221 0,
222 );
223 }
224
225 for (i, level) in raw_asks.iter().take(DEPTH10_LEN).enumerate() {
226 let price = parse_price(level.px, instrument, "book.ask.px")?;
227 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
228 asks[i] = BookOrder::new(OrderSide::Sell, price, size, 0);
229 ask_counts[i] = level.n;
230 }
231
232 for ask in asks.iter_mut().skip(raw_asks.len().min(DEPTH10_LEN)) {
233 *ask = BookOrder::new(
234 OrderSide::Sell,
235 Price::zero(price_precision),
236 Quantity::zero(size_precision),
237 0,
238 );
239 }
240
241 Ok(OrderBookDepth10::new(
242 instrument.id(),
243 bids,
244 asks,
245 bid_counts,
246 ask_counts,
247 RecordFlag::F_SNAPSHOT as u8,
248 0,
249 ts_event,
250 ts_init,
251 ))
252}
253
254pub fn parse_ws_quote_tick(
256 bbo: &WsBboData,
257 instrument: &InstrumentAny,
258 ts_init: UnixNanos,
259) -> anyhow::Result<QuoteTick> {
260 let bid_level = bbo.bbo[0]
261 .as_ref()
262 .context("BBO message missing bid level")?;
263 let ask_level = bbo.bbo[1]
264 .as_ref()
265 .context("BBO message missing ask level")?;
266
267 let bid_price = parse_price(bid_level.px, instrument, "bbo.bid.px")?;
268 let ask_price = parse_price(ask_level.px, instrument, "bbo.ask.px")?;
269 let bid_size = parse_quantity(bid_level.sz, instrument, "bbo.bid.sz")?;
270 let ask_size = parse_quantity(ask_level.sz, instrument, "bbo.ask.sz")?;
271
272 let ts_event = millis_to_nanos(bbo.time)?;
273
274 QuoteTick::new_checked(
275 instrument.id(),
276 bid_price,
277 ask_price,
278 bid_size,
279 ask_size,
280 ts_event,
281 ts_init,
282 )
283 .context("failed to construct QuoteTick from Hyperliquid BBO message")
284}
285
286pub fn parse_ws_candle(
288 candle: &CandleData,
289 instrument: &InstrumentAny,
290 bar_type: &BarType,
291 ts_init: UnixNanos,
292) -> anyhow::Result<Bar> {
293 let open = parse_price(candle.o, instrument, "candle.o")?;
294 let high = parse_price(candle.h, instrument, "candle.h")?;
295 let low = parse_price(candle.l, instrument, "candle.l")?;
296 let close = parse_price(candle.c, instrument, "candle.c")?;
297 let volume = parse_quantity(candle.v, instrument, "candle.v")?;
298
299 let ts_event = millis_to_nanos(candle.t)?;
300
301 Ok(Bar::new(
302 *bar_type, open, high, low, close, volume, ts_event, ts_init,
303 ))
304}
305
306pub fn parse_ws_order_status_report(
311 order: &WsOrderData,
312 instrument: &InstrumentAny,
313 account_id: AccountId,
314 ts_init: UnixNanos,
315) -> anyhow::Result<OrderStatusReport> {
316 let instrument_id = instrument.id();
317 let venue_order_id = VenueOrderId::new(order.order.oid.to_string());
318 let order_side = OrderSide::from(order.order.side);
319
320 let is_conditional =
322 is_conditional_order_data(order.order.trigger_px, order.order.tpsl.as_ref());
323 let order_type = if is_conditional {
324 if let (Some(is_market), Some(tpsl)) = (order.order.is_market, order.order.tpsl.as_ref()) {
325 parse_trigger_order_type(is_market, tpsl)
326 } else {
327 OrderType::Limit }
329 } else {
330 OrderType::Limit };
332
333 let time_in_force = order
334 .order
335 .tif
336 .map_or(TimeInForce::Gtc, hyperliquid_time_in_force_to_nautilus);
337 let order_status = OrderStatus::from(order.status);
338
339 let orig_qty = parse_quantity(order.order.orig_sz, instrument, "order.orig_sz")?;
341 let remaining_qty = parse_quantity(order.order.sz, instrument, "order.sz")?;
342 let filled_qty = Quantity::from_raw(
343 orig_qty.raw.saturating_sub(remaining_qty.raw),
344 instrument.size_precision(),
345 );
346
347 let price = parse_price(order.order.limit_px, instrument, "order.limitPx")?;
348
349 let ts_accepted = millis_to_nanos(order.order.timestamp)?;
350 let ts_last = millis_to_nanos(order.status_timestamp)?;
351
352 let mut report = OrderStatusReport::new(
353 account_id,
354 instrument_id,
355 None, venue_order_id,
357 order_side,
358 order_type,
359 time_in_force,
360 order_status,
361 orig_qty, filled_qty,
363 ts_accepted,
364 ts_last,
365 ts_init,
366 Some(UUID4::new()),
367 );
368
369 if let Some(ref cloid) = order.order.cloid {
370 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
371 }
372
373 if matches!(order.order.tif, Some(HyperliquidTimeInForce::Alo)) {
374 report = report.with_post_only(true);
375 }
376
377 if let Some(reduce_only) = order.order.reduce_only {
378 report = report.with_reduce_only(reduce_only);
379 }
380
381 if let Some(reason) = order.status.rejection_reason() {
382 report = report.with_cancel_reason(reason.to_string());
383 }
384
385 report = report.with_price(price);
386
387 if is_conditional && let Some(trigger_px) = order.order.trigger_px {
388 let trigger_price = parse_price(trigger_px, instrument, "order.triggerPx")?;
389 report = report.with_trigger_price(trigger_price);
390 }
391
392 Ok(report)
393}
394
395pub fn parse_ws_fill_report(
399 fill: &WsFillData,
400 instrument: &InstrumentAny,
401 account_id: AccountId,
402 ts_init: UnixNanos,
403) -> anyhow::Result<FillReport> {
404 let instrument_id = instrument.id();
405
406 if let Some(liquidation) = fill.liquidation.as_ref() {
407 log::warn!(
408 "Liquidation fill: {} oid={} method={:?} mark_px={} liquidated_user={}",
409 instrument_id,
410 fill.oid,
411 liquidation.method,
412 liquidation.mark_px,
413 liquidation
414 .liquidated_user
415 .as_deref()
416 .unwrap_or("<unknown>"),
417 );
418 } else if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
419 log::warn!(
420 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
421 fill.oid,
422 fill.px,
423 fill.sz,
424 );
425 }
426
427 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
428 let trade_id = make_fill_trade_id(
429 &fill.hash,
430 fill.oid,
431 fill.px,
432 fill.sz,
433 fill.time,
434 fill.start_position,
435 );
436
437 let order_side = OrderSide::from(fill.side);
438 let last_qty = parse_quantity(fill.sz, instrument, "fill.sz")?;
439 let last_px = parse_price(fill.px, instrument, "fill.px")?;
440 let liquidity_side = if fill.crossed {
441 LiquiditySide::Taker
442 } else {
443 LiquiditySide::Maker
444 };
445
446 let fee_amount = fill.fee;
447
448 let commission_currency =
449 crate::http::parse::resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
450
451 let commission = Money::from_decimal(fee_amount, commission_currency)
452 .with_context(|| format!("Failed to create commission from fee='{}'", fill.fee))?;
453 let ts_event = millis_to_nanos(fill.time)?;
454
455 let client_order_id = None;
457
458 Ok(FillReport::new(
459 account_id,
460 instrument_id,
461 venue_order_id,
462 trade_id,
463 order_side,
464 last_qty,
465 last_px,
466 commission,
467 liquidity_side,
468 client_order_id,
469 None, ts_event,
471 ts_init,
472 None, ))
474}
475
476pub fn parse_ws_asset_context(
482 ctx: &WsActiveAssetCtxData,
483 instrument: &InstrumentAny,
484 ts_init: UnixNanos,
485) -> anyhow::Result<(
486 MarkPriceUpdate,
487 Option<IndexPriceUpdate>,
488 Option<FundingRateUpdate>,
489)> {
490 let instrument_id = instrument.id();
491
492 match ctx {
493 WsActiveAssetCtxData::Perp { coin: _, ctx } => {
494 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
495 let mark_price_update =
496 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
497
498 let index_price = parse_price(ctx.oracle_px, instrument, "ctx.oracle_px")?;
499 let index_price_update =
500 IndexPriceUpdate::new(instrument_id, index_price, ts_init, ts_init);
501
502 let funding_rate_update = FundingRateUpdate::new(
503 instrument_id,
504 ctx.funding,
505 Some(60), None, ts_init,
508 ts_init,
509 );
510
511 Ok((
512 mark_price_update,
513 Some(index_price_update),
514 Some(funding_rate_update),
515 ))
516 }
517 WsActiveAssetCtxData::Spot { coin: _, ctx } => {
518 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
519 let mark_price_update =
520 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
521
522 Ok((mark_price_update, None, None))
523 }
524 }
525}
526
527pub fn parse_ws_open_interest(
532 open_interest: Decimal,
533 instrument: &InstrumentAny,
534 ts_init: UnixNanos,
535) -> anyhow::Result<HyperliquidOpenInterest> {
536 Ok(HyperliquidOpenInterest::new(
537 instrument.id(),
538 open_interest,
539 ts_init,
540 ts_init,
541 ))
542}
543
544fn venue_time_to_nanos(value: u64) -> anyhow::Result<UnixNanos> {
548 if value < 100_000_000_000 {
549 Ok(UnixNanos::from(value.checked_mul(1_000_000_000).context(
550 "venue time seconds overflow converting to nanos",
551 )?))
552 } else {
553 millis_to_nanos(value)
554 }
555}
556
557pub fn parse_ws_twap_history_row(
561 row: &WsTwapHistoryData,
562 user: &str,
563 is_snapshot: bool,
564 instrument: Option<&InstrumentAny>,
565 ts_init: UnixNanos,
566) -> anyhow::Result<HyperliquidTwapHistory> {
567 let state: &TwapStateData = &row.state;
568 let ts_event = venue_time_to_nanos(row.time)?;
569 let state_timestamp = venue_time_to_nanos(state.timestamp)?;
570 let envelope_user = if user.is_empty() {
571 state.user.as_str()
572 } else {
573 user
574 };
575
576 Ok(HyperliquidTwapHistory::new(
577 envelope_user.to_string(),
578 row.twap_id,
579 state.coin.to_string(),
580 instrument.map(Instrument::id),
581 OrderSide::from(state.side),
582 state.sz,
583 state.executed_sz,
584 state.executed_ntl,
585 state.minutes,
586 state.reduce_only,
587 state.randomize,
588 row.status.status,
589 row.status.description.clone(),
590 state_timestamp,
591 is_snapshot,
592 ts_event,
593 ts_init,
594 ))
595}
596
597pub fn parse_ws_twap_slice_fill(
601 item: &WsTwapSliceFillData,
602 user: &str,
603 is_snapshot: bool,
604 instrument: Option<&InstrumentAny>,
605 ts_init: UnixNanos,
606) -> anyhow::Result<HyperliquidTwapSliceFill> {
607 let fill = &item.fill;
608 let ts_event = millis_to_nanos(fill.time)?;
609
610 Ok(HyperliquidTwapSliceFill::new(
611 user.to_string(),
612 item.twap_id,
613 fill.coin.to_string(),
614 instrument.map(Instrument::id),
615 fill.px,
616 fill.sz,
617 OrderSide::from(fill.side),
618 fill.hash.clone(),
619 fill.oid,
620 fill.tid,
621 fill.crossed,
622 fill.fee,
623 fill.fee_token.to_string(),
624 fill.dir.to_string(),
625 fill.closed_pnl,
626 is_snapshot,
627 ts_event,
628 ts_init,
629 ))
630}
631
632#[cfg(test)]
633mod tests {
634 use std::str::FromStr;
635
636 use nautilus_model::{
637 identifiers::{InstrumentId, Symbol},
638 instruments::CryptoPerpetual,
639 types::currency::Currency,
640 };
641 use rstest::rstest;
642 use rust_decimal_macros::dec;
643 use ustr::Ustr;
644
645 use super::*;
646 use crate::{
647 common::{
648 consts::HYPERLIQUID_VENUE,
649 enums::{
650 HyperliquidFillDirection, HyperliquidLiquidationMethod,
651 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
652 HyperliquidTimeInForce,
653 },
654 },
655 websocket::messages::{
656 CandleData, FillLiquidationData, PerpsAssetCtx, SharedAssetCtx, SpotAssetCtx,
657 WsBasicOrderData, WsBookData, WsLevelData,
658 },
659 };
660
661 fn create_test_instrument() -> InstrumentAny {
662 let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE);
663
664 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
665 instrument_id,
666 Symbol::new("BTC-PERP"),
667 Currency::from("BTC"),
668 Currency::from("USDC"),
669 Currency::from("USDC"),
670 false, 2, 3, Price::from("0.01"),
674 Quantity::from("0.001"),
675 None, None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::default(),
690 UnixNanos::default(),
691 ))
692 }
693
694 #[rstest]
695 fn test_parse_ws_candle_preserves_open_event_and_receipt_initialization_timestamps() {
696 let instrument = create_test_instrument();
697 let bar_type = BarType::from("BTC-PERP.HYPERLIQUID-1-MINUTE-LAST-EXTERNAL");
698 let candle = CandleData {
699 t: 1_700_000_000_000,
700 close_time: 1_700_000_059_999,
701 s: Ustr::from("BTC"),
702 i: Ustr::from("1m"),
703 o: dec!(100.0),
704 c: dec!(100.5),
705 h: dec!(101.0),
706 l: dec!(99.0),
707 v: dec!(10.0),
708 n: 42,
709 };
710 let receipt_timestamp = UnixNanos::from(1_700_000_060_123_000_000);
711
712 let bar = parse_ws_candle(&candle, &instrument, &bar_type, receipt_timestamp).unwrap();
713
714 assert_eq!(bar.ts_event, millis_to_nanos(candle.t).unwrap());
715 assert_eq!(bar.ts_init, receipt_timestamp);
716 }
717
718 #[rstest]
719 fn test_parse_ws_order_status_report_basic() {
720 let instrument = create_test_instrument();
721 let account_id = AccountId::new("HYPERLIQUID-001");
722 let ts_init = UnixNanos::default();
723
724 let order_data = WsOrderData {
725 order: WsBasicOrderData {
726 coin: Ustr::from("BTC"),
727 side: HyperliquidSide::Buy,
728 limit_px: dec!(50000.0),
729 sz: dec!(0.5),
730 oid: 12345,
731 timestamp: 1704470400000,
732 orig_sz: dec!(1.0),
733 cloid: Some("test-order-1".to_string()),
734 tif: Some(HyperliquidTimeInForce::Alo),
735 reduce_only: Some(true),
736 trigger_px: Some(dec!(0.0)),
737 is_market: None,
738 tpsl: None,
739 trigger_activated: None,
740 trailing_stop: None,
741 },
742 status: HyperliquidOrderStatusEnum::Open,
743 status_timestamp: 1704470400000,
744 };
745
746 let result = parse_ws_order_status_report(&order_data, &instrument, account_id, ts_init);
747 assert!(result.is_ok());
748
749 let report = result.unwrap();
750 assert_eq!(report.order_side, OrderSide::Buy);
751 assert_eq!(report.order_type, OrderType::Limit);
752 assert_eq!(report.order_status, OrderStatus::Accepted);
753 assert_eq!(report.time_in_force, TimeInForce::Gtc);
754 assert!(report.post_only);
755 assert!(report.reduce_only);
756 assert!(report.trigger_price.is_none());
757 }
758
759 #[rstest]
760 #[case(
761 HyperliquidOrderStatusEnum::BadAloPxRejected,
762 "Post only order would have immediately matched"
763 )]
764 #[case(
765 HyperliquidOrderStatusEnum::ReduceOnlyRejected,
766 "Reduce only order would increase position."
767 )]
768 #[case(
769 HyperliquidOrderStatusEnum::IocCancelRejected,
770 "Order could not immediately match against any resting orders"
771 )]
772 fn test_parse_ws_rejection_preserves_venue_reason(
773 #[case] status: HyperliquidOrderStatusEnum,
774 #[case] expected_reason: &str,
775 ) {
776 let instrument = create_test_instrument();
777 let order_data = WsOrderData {
778 order: WsBasicOrderData {
779 coin: Ustr::from("BTC"),
780 side: HyperliquidSide::Buy,
781 limit_px: dec!(50000.0),
782 sz: dec!(1.0),
783 oid: 12345,
784 timestamp: 1704470400000,
785 orig_sz: dec!(1.0),
786 cloid: Some("test-rejection".to_string()),
787 tif: Some(HyperliquidTimeInForce::Alo),
788 reduce_only: Some(false),
789 trigger_px: None,
790 is_market: None,
791 tpsl: None,
792 trigger_activated: None,
793 trailing_stop: None,
794 },
795 status,
796 status_timestamp: 1704470400000,
797 };
798
799 let report = parse_ws_order_status_report(
800 &order_data,
801 &instrument,
802 AccountId::new("HYPERLIQUID-001"),
803 UnixNanos::default(),
804 )
805 .unwrap();
806
807 assert_eq!(report.order_status, OrderStatus::Rejected);
808 assert_eq!(report.cancel_reason.as_deref(), Some(expected_reason));
809 }
810
811 #[rstest]
812 fn test_parse_ws_fill_report_basic() {
813 let instrument = create_test_instrument();
814 let account_id = AccountId::new("HYPERLIQUID-001");
815 let ts_init = UnixNanos::default();
816
817 let fill_data = WsFillData {
818 coin: Ustr::from("BTC"),
819 px: dec!(50000.0),
820 sz: dec!(0.1),
821 side: HyperliquidSide::Buy,
822 time: 1704470400000,
823 start_position: dec!(0.0),
824 dir: HyperliquidFillDirection::OpenLong,
825 closed_pnl: dec!(0.0),
826 hash: "0xabc123".to_string(),
827 oid: 12345,
828 crossed: true,
829 fee: dec!(0.05),
830 tid: 98765,
831 liquidation: None,
832 fee_token: Ustr::from("USDC"),
833 builder_fee: None,
834 cloid: Some("0xd211f1c27288259290850338d22132a0".to_string()),
835 twap_id: None,
836 };
837
838 let result = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init);
839 assert!(result.is_ok());
840
841 let report = result.unwrap();
842 assert_eq!(report.order_side, OrderSide::Buy);
843 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
844 }
845
846 #[rstest]
847 fn test_parse_ws_fill_report_with_liquidation() {
848 let instrument = create_test_instrument();
849 let account_id = AccountId::new("HYPERLIQUID-001");
850 let ts_init = UnixNanos::default();
851
852 let fill_data = WsFillData {
853 coin: Ustr::from("BTC"),
854 px: dec!(50000.0),
855 sz: dec!(0.1),
856 side: HyperliquidSide::Sell,
857 time: 1704470400000,
858 start_position: dec!(0.1),
859 dir: HyperliquidFillDirection::CloseLong,
860 closed_pnl: dec!(-25.0),
861 hash: "0xdef456".to_string(),
862 oid: 54321,
863 crossed: true,
864 fee: dec!(0.0),
865 tid: 12345,
866 liquidation: Some(FillLiquidationData {
867 liquidated_user: Some("0xuser".to_string()),
868 mark_px: dec!(50000.0),
869 method: HyperliquidLiquidationMethod::Market,
870 }),
871 fee_token: Ustr::from("USDC"),
872 builder_fee: None,
873 cloid: None,
874 twap_id: None,
875 };
876
877 let report = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init).unwrap();
878
879 assert_eq!(report.order_side, OrderSide::Sell);
882 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
883 assert_eq!(report.venue_order_id.to_string(), "54321");
884 }
885
886 #[rstest]
887 fn test_parse_ws_fill_report_outcome_round_trip() {
888 use crate::http::{
889 models::{OutcomeMarket, OutcomeMeta},
890 parse::{create_instrument_from_def, parse_outcome_instruments},
891 };
892
893 let meta = OutcomeMeta {
894 outcomes: vec![OutcomeMarket {
895 outcome: 99,
896 name: "BTC daily".to_string(),
897 description: String::new(),
898 side_specs: vec![],
899 }],
900 questions: vec![],
901 };
902
903 let defs = parse_outcome_instruments(&meta).unwrap();
904 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
905 assert_eq!(instrument.id().symbol.as_str(), "99-YES-OUTCOME");
906
907 let fill_data = WsFillData {
908 coin: Ustr::from("#990"),
909 px: dec!(0.4500),
910 sz: dec!(1500.00),
911 side: HyperliquidSide::Buy,
912 time: 1_704_470_400_000,
913 start_position: dec!(0.00),
914 dir: HyperliquidFillDirection::OpenLong,
915 closed_pnl: dec!(0.0),
916 hash: "0xabc789".to_string(),
917 oid: 42_42,
918 crossed: true,
919 fee: dec!(0.0),
920 tid: 7777,
921 liquidation: None,
922 fee_token: Ustr::from("+990"),
923 builder_fee: None,
924 cloid: None,
925 twap_id: None,
926 };
927
928 let report = parse_ws_fill_report(
929 &fill_data,
930 &instrument,
931 AccountId::new("HYPERLIQUID-001"),
932 UnixNanos::default(),
933 )
934 .unwrap();
935
936 assert_eq!(report.commission.currency.code.as_str(), "USDH");
940 assert!(report.commission.as_decimal().is_zero());
941 assert_eq!(report.order_side, OrderSide::Buy);
942 }
943
944 #[rstest]
945 fn test_parse_ws_order_book_deltas_snapshot_behavior() {
946 let instrument = create_test_instrument();
947 let ts_init = UnixNanos::default();
948
949 let book = WsBookData {
950 coin: Ustr::from("BTC"),
951 levels: [
952 vec![WsLevelData {
953 px: dec!(50000.0),
954 sz: dec!(1.0),
955 n: 1,
956 }],
957 vec![WsLevelData {
958 px: dec!(50001.0),
959 sz: dec!(2.0),
960 n: 1,
961 }],
962 ],
963 time: 1_704_470_400_000,
964 };
965
966 let deltas = parse_ws_order_book_deltas(&book, &instrument, ts_init).unwrap();
967
968 assert_eq!(deltas.deltas.len(), 3); assert_eq!(deltas.deltas[0].action, BookAction::Clear);
970
971 let bid_delta = &deltas.deltas[1];
972 assert_eq!(bid_delta.action, BookAction::Add);
973 assert_eq!(bid_delta.order.side, OrderSide::Buy);
974 assert!(bid_delta.order.size.is_positive());
975 assert_eq!(bid_delta.order.order_id, 0);
976
977 let ask_delta = &deltas.deltas[2];
978 assert_eq!(ask_delta.action, BookAction::Add);
979 assert_eq!(ask_delta.order.side, OrderSide::Sell);
980 assert!(ask_delta.order.size.is_positive());
981 assert_eq!(ask_delta.order.order_id, 0);
982 }
983
984 #[rstest]
985 fn test_parse_ws_order_book_depth10_pads_sparse_book() {
986 let instrument = create_test_instrument();
987 let ts_init = UnixNanos::default();
988
989 let book = WsBookData {
991 coin: Ustr::from("BTC"),
992 levels: [
993 vec![
994 WsLevelData {
995 px: dec!(100.00),
996 sz: dec!(1.0),
997 n: 2,
998 },
999 WsLevelData {
1000 px: dec!(99.99),
1001 sz: dec!(2.0),
1002 n: 3,
1003 },
1004 WsLevelData {
1005 px: dec!(99.98),
1006 sz: dec!(3.0),
1007 n: 1,
1008 },
1009 ],
1010 vec![
1011 WsLevelData {
1012 px: dec!(100.01),
1013 sz: dec!(1.5),
1014 n: 1,
1015 },
1016 WsLevelData {
1017 px: dec!(100.02),
1018 sz: dec!(2.5),
1019 n: 4,
1020 },
1021 ],
1022 ],
1023 time: 1_704_470_400_000,
1024 };
1025
1026 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
1027
1028 assert_eq!(depth.instrument_id, instrument.id());
1029 assert_eq!(depth.bids.len(), 10);
1030 assert_eq!(depth.asks.len(), 10);
1031
1032 assert_eq!(depth.bids[0].price.as_f64(), 100.00);
1033 assert_eq!(depth.bids[0].side, OrderSide::Buy);
1034 assert_eq!(depth.bid_counts[0], 2);
1035 assert_eq!(depth.bids[2].price.as_f64(), 99.98);
1036 assert_eq!(depth.bid_counts[2], 1);
1037
1038 for i in 3..10 {
1040 assert_eq!(depth.bids[i].side, OrderSide::Buy);
1041 assert!(depth.bids[i].size.is_zero());
1042 assert_eq!(depth.bid_counts[i], 0);
1043 }
1044
1045 assert_eq!(depth.asks[0].price.as_f64(), 100.01);
1046 assert_eq!(depth.asks[0].side, OrderSide::Sell);
1047 assert_eq!(depth.ask_counts[0], 1);
1048 assert_eq!(depth.asks[1].price.as_f64(), 100.02);
1049 assert_eq!(depth.ask_counts[1], 4);
1050
1051 for i in 2..10 {
1052 assert_eq!(depth.asks[i].side, OrderSide::Sell);
1053 assert!(depth.asks[i].size.is_zero());
1054 assert_eq!(depth.ask_counts[i], 0);
1055 }
1056
1057 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1059 assert_eq!(
1060 depth.ts_event,
1061 UnixNanos::from(1_704_470_400_000 * 1_000_000)
1062 );
1063 }
1064
1065 #[rstest]
1066 fn test_parse_ws_order_book_depth10_truncates_beyond_10() {
1067 let instrument = create_test_instrument();
1068 let ts_init = UnixNanos::default();
1069
1070 let mk_levels = |base: f64, n: usize| -> Vec<WsLevelData> {
1071 (0..n)
1072 .map(|i| WsLevelData {
1073 px: Decimal::from_str(&format!("{:.2}", base - i as f64 * 0.01)).unwrap(),
1074 sz: dec!(1.0),
1075 n: 1,
1076 })
1077 .collect()
1078 };
1079
1080 let book = WsBookData {
1081 coin: Ustr::from("BTC"),
1082 levels: [mk_levels(100.00, 15), mk_levels(100.50, 12)],
1083 time: 1_704_470_400_000,
1084 };
1085
1086 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
1087
1088 for i in 0..10 {
1090 assert!(
1091 !depth.bids[i].size.is_zero(),
1092 "bid slot {i} unexpectedly empty"
1093 );
1094 assert!(
1095 !depth.asks[i].size.is_zero(),
1096 "ask slot {i} unexpectedly empty"
1097 );
1098 }
1099 }
1100
1101 #[rstest]
1102 fn test_parse_ws_asset_context_perp() {
1103 let instrument = create_test_instrument();
1104 let ts_init = UnixNanos::default();
1105
1106 let ctx_data = WsActiveAssetCtxData::Perp {
1107 coin: Ustr::from("BTC"),
1108 ctx: PerpsAssetCtx {
1109 shared: SharedAssetCtx {
1110 day_ntl_vlm: dec!(1000000.0),
1111 prev_day_px: dec!(49000.0),
1112 mark_px: dec!(50000.0),
1113 mid_px: Some(dec!(50001.0)),
1114 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1115 day_base_vlm: Some(dec!(100.0)),
1116 },
1117 funding: dec!(0.0001),
1118 open_interest: dec!(100000.0),
1119 oracle_px: dec!(50005.0),
1120 premium: Some(dec!(-0.0001)),
1121 },
1122 };
1123
1124 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
1125 assert!(result.is_ok());
1126
1127 let (mark_price, index_price, funding_rate) = result.unwrap();
1128
1129 assert_eq!(mark_price.instrument_id, instrument.id());
1130 assert_eq!(mark_price.value.as_f64(), 50_000.0);
1131
1132 assert!(index_price.is_some());
1133 let index = index_price.unwrap();
1134 assert_eq!(index.instrument_id, instrument.id());
1135 assert_eq!(index.value.as_f64(), 50_005.0);
1136
1137 assert!(funding_rate.is_some());
1138 let funding = funding_rate.unwrap();
1139 assert_eq!(funding.instrument_id, instrument.id());
1140 assert_eq!(funding.rate.to_string(), "0.0001");
1141 assert_eq!(funding.interval, Some(60));
1142 }
1143
1144 #[rstest]
1145 fn test_parse_ws_asset_context_spot() {
1146 let instrument = create_test_instrument();
1147 let ts_init = UnixNanos::default();
1148
1149 let ctx_data = WsActiveAssetCtxData::Spot {
1150 coin: Ustr::from("BTC"),
1151 ctx: SpotAssetCtx {
1152 shared: SharedAssetCtx {
1153 day_ntl_vlm: dec!(1000000.0),
1154 prev_day_px: dec!(49000.0),
1155 mark_px: dec!(50000.0),
1156 mid_px: Some(dec!(50001.0)),
1157 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1158 day_base_vlm: Some(dec!(100.0)),
1159 },
1160 circulating_supply: dec!(19000000.0),
1161 },
1162 };
1163
1164 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
1165 assert!(result.is_ok());
1166
1167 let (mark_price, index_price, funding_rate) = result.unwrap();
1168
1169 assert_eq!(mark_price.instrument_id, instrument.id());
1170 assert_eq!(mark_price.value.as_f64(), 50_000.0);
1171 assert!(index_price.is_none());
1172 assert!(funding_rate.is_none());
1173 }
1174
1175 #[rstest]
1179 #[case::positive_high_precision("0.0001234567890123456")]
1180 #[case::negative_high_precision("-0.0001234567890123456")]
1181 fn test_parse_ws_asset_context_perp_preserves_funding_precision(#[case] funding_str: &str) {
1182 let instrument = create_test_instrument();
1183 let ts_init = UnixNanos::default();
1184
1185 let expected = Decimal::from_str(funding_str).unwrap();
1186
1187 let ctx_data = WsActiveAssetCtxData::Perp {
1188 coin: Ustr::from("BTC"),
1189 ctx: PerpsAssetCtx {
1190 shared: SharedAssetCtx {
1191 day_ntl_vlm: dec!(1000000.0),
1192 prev_day_px: dec!(49000.0),
1193 mark_px: dec!(50000.0),
1194 mid_px: None,
1195 impact_pxs: None,
1196 day_base_vlm: None,
1197 },
1198 funding: Decimal::from_str(funding_str).unwrap(),
1199 open_interest: dec!(100000.0),
1200 oracle_px: dec!(50005.0),
1201 premium: None,
1202 },
1203 };
1204
1205 let (_, _, funding_rate) = parse_ws_asset_context(&ctx_data, &instrument, ts_init).unwrap();
1206
1207 let funding = funding_rate.expect("perp ctx must yield funding rate");
1208 assert_eq!(funding.rate, expected);
1209 }
1210
1211 #[rstest]
1212 fn test_parse_ws_open_interest_perp() {
1213 let instrument = create_test_instrument();
1214 let ts_init = UnixNanos::default();
1215
1216 let open_interest = parse_ws_open_interest(dec!(100000.0), &instrument, ts_init).unwrap();
1217
1218 assert_eq!(open_interest.instrument_id, instrument.id());
1219 assert_eq!(open_interest.open_interest.to_string(), "100000.0");
1220 assert_eq!(open_interest.ts_event, ts_init);
1221 assert_eq!(open_interest.ts_init, ts_init);
1222 }
1223
1224 #[rstest]
1225 #[case::round("100000.0")]
1226 #[case::precise("100000.123456789")]
1227 fn test_parse_ws_open_interest_preserves_precision(#[case] open_interest_str: &str) {
1228 let instrument = create_test_instrument();
1229 let ts_init = UnixNanos::default();
1230
1231 let expected = Decimal::from_str(open_interest_str).unwrap();
1232
1233 let open_interest = parse_ws_open_interest(
1234 Decimal::from_str(open_interest_str).unwrap(),
1235 &instrument,
1236 ts_init,
1237 )
1238 .unwrap();
1239
1240 assert_eq!(open_interest.open_interest, expected);
1241 }
1242
1243 #[rstest]
1244 fn test_parse_ws_twap_history_row_from_live_mainnet_fixture() {
1245 let fixture = include_str!("../../test_data/ws_user_twap_history.json");
1246 let msg: crate::websocket::messages::HyperliquidWsMessage =
1247 serde_json::from_str(fixture).expect("fixture should deserialize");
1248 let crate::websocket::messages::HyperliquidWsMessage::UserTwapHistory { data } = msg else {
1249 panic!("expected UserTwapHistory");
1250 };
1251 let ts_init = UnixNanos::from(99);
1252 let is_snapshot = data.is_snapshot.unwrap_or(false);
1253
1254 let row =
1255 parse_ws_twap_history_row(&data.history[0], &data.user, is_snapshot, None, ts_init)
1256 .unwrap();
1257
1258 assert!(row.is_snapshot);
1259 assert_eq!(row.user, data.user);
1260 assert_eq!(row.coin, "xyz:HOOD");
1261 assert_eq!(row.twap_id, Some(2081397));
1262 assert!(row.instrument_id.is_none());
1263 assert_eq!(row.side, OrderSide::Buy);
1264 assert_eq!(row.size.to_string(), "100.0");
1265 assert_eq!(row.executed_size.to_string(), "100.0");
1266 assert_eq!(row.minutes, 240);
1267 assert!(!row.randomize);
1268 assert!(!row.reduce_only);
1269 assert_eq!(
1270 row.status,
1271 crate::common::enums::HyperliquidTwapStatus::Finished
1272 );
1273 assert!(row.status_description.is_empty());
1274 assert_eq!(row.ts_event, UnixNanos::from(1_785_848_057_000_000_000));
1276 assert_eq!(row.ts_init, ts_init);
1277 }
1278
1279 #[rstest]
1280 fn test_parse_ws_twap_slice_fill_from_live_mainnet_fixture() {
1281 let fixture = include_str!("../../test_data/ws_user_twap_slice_fills.json");
1282 let msg: crate::websocket::messages::HyperliquidWsMessage =
1283 serde_json::from_str(fixture).expect("fixture should deserialize");
1284 let crate::websocket::messages::HyperliquidWsMessage::UserTwapSliceFills { data } = msg
1285 else {
1286 panic!("expected UserTwapSliceFills");
1287 };
1288 let instrument = create_test_instrument();
1289 let ts_init = UnixNanos::from(99);
1290 let is_snapshot = data.is_snapshot.unwrap_or(false);
1291
1292 let fill = parse_ws_twap_slice_fill(
1293 &data.twap_slice_fills[0],
1294 &data.user,
1295 is_snapshot,
1296 Some(&instrument),
1297 ts_init,
1298 )
1299 .unwrap();
1300
1301 assert!(fill.is_snapshot);
1302 assert_eq!(fill.twap_id, 2_087_225);
1303 assert_eq!(
1304 fill.hash,
1305 "0x0000000000000000000000000000000000000000000000000000000000000000"
1306 );
1307 assert_eq!(fill.coin, "BTC");
1308 assert_eq!(fill.instrument_id, Some(instrument.id()));
1309 assert_eq!(fill.side, OrderSide::Buy);
1310 assert_eq!(fill.price.to_string(), "64597.0");
1311 assert!(fill.crossed);
1312 }
1313}