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