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