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, 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
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 trade frame into a complete public Hyperliquid trade.
95pub 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
118/// Parses a WebSocket L2 order book message into [`OrderBookDeltas`].
119pub 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    // Treat every book payload as a snapshot: clear existing depth and rebuild it
130    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, // sequence
148            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, // sequence
171            ts_event,
172            ts_init,
173        );
174
175        deltas.push(delta);
176    }
177
178    Ok(OrderBookDeltas::new(instrument.id(), deltas))
179}
180
181/// Parses a WebSocket L2 order book snapshot into [`OrderBookDepth10`].
182///
183/// Hyperliquid's `l2Book` subscription emits snapshots of bid/ask levels.
184/// Fills any missing levels past the venue-provided depth with zero-size
185/// placeholder orders so the fixed-size `[BookOrder; 10]` arrays are
186/// always fully populated.
187pub 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
249/// Parses a WebSocket BBO (best bid/offer) message into a [`QuoteTick`].
250pub 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
281/// Parses a WebSocket candle message into a [`Bar`].
282pub 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
301/// Parses a WebSocket order update message into an [`OrderStatusReport`].
302///
303/// This converts Hyperliquid order data from WebSocket into Nautilus order status reports.
304/// Handles both regular and conditional orders (stop/limit-if-touched).
305pub 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    // Determine order type based on trigger info
316    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 // fallback
323        }
324    } else {
325        OrderType::Limit // Regular limit order
326    };
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    // orig_sz is the original order quantity, sz is the remaining quantity
335    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_modified
351        venue_order_id,
352        order_side,
353        order_type,
354        time_in_force,
355        order_status,
356        orig_qty, // Use original quantity, not remaining
357        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
390/// Parses a WebSocket fill message into a [`FillReport`].
391///
392/// This converts Hyperliquid fill data from WebSocket user events into Nautilus fill reports.
393pub 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    // No client order ID available in fill data directly
451    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, // venue_position_id
465        ts_event,
466        ts_init,
467        None, // report_id
468    ))
469}
470
471/// Parses a WebSocket ActiveAssetCtx message into mark price, index price, and funding rate updates.
472///
473/// This converts Hyperliquid asset context data into Nautilus price and funding rate updates.
474/// Returns a tuple of (`MarkPriceUpdate`, `Option<IndexPriceUpdate>`, `Option<FundingRateUpdate>`).
475/// Index price and funding rate are only present for perpetual contracts.
476pub 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), // Hyperliquid exchanges funding hourly
501                None,     // Hyperliquid doesn't provide next funding time in this message
502                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
522/// Parses an `activeAssetCtx` open interest string into an open interest custom data update.
523///
524/// The caller is responsible for restricting this to the perpetual branch; spot
525/// `activeAssetCtx` payloads carry no open interest field.
526pub 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, // is_inverse
578            2,     // price_precision
579            3,     // size_precision
580            Price::from("0.01"),
581            Quantity::from("0.001"),
582            None, // multiplier
583            None, // lot_size
584            None, // max_quantity
585            None, // min_quantity
586            None, // max_notional
587            None, // min_notional
588            None, // max_price
589            None, // min_price
590            None, // margin_init
591            None, // margin_maint
592            None, // maker_fee
593            None, // taker_fee
594            None, // tick_scheme
595            None, // info
596            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        // The fill is still emitted through the standard path; the liquidation
763        // metadata is logged for observability rather than encoded on the report.
764        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        // Zero-fee outcome fills fall back to the instrument's quote currency
820        // (USDH) instead of the unregistered side token, keeping downstream
821        // OrderFilled events and persistence on a registered currency.
822        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); // clear + bid + ask
852        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        // 3 bids, 2 asks - Depth10 must pad the remaining 7/8 slots with zero orders
873        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        // Padded bid slots
922        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        // Snapshot flag set
941        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        // Only first 10 on each side retained
972        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    /// Pins the direct `Decimal::from_str` path for the funding rate. An f64
1059    /// round-trip (the prior implementation) cannot represent these values
1060    /// exactly, so the parsed Decimal would diverge from the input string.
1061    #[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}