Skip to main content

nautilus_hyperliquid/websocket/
parse.rs

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