nautilus_hyperliquid/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing helpers for Hyperliquid WebSocket payloads.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{nanos::UnixNanos, uuid::UUID4};
22use nautilus_model::{
23    data::{Bar, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick},
24    enums::{AggressorSide, BookAction, LiquiditySide, OrderSide, OrderType, TimeInForce},
25    identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
26    instruments::{Instrument, InstrumentAny},
27    reports::{FillReport, OrderStatusReport},
28    types::{Currency, Money, Price, Quantity, price::PriceRaw, quantity::QuantityRaw},
29};
30use rust_decimal::Decimal;
31
32use super::messages::{CandleData, WsBboData, WsBookData, WsFillData, WsOrderData, WsTradeData};
33use crate::common::{
34    enums::hyperliquid_status_to_order_status,
35    parse::{is_conditional_order_data, parse_trigger_order_type},
36};
37
38/// Helper to parse a price string with instrument precision.
39fn parse_price(
40    price_str: &str,
41    instrument: &InstrumentAny,
42    field_name: &str,
43) -> anyhow::Result<Price> {
44    let decimal = Decimal::from_str(price_str)
45        .with_context(|| format!("Failed to parse price from '{price_str}' for {field_name}"))?;
46
47    let raw = decimal.mantissa() as PriceRaw;
48
49    Ok(Price::from_raw(raw, instrument.price_precision()))
50}
51
52/// Helper to parse a quantity string with instrument precision.
53fn parse_quantity(
54    quantity_str: &str,
55    instrument: &InstrumentAny,
56    field_name: &str,
57) -> anyhow::Result<Quantity> {
58    let decimal = Decimal::from_str(quantity_str).with_context(|| {
59        format!("Failed to parse quantity from '{quantity_str}' for {field_name}")
60    })?;
61
62    let raw = decimal.mantissa().unsigned_abs() as QuantityRaw;
63
64    Ok(Quantity::from_raw(raw, instrument.size_precision()))
65}
66
67/// Helper to parse millisecond timestamp to UnixNanos.
68fn parse_millis_to_nanos(millis: u64) -> UnixNanos {
69    UnixNanos::from(millis * 1_000_000)
70}
71
72/// Parses a WebSocket trade frame into a [`TradeTick`].
73pub fn parse_ws_trade_tick(
74    trade: &WsTradeData,
75    instrument: &InstrumentAny,
76    ts_init: UnixNanos,
77) -> anyhow::Result<TradeTick> {
78    let price = parse_price(&trade.px, instrument, "trade.px")?;
79    let size = parse_quantity(&trade.sz, instrument, "trade.sz")?;
80
81    // Determine aggressor side from the 'side' field
82    // In Hyperliquid: "A" = Ask (sell), "B" = Bid (buy)
83    let aggressor = match trade.side.as_str() {
84        "A" => AggressorSide::Seller, // Sell side was aggressor
85        "B" => AggressorSide::Buyer,  // Buy side was aggressor
86        _ => AggressorSide::NoAggressor,
87    };
88
89    let trade_id = TradeId::new_checked(trade.tid.to_string())
90        .context("Invalid trade identifier in Hyperliquid trade message")?;
91
92    let ts_event = parse_millis_to_nanos(trade.time);
93
94    TradeTick::new_checked(
95        instrument.id(),
96        price,
97        size,
98        aggressor,
99        trade_id,
100        ts_event,
101        ts_init,
102    )
103    .context("Failed to construct TradeTick from Hyperliquid trade message")
104}
105
106/// Parses a WebSocket L2 order book message into [`OrderBookDeltas`].
107pub fn parse_ws_order_book_deltas(
108    book: &WsBookData,
109    instrument: &InstrumentAny,
110    ts_init: UnixNanos,
111) -> anyhow::Result<OrderBookDeltas> {
112    let ts_event = parse_millis_to_nanos(book.time);
113    let mut deltas = Vec::new();
114
115    // Parse bids (index 0)
116    for level in &book.levels[0] {
117        let price = parse_price(&level.px, instrument, "book.bid.px")?;
118        let size = parse_quantity(&level.sz, instrument, "book.bid.sz")?;
119
120        let action = if size.raw == 0 {
121            BookAction::Delete
122        } else {
123            BookAction::Update
124        };
125
126        let order = BookOrder::new(
127            nautilus_model::enums::OrderSide::Buy,
128            price,
129            size,
130            0, // order_id not provided in Hyperliquid L2 data
131        );
132
133        let delta = OrderBookDelta::new(
134            instrument.id(),
135            action,
136            order,
137            0, // flags
138            0, // sequence
139            ts_event,
140            ts_init,
141        );
142
143        deltas.push(delta);
144    }
145
146    // Parse asks (index 1)
147    for level in &book.levels[1] {
148        let price = parse_price(&level.px, instrument, "book.ask.px")?;
149        let size = parse_quantity(&level.sz, instrument, "book.ask.sz")?;
150
151        let action = if size.raw == 0 {
152            BookAction::Delete
153        } else {
154            BookAction::Update
155        };
156
157        let order = BookOrder::new(
158            nautilus_model::enums::OrderSide::Sell,
159            price,
160            size,
161            0, // order_id not provided in Hyperliquid L2 data
162        );
163
164        let delta = OrderBookDelta::new(
165            instrument.id(),
166            action,
167            order,
168            0, // flags
169            0, // sequence
170            ts_event,
171            ts_init,
172        );
173
174        deltas.push(delta);
175    }
176
177    Ok(OrderBookDeltas::new(instrument.id(), deltas))
178}
179
180/// Parses a WebSocket BBO (best bid/offer) message into a [`QuoteTick`].
181pub fn parse_ws_quote_tick(
182    bbo: &WsBboData,
183    instrument: &InstrumentAny,
184    ts_init: UnixNanos,
185) -> anyhow::Result<QuoteTick> {
186    let bid_level = bbo.bbo[0]
187        .as_ref()
188        .context("BBO message missing bid level")?;
189    let ask_level = bbo.bbo[1]
190        .as_ref()
191        .context("BBO message missing ask level")?;
192
193    let bid_price = parse_price(&bid_level.px, instrument, "bbo.bid.px")?;
194    let ask_price = parse_price(&ask_level.px, instrument, "bbo.ask.px")?;
195    let bid_size = parse_quantity(&bid_level.sz, instrument, "bbo.bid.sz")?;
196    let ask_size = parse_quantity(&ask_level.sz, instrument, "bbo.ask.sz")?;
197
198    let ts_event = parse_millis_to_nanos(bbo.time);
199
200    QuoteTick::new_checked(
201        instrument.id(),
202        bid_price,
203        ask_price,
204        bid_size,
205        ask_size,
206        ts_event,
207        ts_init,
208    )
209    .context("Failed to construct QuoteTick from Hyperliquid BBO message")
210}
211
212/// Parses a WebSocket candle message into a [`Bar`].
213pub fn parse_ws_candle(
214    candle: &CandleData,
215    instrument: &InstrumentAny,
216    bar_type: &BarType,
217    ts_init: UnixNanos,
218) -> anyhow::Result<Bar> {
219    // Get precision from the instrument
220    let price_precision = instrument.price_precision();
221    let size_precision = instrument.size_precision();
222
223    let open_decimal = Decimal::from_str(&candle.o).context("Failed to parse open price")?;
224    let open_raw = open_decimal.mantissa() as PriceRaw;
225    let open = Price::from_raw(open_raw, price_precision);
226
227    let high_decimal = Decimal::from_str(&candle.h).context("Failed to parse high price")?;
228    let high_raw = high_decimal.mantissa() as PriceRaw;
229    let high = Price::from_raw(high_raw, price_precision);
230
231    let low_decimal = Decimal::from_str(&candle.l).context("Failed to parse low price")?;
232    let low_raw = low_decimal.mantissa() as PriceRaw;
233    let low = Price::from_raw(low_raw, price_precision);
234
235    let close_decimal = Decimal::from_str(&candle.c).context("Failed to parse close price")?;
236    let close_raw = close_decimal.mantissa() as PriceRaw;
237    let close = Price::from_raw(close_raw, price_precision);
238
239    let volume_decimal = Decimal::from_str(&candle.v).context("Failed to parse volume")?;
240    let volume_raw = volume_decimal.mantissa().unsigned_abs() as QuantityRaw;
241    let volume = Quantity::from_raw(volume_raw, size_precision);
242
243    let ts_event = parse_millis_to_nanos(candle.t);
244
245    Ok(Bar::new(
246        *bar_type, open, high, low, close, volume, ts_event, ts_init,
247    ))
248}
249
250/// Parses a WebSocket order update message into an [`OrderStatusReport`].
251///
252/// This converts Hyperliquid order data from WebSocket into Nautilus order status reports.
253/// Handles both regular and conditional orders (stop/limit-if-touched).
254pub fn parse_ws_order_status_report(
255    order: &WsOrderData,
256    instrument: &InstrumentAny,
257    account_id: AccountId,
258    ts_init: UnixNanos,
259) -> anyhow::Result<OrderStatusReport> {
260    let instrument_id = instrument.id();
261    let venue_order_id = VenueOrderId::new(order.order.oid.to_string());
262
263    // Parse order side
264    let order_side: OrderSide = match order.order.side.as_str() {
265        "B" => OrderSide::Buy,
266        "A" => OrderSide::Sell,
267        _ => anyhow::bail!("Unknown order side: {}", order.order.side),
268    };
269
270    // Determine order type based on trigger info
271    let order_type = if is_conditional_order_data(
272        order.order.trigger_px.as_deref(),
273        order.order.tpsl.as_deref(),
274    ) {
275        if let (Some(is_market), Some(tpsl)) = (order.order.is_market, order.order.tpsl.as_deref())
276        {
277            parse_trigger_order_type(is_market, tpsl)
278        } else {
279            OrderType::Limit // fallback
280        }
281    } else {
282        OrderType::Limit // Regular limit order
283    };
284
285    // Parse time in force (assuming GTC for now, could be derived from order data)
286    let time_in_force = TimeInForce::Gtc;
287
288    // Parse order status
289    let order_status = hyperliquid_status_to_order_status(&order.status);
290
291    // Parse quantity
292    let quantity = parse_quantity(&order.order.sz, instrument, "order.sz")?;
293
294    // Calculate filled quantity (orig_sz - sz)
295    let orig_qty = parse_quantity(&order.order.orig_sz, instrument, "order.orig_sz")?;
296    let filled_qty = Quantity::from_raw(
297        orig_qty.raw.saturating_sub(quantity.raw),
298        instrument.size_precision(),
299    );
300
301    // Parse price
302    let price = parse_price(&order.order.limit_px, instrument, "order.limitPx")?;
303
304    // Parse timestamps
305    let ts_accepted = parse_millis_to_nanos(order.order.timestamp);
306    let ts_last = parse_millis_to_nanos(order.status_timestamp);
307
308    // Build the report
309    let mut report = OrderStatusReport::new(
310        account_id,
311        instrument_id,
312        None, // venue_order_id_modified
313        venue_order_id,
314        order_side,
315        order_type,
316        time_in_force,
317        order_status,
318        quantity,
319        filled_qty,
320        ts_accepted,
321        ts_last,
322        ts_init,
323        Some(UUID4::new()),
324    );
325
326    // Add client order ID if present
327    if let Some(ref cloid) = order.order.cloid {
328        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
329    }
330
331    // Add price
332    report = report.with_price(price);
333
334    // Add trigger price for conditional orders
335    if let Some(ref trigger_px_str) = order.order.trigger_px {
336        let trigger_price = parse_price(trigger_px_str, instrument, "order.triggerPx")?;
337        report = report.with_trigger_price(trigger_price);
338    }
339
340    Ok(report)
341}
342
343/// Parses a WebSocket fill message into a [`FillReport`].
344///
345/// This converts Hyperliquid fill data from WebSocket user events into Nautilus fill reports.
346pub fn parse_ws_fill_report(
347    fill: &WsFillData,
348    instrument: &InstrumentAny,
349    account_id: AccountId,
350    ts_init: UnixNanos,
351) -> anyhow::Result<FillReport> {
352    let instrument_id = instrument.id();
353    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
354    let trade_id = TradeId::new_checked(fill.tid.to_string())
355        .context("Invalid trade identifier in Hyperliquid fill message")?;
356
357    // Parse order side
358    let order_side: OrderSide = match fill.side.as_str() {
359        "B" => OrderSide::Buy,
360        "A" => OrderSide::Sell,
361        _ => anyhow::bail!("Unknown fill side: {}", fill.side),
362    };
363
364    // Parse quantities and prices
365    let last_qty = parse_quantity(&fill.sz, instrument, "fill.sz")?;
366    let last_px = parse_price(&fill.px, instrument, "fill.px")?;
367
368    // Parse liquidity side
369    let liquidity_side = if fill.crossed {
370        LiquiditySide::Taker
371    } else {
372        LiquiditySide::Maker
373    };
374
375    // Parse commission
376    let commission_amount = Decimal::from_str(&fill.fee)
377        .with_context(|| format!("Failed to parse fee='{}' as decimal", fill.fee))?
378        .abs()
379        .to_string()
380        .parse::<f64>()
381        .unwrap_or(0.0);
382
383    // Determine commission currency from fee_token
384    let commission_currency = if fill.fee_token == "USDC" {
385        Currency::from("USDC")
386    } else {
387        // Default to quote currency if fee_token is something else
388        instrument.quote_currency()
389    };
390
391    let commission = Money::new(commission_amount, commission_currency);
392
393    // Parse timestamp
394    let ts_event = parse_millis_to_nanos(fill.time);
395
396    // No client order ID available in fill data directly
397    let client_order_id = None;
398
399    Ok(FillReport::new(
400        account_id,
401        instrument_id,
402        venue_order_id,
403        trade_id,
404        order_side,
405        last_qty,
406        last_px,
407        commission,
408        liquidity_side,
409        client_order_id,
410        None, // venue_position_id
411        ts_event,
412        ts_init,
413        None, // report_id
414    ))
415}
416
417////////////////////////////////////////////////////////////////////////////////
418// Tests
419////////////////////////////////////////////////////////////////////////////////
420
421#[cfg(test)]
422mod tests {
423    use nautilus_model::{
424        identifiers::{InstrumentId, Symbol, Venue},
425        instruments::CryptoPerpetual,
426        types::currency::Currency,
427    };
428    use ustr::Ustr;
429
430    use super::*;
431
432    fn create_test_instrument() -> InstrumentAny {
433        let instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), Venue::new("HYPERLIQUID"));
434
435        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
436            instrument_id,
437            Symbol::new("BTC-PERP"),
438            Currency::from("BTC"),
439            Currency::from("USDC"),
440            Currency::from("USDC"),
441            false, // is_inverse
442            2,     // price_precision
443            3,     // size_precision
444            Price::from("0.01"),
445            Quantity::from("0.001"),
446            None, // multiplier
447            None, // lot_size
448            None, // max_quantity
449            None, // min_quantity
450            None, // max_notional
451            None, // min_notional
452            None, // max_price
453            None, // min_price
454            None, // margin_init
455            None, // margin_maint
456            None, // maker_fee
457            None, // taker_fee
458            UnixNanos::default(),
459            UnixNanos::default(),
460        ))
461    }
462
463    #[test]
464    fn test_parse_ws_order_status_report_basic() {
465        let instrument = create_test_instrument();
466        let account_id = AccountId::new("HYPERLIQUID-001");
467        let ts_init = UnixNanos::default();
468
469        let order_data = WsOrderData {
470            order: super::super::messages::WsBasicOrderData {
471                coin: Ustr::from("BTC"),
472                side: "B".to_string(),
473                limit_px: "50000.0".to_string(),
474                sz: "0.5".to_string(),
475                oid: 12345,
476                timestamp: 1704470400000,
477                orig_sz: "1.0".to_string(),
478                cloid: Some("test-order-1".to_string()),
479                trigger_px: None,
480                is_market: None,
481                tpsl: None,
482                trigger_activated: None,
483                trailing_stop: None,
484            },
485            status: "open".to_string(),
486            status_timestamp: 1704470400000,
487        };
488
489        let result = parse_ws_order_status_report(&order_data, &instrument, account_id, ts_init);
490        assert!(result.is_ok());
491
492        let report = result.unwrap();
493        assert_eq!(report.order_side, OrderSide::Buy);
494        assert_eq!(report.order_type, OrderType::Limit);
495        assert_eq!(
496            report.order_status,
497            nautilus_model::enums::OrderStatus::Accepted
498        );
499    }
500
501    #[test]
502    fn test_parse_ws_fill_report_basic() {
503        let instrument = create_test_instrument();
504        let account_id = AccountId::new("HYPERLIQUID-001");
505        let ts_init = UnixNanos::default();
506
507        let fill_data = super::super::messages::WsFillData {
508            coin: Ustr::from("BTC"),
509            px: "50000.0".to_string(),
510            sz: "0.1".to_string(),
511            side: "B".to_string(),
512            time: 1704470400000,
513            start_position: "0.0".to_string(),
514            dir: "Open Long".to_string(),
515            closed_pnl: "0.0".to_string(),
516            hash: "0xabc123".to_string(),
517            oid: 12345,
518            crossed: true,
519            fee: "0.05".to_string(),
520            tid: 98765,
521            liquidation: None,
522            fee_token: "USDC".to_string(),
523            builder_fee: None,
524        };
525
526        let result = parse_ws_fill_report(&fill_data, &instrument, account_id, ts_init);
527        assert!(result.is_ok());
528
529        let report = result.unwrap();
530        assert_eq!(report.order_side, OrderSide::Buy);
531        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
532    }
533}