1use 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
38fn 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
52fn 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
67fn parse_millis_to_nanos(millis: u64) -> UnixNanos {
69 UnixNanos::from(millis * 1_000_000)
70}
71
72pub 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 let aggressor = match trade.side.as_str() {
84 "A" => AggressorSide::Seller, "B" => AggressorSide::Buyer, _ => 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
106pub 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 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, );
132
133 let delta = OrderBookDelta::new(
134 instrument.id(),
135 action,
136 order,
137 0, 0, ts_event,
140 ts_init,
141 );
142
143 deltas.push(delta);
144 }
145
146 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, );
163
164 let delta = OrderBookDelta::new(
165 instrument.id(),
166 action,
167 order,
168 0, 0, ts_event,
171 ts_init,
172 );
173
174 deltas.push(delta);
175 }
176
177 Ok(OrderBookDeltas::new(instrument.id(), deltas))
178}
179
180pub 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
212pub fn parse_ws_candle(
214 candle: &CandleData,
215 instrument: &InstrumentAny,
216 bar_type: &BarType,
217 ts_init: UnixNanos,
218) -> anyhow::Result<Bar> {
219 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
250pub 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 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 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 }
281 } else {
282 OrderType::Limit };
284
285 let time_in_force = TimeInForce::Gtc;
287
288 let order_status = hyperliquid_status_to_order_status(&order.status);
290
291 let quantity = parse_quantity(&order.order.sz, instrument, "order.sz")?;
293
294 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 let price = parse_price(&order.order.limit_px, instrument, "order.limitPx")?;
303
304 let ts_accepted = parse_millis_to_nanos(order.order.timestamp);
306 let ts_last = parse_millis_to_nanos(order.status_timestamp);
307
308 let mut report = OrderStatusReport::new(
310 account_id,
311 instrument_id,
312 None, 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 if let Some(ref cloid) = order.order.cloid {
328 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
329 }
330
331 report = report.with_price(price);
333
334 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
343pub 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 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 let last_qty = parse_quantity(&fill.sz, instrument, "fill.sz")?;
366 let last_px = parse_price(&fill.px, instrument, "fill.px")?;
367
368 let liquidity_side = if fill.crossed {
370 LiquiditySide::Taker
371 } else {
372 LiquiditySide::Maker
373 };
374
375 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 let commission_currency = if fill.fee_token == "USDC" {
385 Currency::from("USDC")
386 } else {
387 instrument.quote_currency()
389 };
390
391 let commission = Money::new(commission_amount, commission_currency);
392
393 let ts_event = parse_millis_to_nanos(fill.time);
395
396 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, ts_event,
412 ts_init,
413 None, ))
415}
416
417#[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, 2, 3, Price::from("0.01"),
445 Quantity::from("0.001"),
446 None, None, None, None, None, None, None, None, None, None, None, None, 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}