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, HyperliquidPublicTrade},
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_public_trade(
96 trade: &WsTradeData,
97 instrument: &InstrumentAny,
98 ts_init: UnixNanos,
99) -> anyhow::Result<HyperliquidPublicTrade> {
100 let price = parse_price(trade.px, instrument, "trade.px")?;
101 let size = parse_quantity(trade.sz, instrument, "trade.sz")?;
102 let ts_event = millis_to_nanos(trade.time)?;
103
104 Ok(HyperliquidPublicTrade::new(
105 instrument.id(),
106 price,
107 size,
108 AggressorSide::from(trade.side),
109 trade.tid.to_string(),
110 trade.users[0].clone(),
111 trade.users[1].clone(),
112 trade.hash.clone(),
113 ts_event,
114 ts_init,
115 ))
116}
117
118pub fn parse_ws_order_book_deltas(
120 book: &WsBookData,
121 instrument: &InstrumentAny,
122 ts_init: UnixNanos,
123) -> anyhow::Result<OrderBookDeltas> {
124 let ts_event = millis_to_nanos(book.time)?;
125 let bids = &book.levels[0];
126 let asks = &book.levels[1];
127 let mut deltas = Vec::with_capacity(1 + bids.len() + asks.len());
128
129 deltas.push(OrderBookDelta::clear(instrument.id(), 0, ts_event, ts_init));
131
132 for level in bids {
133 let price = parse_price(level.px, instrument, "book.bid.px")?;
134 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
135
136 if !size.is_positive() {
137 continue;
138 }
139
140 let order = BookOrder::new(OrderSide::Buy, price, size, 0);
141
142 let delta = OrderBookDelta::new(
143 instrument.id(),
144 BookAction::Add,
145 order,
146 RecordFlag::F_LAST as u8,
147 0, ts_event,
149 ts_init,
150 );
151
152 deltas.push(delta);
153 }
154
155 for level in asks {
156 let price = parse_price(level.px, instrument, "book.ask.px")?;
157 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
158
159 if !size.is_positive() {
160 continue;
161 }
162
163 let order = BookOrder::new(OrderSide::Sell, price, size, 0);
164
165 let delta = OrderBookDelta::new(
166 instrument.id(),
167 BookAction::Add,
168 order,
169 RecordFlag::F_LAST as u8,
170 0, ts_event,
172 ts_init,
173 );
174
175 deltas.push(delta);
176 }
177
178 Ok(OrderBookDeltas::new(instrument.id(), deltas))
179}
180
181pub fn parse_ws_order_book_depth10(
188 book: &WsBookData,
189 instrument: &InstrumentAny,
190 ts_init: UnixNanos,
191) -> anyhow::Result<OrderBookDepth10> {
192 let ts_event = millis_to_nanos(book.time)?;
193 let price_precision = instrument.price_precision();
194 let size_precision = instrument.size_precision();
195
196 let mut bids: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
197 let mut asks: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
198 let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
199 let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
200
201 let raw_bids = book.levels.first().map_or(&[][..], |v| v.as_slice());
202 let raw_asks = book.levels.get(1).map_or(&[][..], |v| v.as_slice());
203
204 for (i, level) in raw_bids.iter().take(DEPTH10_LEN).enumerate() {
205 let price = parse_price(level.px, instrument, "book.bid.px")?;
206 let size = parse_quantity(level.sz, instrument, "book.bid.sz")?;
207 bids[i] = BookOrder::new(OrderSide::Buy, price, size, 0);
208 bid_counts[i] = level.n;
209 }
210
211 for bid in bids.iter_mut().skip(raw_bids.len().min(DEPTH10_LEN)) {
212 *bid = BookOrder::new(
213 OrderSide::Buy,
214 Price::zero(price_precision),
215 Quantity::zero(size_precision),
216 0,
217 );
218 }
219
220 for (i, level) in raw_asks.iter().take(DEPTH10_LEN).enumerate() {
221 let price = parse_price(level.px, instrument, "book.ask.px")?;
222 let size = parse_quantity(level.sz, instrument, "book.ask.sz")?;
223 asks[i] = BookOrder::new(OrderSide::Sell, price, size, 0);
224 ask_counts[i] = level.n;
225 }
226
227 for ask in asks.iter_mut().skip(raw_asks.len().min(DEPTH10_LEN)) {
228 *ask = BookOrder::new(
229 OrderSide::Sell,
230 Price::zero(price_precision),
231 Quantity::zero(size_precision),
232 0,
233 );
234 }
235
236 Ok(OrderBookDepth10::new(
237 instrument.id(),
238 bids,
239 asks,
240 bid_counts,
241 ask_counts,
242 RecordFlag::F_SNAPSHOT as u8,
243 0,
244 ts_event,
245 ts_init,
246 ))
247}
248
249pub fn parse_ws_quote_tick(
251 bbo: &WsBboData,
252 instrument: &InstrumentAny,
253 ts_init: UnixNanos,
254) -> anyhow::Result<QuoteTick> {
255 let bid_level = bbo.bbo[0]
256 .as_ref()
257 .context("BBO message missing bid level")?;
258 let ask_level = bbo.bbo[1]
259 .as_ref()
260 .context("BBO message missing ask level")?;
261
262 let bid_price = parse_price(bid_level.px, instrument, "bbo.bid.px")?;
263 let ask_price = parse_price(ask_level.px, instrument, "bbo.ask.px")?;
264 let bid_size = parse_quantity(bid_level.sz, instrument, "bbo.bid.sz")?;
265 let ask_size = parse_quantity(ask_level.sz, instrument, "bbo.ask.sz")?;
266
267 let ts_event = millis_to_nanos(bbo.time)?;
268
269 QuoteTick::new_checked(
270 instrument.id(),
271 bid_price,
272 ask_price,
273 bid_size,
274 ask_size,
275 ts_event,
276 ts_init,
277 )
278 .context("failed to construct QuoteTick from Hyperliquid BBO message")
279}
280
281pub fn parse_ws_candle(
283 candle: &CandleData,
284 instrument: &InstrumentAny,
285 bar_type: &BarType,
286 ts_init: UnixNanos,
287) -> anyhow::Result<Bar> {
288 let open = parse_price(candle.o, instrument, "candle.o")?;
289 let high = parse_price(candle.h, instrument, "candle.h")?;
290 let low = parse_price(candle.l, instrument, "candle.l")?;
291 let close = parse_price(candle.c, instrument, "candle.c")?;
292 let volume = parse_quantity(candle.v, instrument, "candle.v")?;
293
294 let ts_event = millis_to_nanos(candle.t)?;
295
296 Ok(Bar::new(
297 *bar_type, open, high, low, close, volume, ts_event, ts_init,
298 ))
299}
300
301pub fn parse_ws_order_status_report(
306 order: &WsOrderData,
307 instrument: &InstrumentAny,
308 account_id: AccountId,
309 ts_init: UnixNanos,
310) -> anyhow::Result<OrderStatusReport> {
311 let instrument_id = instrument.id();
312 let venue_order_id = VenueOrderId::new(order.order.oid.to_string());
313 let order_side = OrderSide::from(order.order.side);
314
315 let is_conditional =
317 is_conditional_order_data(order.order.trigger_px, order.order.tpsl.as_ref());
318 let order_type = if is_conditional {
319 if let (Some(is_market), Some(tpsl)) = (order.order.is_market, order.order.tpsl.as_ref()) {
320 parse_trigger_order_type(is_market, tpsl)
321 } else {
322 OrderType::Limit }
324 } else {
325 OrderType::Limit };
327
328 let time_in_force = match order.order.tif {
329 Some(HyperliquidTimeInForce::Ioc) => TimeInForce::Ioc,
330 _ => TimeInForce::Gtc,
331 };
332 let order_status = OrderStatus::from(order.status);
333
334 let orig_qty = parse_quantity(order.order.orig_sz, instrument, "order.orig_sz")?;
336 let remaining_qty = parse_quantity(order.order.sz, instrument, "order.sz")?;
337 let filled_qty = Quantity::from_raw(
338 orig_qty.raw.saturating_sub(remaining_qty.raw),
339 instrument.size_precision(),
340 );
341
342 let price = parse_price(order.order.limit_px, instrument, "order.limitPx")?;
343
344 let ts_accepted = millis_to_nanos(order.order.timestamp)?;
345 let ts_last = millis_to_nanos(order.status_timestamp)?;
346
347 let mut report = OrderStatusReport::new(
348 account_id,
349 instrument_id,
350 None, venue_order_id,
352 order_side,
353 order_type,
354 time_in_force,
355 order_status,
356 orig_qty, filled_qty,
358 ts_accepted,
359 ts_last,
360 ts_init,
361 Some(UUID4::new()),
362 );
363
364 if let Some(ref cloid) = order.order.cloid {
365 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
366 }
367
368 if matches!(order.order.tif, Some(HyperliquidTimeInForce::Alo)) {
369 report = report.with_post_only(true);
370 }
371
372 if let Some(reduce_only) = order.order.reduce_only {
373 report = report.with_reduce_only(reduce_only);
374 }
375
376 if let Some(reason) = order.status.rejection_reason() {
377 report = report.with_cancel_reason(reason.to_string());
378 }
379
380 report = report.with_price(price);
381
382 if is_conditional && let Some(trigger_px) = order.order.trigger_px {
383 let trigger_price = parse_price(trigger_px, instrument, "order.triggerPx")?;
384 report = report.with_trigger_price(trigger_price);
385 }
386
387 Ok(report)
388}
389
390pub fn parse_ws_fill_report(
394 fill: &WsFillData,
395 instrument: &InstrumentAny,
396 account_id: AccountId,
397 ts_init: UnixNanos,
398) -> anyhow::Result<FillReport> {
399 let instrument_id = instrument.id();
400
401 if let Some(liquidation) = fill.liquidation.as_ref() {
402 log::warn!(
403 "Liquidation fill: {} oid={} method={:?} mark_px={} liquidated_user={}",
404 instrument_id,
405 fill.oid,
406 liquidation.method,
407 liquidation.mark_px,
408 liquidation
409 .liquidated_user
410 .as_deref()
411 .unwrap_or("<unknown>"),
412 );
413 } else if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
414 log::warn!(
415 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
416 fill.oid,
417 fill.px,
418 fill.sz,
419 );
420 }
421
422 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
423 let trade_id = make_fill_trade_id(
424 &fill.hash,
425 fill.oid,
426 fill.px,
427 fill.sz,
428 fill.time,
429 fill.start_position,
430 );
431
432 let order_side = OrderSide::from(fill.side);
433 let last_qty = parse_quantity(fill.sz, instrument, "fill.sz")?;
434 let last_px = parse_price(fill.px, instrument, "fill.px")?;
435 let liquidity_side = if fill.crossed {
436 LiquiditySide::Taker
437 } else {
438 LiquiditySide::Maker
439 };
440
441 let fee_amount = fill.fee;
442
443 let commission_currency =
444 crate::http::parse::resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
445
446 let commission = Money::from_decimal(fee_amount, commission_currency)
447 .with_context(|| format!("Failed to create commission from fee='{}'", fill.fee))?;
448 let ts_event = millis_to_nanos(fill.time)?;
449
450 let client_order_id = None;
452
453 Ok(FillReport::new(
454 account_id,
455 instrument_id,
456 venue_order_id,
457 trade_id,
458 order_side,
459 last_qty,
460 last_px,
461 commission,
462 liquidity_side,
463 client_order_id,
464 None, ts_event,
466 ts_init,
467 None, ))
469}
470
471pub fn parse_ws_asset_context(
477 ctx: &WsActiveAssetCtxData,
478 instrument: &InstrumentAny,
479 ts_init: UnixNanos,
480) -> anyhow::Result<(
481 MarkPriceUpdate,
482 Option<IndexPriceUpdate>,
483 Option<FundingRateUpdate>,
484)> {
485 let instrument_id = instrument.id();
486
487 match ctx {
488 WsActiveAssetCtxData::Perp { coin: _, ctx } => {
489 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
490 let mark_price_update =
491 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
492
493 let index_price = parse_price(ctx.oracle_px, instrument, "ctx.oracle_px")?;
494 let index_price_update =
495 IndexPriceUpdate::new(instrument_id, index_price, ts_init, ts_init);
496
497 let funding_rate_update = FundingRateUpdate::new(
498 instrument_id,
499 ctx.funding,
500 Some(60), None, ts_init,
503 ts_init,
504 );
505
506 Ok((
507 mark_price_update,
508 Some(index_price_update),
509 Some(funding_rate_update),
510 ))
511 }
512 WsActiveAssetCtxData::Spot { coin: _, ctx } => {
513 let mark_price = parse_price(ctx.shared.mark_px, instrument, "ctx.mark_px")?;
514 let mark_price_update =
515 MarkPriceUpdate::new(instrument_id, mark_price, ts_init, ts_init);
516
517 Ok((mark_price_update, None, None))
518 }
519 }
520}
521
522pub fn parse_ws_open_interest(
527 open_interest: Decimal,
528 instrument: &InstrumentAny,
529 ts_init: UnixNanos,
530) -> anyhow::Result<HyperliquidOpenInterest> {
531 Ok(HyperliquidOpenInterest::new(
532 instrument.id(),
533 open_interest,
534 ts_init,
535 ts_init,
536 ))
537}
538
539#[cfg(test)]
540mod tests {
541 use std::str::FromStr;
542
543 use nautilus_model::{
544 identifiers::{InstrumentId, Symbol},
545 instruments::CryptoPerpetual,
546 types::currency::Currency,
547 };
548 use rstest::rstest;
549 use rust_decimal_macros::dec;
550 use ustr::Ustr;
551
552 use super::*;
553 use crate::{
554 common::{
555 consts::HYPERLIQUID_VENUE,
556 enums::{
557 HyperliquidFillDirection, HyperliquidLiquidationMethod,
558 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide,
559 HyperliquidTimeInForce,
560 },
561 },
562 websocket::messages::{
563 FillLiquidationData, PerpsAssetCtx, SharedAssetCtx, SpotAssetCtx, WsBasicOrderData,
564 WsBookData, WsLevelData,
565 },
566 };
567
568 fn create_test_instrument() -> InstrumentAny {
569 let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE);
570
571 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
572 instrument_id,
573 Symbol::new("BTC-PERP"),
574 Currency::from("BTC"),
575 Currency::from("USDC"),
576 Currency::from("USDC"),
577 false, 2, 3, Price::from("0.01"),
581 Quantity::from("0.001"),
582 None, None, None, None, None, None, None, None, None, None, None, None, None, None, UnixNanos::default(),
597 UnixNanos::default(),
598 ))
599 }
600
601 #[rstest]
602 fn test_parse_ws_order_status_report_basic() {
603 let instrument = create_test_instrument();
604 let account_id = AccountId::new("HYPERLIQUID-001");
605 let ts_init = UnixNanos::default();
606
607 let order_data = WsOrderData {
608 order: WsBasicOrderData {
609 coin: Ustr::from("BTC"),
610 side: HyperliquidSide::Buy,
611 limit_px: dec!(50000.0),
612 sz: dec!(0.5),
613 oid: 12345,
614 timestamp: 1704470400000,
615 orig_sz: dec!(1.0),
616 cloid: Some("test-order-1".to_string()),
617 tif: Some(HyperliquidTimeInForce::Alo),
618 reduce_only: Some(true),
619 trigger_px: Some(dec!(0.0)),
620 is_market: None,
621 tpsl: None,
622 trigger_activated: None,
623 trailing_stop: None,
624 },
625 status: HyperliquidOrderStatusEnum::Open,
626 status_timestamp: 1704470400000,
627 };
628
629 let result = parse_ws_order_status_report(&order_data, &instrument, account_id, ts_init);
630 assert!(result.is_ok());
631
632 let report = result.unwrap();
633 assert_eq!(report.order_side, OrderSide::Buy);
634 assert_eq!(report.order_type, OrderType::Limit);
635 assert_eq!(report.order_status, OrderStatus::Accepted);
636 assert_eq!(report.time_in_force, TimeInForce::Gtc);
637 assert!(report.post_only);
638 assert!(report.reduce_only);
639 assert!(report.trigger_price.is_none());
640 }
641
642 #[rstest]
643 #[case(
644 HyperliquidOrderStatusEnum::BadAloPxRejected,
645 "Post only order would have immediately matched"
646 )]
647 #[case(
648 HyperliquidOrderStatusEnum::ReduceOnlyRejected,
649 "Reduce only order would increase position."
650 )]
651 #[case(
652 HyperliquidOrderStatusEnum::IocCancelRejected,
653 "Order could not immediately match against any resting orders"
654 )]
655 fn test_parse_ws_rejection_preserves_venue_reason(
656 #[case] status: HyperliquidOrderStatusEnum,
657 #[case] expected_reason: &str,
658 ) {
659 let instrument = create_test_instrument();
660 let order_data = WsOrderData {
661 order: WsBasicOrderData {
662 coin: Ustr::from("BTC"),
663 side: HyperliquidSide::Buy,
664 limit_px: dec!(50000.0),
665 sz: dec!(1.0),
666 oid: 12345,
667 timestamp: 1704470400000,
668 orig_sz: dec!(1.0),
669 cloid: Some("test-rejection".to_string()),
670 tif: Some(HyperliquidTimeInForce::Alo),
671 reduce_only: Some(false),
672 trigger_px: None,
673 is_market: None,
674 tpsl: None,
675 trigger_activated: None,
676 trailing_stop: None,
677 },
678 status,
679 status_timestamp: 1704470400000,
680 };
681
682 let report = parse_ws_order_status_report(
683 &order_data,
684 &instrument,
685 AccountId::new("HYPERLIQUID-001"),
686 UnixNanos::default(),
687 )
688 .unwrap();
689
690 assert_eq!(report.order_status, OrderStatus::Rejected);
691 assert_eq!(report.cancel_reason.as_deref(), Some(expected_reason));
692 }
693
694 #[rstest]
695 fn test_parse_ws_fill_report_basic() {
696 let instrument = create_test_instrument();
697 let account_id = AccountId::new("HYPERLIQUID-001");
698 let ts_init = UnixNanos::default();
699
700 let fill_data = WsFillData {
701 coin: Ustr::from("BTC"),
702 px: dec!(50000.0),
703 sz: dec!(0.1),
704 side: HyperliquidSide::Buy,
705 time: 1704470400000,
706 start_position: dec!(0.0),
707 dir: HyperliquidFillDirection::OpenLong,
708 closed_pnl: dec!(0.0),
709 hash: "0xabc123".to_string(),
710 oid: 12345,
711 crossed: true,
712 fee: dec!(0.05),
713 tid: 98765,
714 liquidation: None,
715 fee_token: Ustr::from("USDC"),
716 builder_fee: None,
717 cloid: Some("0xd211f1c27288259290850338d22132a0".to_string()),
718 twap_id: None,
719 };
720
721 let result = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init);
722 assert!(result.is_ok());
723
724 let report = result.unwrap();
725 assert_eq!(report.order_side, OrderSide::Buy);
726 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
727 }
728
729 #[rstest]
730 fn test_parse_ws_fill_report_with_liquidation() {
731 let instrument = create_test_instrument();
732 let account_id = AccountId::new("HYPERLIQUID-001");
733 let ts_init = UnixNanos::default();
734
735 let fill_data = WsFillData {
736 coin: Ustr::from("BTC"),
737 px: dec!(50000.0),
738 sz: dec!(0.1),
739 side: HyperliquidSide::Sell,
740 time: 1704470400000,
741 start_position: dec!(0.1),
742 dir: HyperliquidFillDirection::CloseLong,
743 closed_pnl: dec!(-25.0),
744 hash: "0xdef456".to_string(),
745 oid: 54321,
746 crossed: true,
747 fee: dec!(0.0),
748 tid: 12345,
749 liquidation: Some(FillLiquidationData {
750 liquidated_user: Some("0xuser".to_string()),
751 mark_px: dec!(50000.0),
752 method: HyperliquidLiquidationMethod::Market,
753 }),
754 fee_token: Ustr::from("USDC"),
755 builder_fee: None,
756 cloid: None,
757 twap_id: None,
758 };
759
760 let report = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init).unwrap();
761
762 assert_eq!(report.order_side, OrderSide::Sell);
765 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
766 assert_eq!(report.venue_order_id.to_string(), "54321");
767 }
768
769 #[rstest]
770 fn test_parse_ws_fill_report_outcome_round_trip() {
771 use crate::http::{
772 models::{OutcomeMarket, OutcomeMeta},
773 parse::{create_instrument_from_def, parse_outcome_instruments},
774 };
775
776 let meta = OutcomeMeta {
777 outcomes: vec![OutcomeMarket {
778 outcome: 99,
779 name: "BTC daily".to_string(),
780 description: String::new(),
781 side_specs: vec![],
782 }],
783 questions: vec![],
784 };
785
786 let defs = parse_outcome_instruments(&meta).unwrap();
787 let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
788 assert_eq!(instrument.id().symbol.as_str(), "99-YES-OUTCOME");
789
790 let fill_data = WsFillData {
791 coin: Ustr::from("#990"),
792 px: dec!(0.4500),
793 sz: dec!(1500.00),
794 side: HyperliquidSide::Buy,
795 time: 1_704_470_400_000,
796 start_position: dec!(0.00),
797 dir: HyperliquidFillDirection::OpenLong,
798 closed_pnl: dec!(0.0),
799 hash: "0xabc789".to_string(),
800 oid: 42_42,
801 crossed: true,
802 fee: dec!(0.0),
803 tid: 7777,
804 liquidation: None,
805 fee_token: Ustr::from("+990"),
806 builder_fee: None,
807 cloid: None,
808 twap_id: None,
809 };
810
811 let report = parse_ws_fill_report(
812 &fill_data,
813 &instrument,
814 AccountId::new("HYPERLIQUID-001"),
815 UnixNanos::default(),
816 )
817 .unwrap();
818
819 assert_eq!(report.commission.currency.code.as_str(), "USDH");
823 assert!(report.commission.as_decimal().is_zero());
824 assert_eq!(report.order_side, OrderSide::Buy);
825 }
826
827 #[rstest]
828 fn test_parse_ws_order_book_deltas_snapshot_behavior() {
829 let instrument = create_test_instrument();
830 let ts_init = UnixNanos::default();
831
832 let book = WsBookData {
833 coin: Ustr::from("BTC"),
834 levels: [
835 vec![WsLevelData {
836 px: dec!(50000.0),
837 sz: dec!(1.0),
838 n: 1,
839 }],
840 vec![WsLevelData {
841 px: dec!(50001.0),
842 sz: dec!(2.0),
843 n: 1,
844 }],
845 ],
846 time: 1_704_470_400_000,
847 };
848
849 let deltas = parse_ws_order_book_deltas(&book, &instrument, ts_init).unwrap();
850
851 assert_eq!(deltas.deltas.len(), 3); assert_eq!(deltas.deltas[0].action, BookAction::Clear);
853
854 let bid_delta = &deltas.deltas[1];
855 assert_eq!(bid_delta.action, BookAction::Add);
856 assert_eq!(bid_delta.order.side, OrderSide::Buy);
857 assert!(bid_delta.order.size.is_positive());
858 assert_eq!(bid_delta.order.order_id, 0);
859
860 let ask_delta = &deltas.deltas[2];
861 assert_eq!(ask_delta.action, BookAction::Add);
862 assert_eq!(ask_delta.order.side, OrderSide::Sell);
863 assert!(ask_delta.order.size.is_positive());
864 assert_eq!(ask_delta.order.order_id, 0);
865 }
866
867 #[rstest]
868 fn test_parse_ws_order_book_depth10_pads_sparse_book() {
869 let instrument = create_test_instrument();
870 let ts_init = UnixNanos::default();
871
872 let book = WsBookData {
874 coin: Ustr::from("BTC"),
875 levels: [
876 vec![
877 WsLevelData {
878 px: dec!(100.00),
879 sz: dec!(1.0),
880 n: 2,
881 },
882 WsLevelData {
883 px: dec!(99.99),
884 sz: dec!(2.0),
885 n: 3,
886 },
887 WsLevelData {
888 px: dec!(99.98),
889 sz: dec!(3.0),
890 n: 1,
891 },
892 ],
893 vec![
894 WsLevelData {
895 px: dec!(100.01),
896 sz: dec!(1.5),
897 n: 1,
898 },
899 WsLevelData {
900 px: dec!(100.02),
901 sz: dec!(2.5),
902 n: 4,
903 },
904 ],
905 ],
906 time: 1_704_470_400_000,
907 };
908
909 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
910
911 assert_eq!(depth.instrument_id, instrument.id());
912 assert_eq!(depth.bids.len(), 10);
913 assert_eq!(depth.asks.len(), 10);
914
915 assert_eq!(depth.bids[0].price.as_f64(), 100.00);
916 assert_eq!(depth.bids[0].side, OrderSide::Buy);
917 assert_eq!(depth.bid_counts[0], 2);
918 assert_eq!(depth.bids[2].price.as_f64(), 99.98);
919 assert_eq!(depth.bid_counts[2], 1);
920
921 for i in 3..10 {
923 assert_eq!(depth.bids[i].side, OrderSide::Buy);
924 assert!(depth.bids[i].size.is_zero());
925 assert_eq!(depth.bid_counts[i], 0);
926 }
927
928 assert_eq!(depth.asks[0].price.as_f64(), 100.01);
929 assert_eq!(depth.asks[0].side, OrderSide::Sell);
930 assert_eq!(depth.ask_counts[0], 1);
931 assert_eq!(depth.asks[1].price.as_f64(), 100.02);
932 assert_eq!(depth.ask_counts[1], 4);
933
934 for i in 2..10 {
935 assert_eq!(depth.asks[i].side, OrderSide::Sell);
936 assert!(depth.asks[i].size.is_zero());
937 assert_eq!(depth.ask_counts[i], 0);
938 }
939
940 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
942 assert_eq!(
943 depth.ts_event,
944 UnixNanos::from(1_704_470_400_000 * 1_000_000)
945 );
946 }
947
948 #[rstest]
949 fn test_parse_ws_order_book_depth10_truncates_beyond_10() {
950 let instrument = create_test_instrument();
951 let ts_init = UnixNanos::default();
952
953 let mk_levels = |base: f64, n: usize| -> Vec<WsLevelData> {
954 (0..n)
955 .map(|i| WsLevelData {
956 px: Decimal::from_str(&format!("{:.2}", base - i as f64 * 0.01)).unwrap(),
957 sz: dec!(1.0),
958 n: 1,
959 })
960 .collect()
961 };
962
963 let book = WsBookData {
964 coin: Ustr::from("BTC"),
965 levels: [mk_levels(100.00, 15), mk_levels(100.50, 12)],
966 time: 1_704_470_400_000,
967 };
968
969 let depth = parse_ws_order_book_depth10(&book, &instrument, ts_init).unwrap();
970
971 for i in 0..10 {
973 assert!(
974 !depth.bids[i].size.is_zero(),
975 "bid slot {i} unexpectedly empty"
976 );
977 assert!(
978 !depth.asks[i].size.is_zero(),
979 "ask slot {i} unexpectedly empty"
980 );
981 }
982 }
983
984 #[rstest]
985 fn test_parse_ws_asset_context_perp() {
986 let instrument = create_test_instrument();
987 let ts_init = UnixNanos::default();
988
989 let ctx_data = WsActiveAssetCtxData::Perp {
990 coin: Ustr::from("BTC"),
991 ctx: PerpsAssetCtx {
992 shared: SharedAssetCtx {
993 day_ntl_vlm: dec!(1000000.0),
994 prev_day_px: dec!(49000.0),
995 mark_px: dec!(50000.0),
996 mid_px: Some(dec!(50001.0)),
997 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
998 day_base_vlm: Some(dec!(100.0)),
999 },
1000 funding: dec!(0.0001),
1001 open_interest: dec!(100000.0),
1002 oracle_px: dec!(50005.0),
1003 premium: Some(dec!(-0.0001)),
1004 },
1005 };
1006
1007 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
1008 assert!(result.is_ok());
1009
1010 let (mark_price, index_price, funding_rate) = result.unwrap();
1011
1012 assert_eq!(mark_price.instrument_id, instrument.id());
1013 assert_eq!(mark_price.value.as_f64(), 50_000.0);
1014
1015 assert!(index_price.is_some());
1016 let index = index_price.unwrap();
1017 assert_eq!(index.instrument_id, instrument.id());
1018 assert_eq!(index.value.as_f64(), 50_005.0);
1019
1020 assert!(funding_rate.is_some());
1021 let funding = funding_rate.unwrap();
1022 assert_eq!(funding.instrument_id, instrument.id());
1023 assert_eq!(funding.rate.to_string(), "0.0001");
1024 assert_eq!(funding.interval, Some(60));
1025 }
1026
1027 #[rstest]
1028 fn test_parse_ws_asset_context_spot() {
1029 let instrument = create_test_instrument();
1030 let ts_init = UnixNanos::default();
1031
1032 let ctx_data = WsActiveAssetCtxData::Spot {
1033 coin: Ustr::from("BTC"),
1034 ctx: SpotAssetCtx {
1035 shared: SharedAssetCtx {
1036 day_ntl_vlm: dec!(1000000.0),
1037 prev_day_px: dec!(49000.0),
1038 mark_px: dec!(50000.0),
1039 mid_px: Some(dec!(50001.0)),
1040 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1041 day_base_vlm: Some(dec!(100.0)),
1042 },
1043 circulating_supply: dec!(19000000.0),
1044 },
1045 };
1046
1047 let result = parse_ws_asset_context(&ctx_data, &instrument, ts_init);
1048 assert!(result.is_ok());
1049
1050 let (mark_price, index_price, funding_rate) = result.unwrap();
1051
1052 assert_eq!(mark_price.instrument_id, instrument.id());
1053 assert_eq!(mark_price.value.as_f64(), 50_000.0);
1054 assert!(index_price.is_none());
1055 assert!(funding_rate.is_none());
1056 }
1057
1058 #[rstest]
1062 #[case::positive_high_precision("0.0001234567890123456")]
1063 #[case::negative_high_precision("-0.0001234567890123456")]
1064 fn test_parse_ws_asset_context_perp_preserves_funding_precision(#[case] funding_str: &str) {
1065 let instrument = create_test_instrument();
1066 let ts_init = UnixNanos::default();
1067
1068 let expected = Decimal::from_str(funding_str).unwrap();
1069
1070 let ctx_data = WsActiveAssetCtxData::Perp {
1071 coin: Ustr::from("BTC"),
1072 ctx: PerpsAssetCtx {
1073 shared: SharedAssetCtx {
1074 day_ntl_vlm: dec!(1000000.0),
1075 prev_day_px: dec!(49000.0),
1076 mark_px: dec!(50000.0),
1077 mid_px: None,
1078 impact_pxs: None,
1079 day_base_vlm: None,
1080 },
1081 funding: Decimal::from_str(funding_str).unwrap(),
1082 open_interest: dec!(100000.0),
1083 oracle_px: dec!(50005.0),
1084 premium: None,
1085 },
1086 };
1087
1088 let (_, _, funding_rate) = parse_ws_asset_context(&ctx_data, &instrument, ts_init).unwrap();
1089
1090 let funding = funding_rate.expect("perp ctx must yield funding rate");
1091 assert_eq!(funding.rate, expected);
1092 }
1093
1094 #[rstest]
1095 fn test_parse_ws_open_interest_perp() {
1096 let instrument = create_test_instrument();
1097 let ts_init = UnixNanos::default();
1098
1099 let open_interest = parse_ws_open_interest(dec!(100000.0), &instrument, ts_init).unwrap();
1100
1101 assert_eq!(open_interest.instrument_id, instrument.id());
1102 assert_eq!(open_interest.open_interest.to_string(), "100000.0");
1103 assert_eq!(open_interest.ts_event, ts_init);
1104 assert_eq!(open_interest.ts_init, ts_init);
1105 }
1106
1107 #[rstest]
1108 #[case::round("100000.0")]
1109 #[case::precise("100000.123456789")]
1110 fn test_parse_ws_open_interest_preserves_precision(#[case] open_interest_str: &str) {
1111 let instrument = create_test_instrument();
1112 let ts_init = UnixNanos::default();
1113
1114 let expected = Decimal::from_str(open_interest_str).unwrap();
1115
1116 let open_interest = parse_ws_open_interest(
1117 Decimal::from_str(open_interest_str).unwrap(),
1118 &instrument,
1119 ts_init,
1120 )
1121 .unwrap();
1122
1123 assert_eq!(open_interest.open_interest, expected);
1124 }
1125}