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