1use anyhow::Context;
17use nautilus_core::{UUID4, UnixNanos, time::get_atomic_clock_realtime};
18use nautilus_model::{
19 enums::{
20 CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified,
21 TimeInForce, TriggerType,
22 },
23 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
24 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{Currency, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::models::{AssetPosition, HyperliquidFill, PerpMeta, SpotMeta};
33use crate::{
34 common::{
35 consts::HYPERLIQUID_VENUE,
36 enums::{
37 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
38 },
39 parse::make_fill_trade_id,
40 },
41 websocket::messages::{WsBasicOrderData, WsOrderData},
42};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum HyperliquidMarketType {
47 Perp,
49 Spot,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct HyperliquidInstrumentDef {
59 pub symbol: Ustr,
61 pub raw_symbol: Ustr,
65 pub base: Ustr,
67 pub quote: Ustr,
69 pub market_type: HyperliquidMarketType,
71 pub asset_index: u32,
75 pub price_decimals: u32,
77 pub size_decimals: u32,
79 pub tick_size: Decimal,
81 pub lot_size: Decimal,
83 pub max_leverage: Option<u32>,
85 pub only_isolated: bool,
87 pub active: bool,
89 pub raw_data: String,
91}
92
93pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
104 const PERP_MAX_DECIMALS: i32 = 6; let mut defs = Vec::new();
107
108 for (index, asset) in meta.universe.iter().enumerate() {
109 let is_delisted = asset.is_delisted.unwrap_or(false);
112
113 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
114 let tick_size = pow10_neg(price_decimals)?;
115 let lot_size = pow10_neg(asset.sz_decimals)?;
116
117 let symbol = format!("{}-USD-PERP", asset.name);
118
119 let raw_symbol: Ustr = asset.name.as_str().into();
121
122 let def = HyperliquidInstrumentDef {
123 symbol: symbol.into(),
124 raw_symbol,
125 base: asset.name.clone().into(),
126 quote: "USD".into(), market_type: HyperliquidMarketType::Perp,
128 asset_index: index as u32,
129 price_decimals,
130 size_decimals: asset.sz_decimals,
131 tick_size,
132 lot_size,
133 max_leverage: asset.max_leverage,
134 only_isolated: asset.only_isolated.unwrap_or(false),
135 active: !is_delisted,
136 raw_data: serde_json::to_string(asset).unwrap_or_default(),
137 };
138
139 defs.push(def);
140 }
141
142 Ok(defs)
143}
144
145pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
153 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
157
158 let mut tokens_by_index = ahash::AHashMap::new();
160 for token in &meta.tokens {
161 tokens_by_index.insert(token.index, token);
162 }
163
164 for pair in &meta.universe {
165 let base_token = tokens_by_index
169 .get(&pair.tokens[0])
170 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
171 let quote_token = tokens_by_index
172 .get(&pair.tokens[1])
173 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
174
175 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
176 let tick_size = pow10_neg(price_decimals)?;
177 let lot_size = pow10_neg(base_token.sz_decimals)?;
178
179 let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
180
181 let raw_symbol: Ustr = if base_token.name == "PURR" {
185 pair.name.as_str().into()
186 } else {
187 format!("@{}", pair.index).into()
188 };
189
190 let def = HyperliquidInstrumentDef {
191 symbol: symbol.into(),
192 raw_symbol,
193 base: base_token.name.clone().into(),
194 quote: quote_token.name.clone().into(),
195 market_type: HyperliquidMarketType::Spot,
196 asset_index: SPOT_INDEX_OFFSET + pair.index,
197 price_decimals,
198 size_decimals: base_token.sz_decimals,
199 tick_size,
200 lot_size,
201 max_leverage: None,
202 only_isolated: false,
203 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
205 };
206
207 defs.push(def);
208 }
209
210 Ok(defs)
211}
212
213fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
214 if decimals == 0 {
215 return Ok(Decimal::ONE);
216 }
217
218 Ok(Decimal::from_i128_with_scale(1, decimals))
220}
221
222pub fn get_currency(code: &str) -> Currency {
223 Currency::try_from_str(code).unwrap_or_else(|| {
224 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
225 if let Err(e) = Currency::register(currency, false) {
226 log::error!("Failed to register currency '{code}': {e}");
227 }
228 currency
229 })
230}
231
232#[must_use]
236pub fn create_instrument_from_def(
237 def: &HyperliquidInstrumentDef,
238 ts_init: UnixNanos,
239) -> Option<InstrumentAny> {
240 let symbol = Symbol::new(def.symbol);
241 let venue = *HYPERLIQUID_VENUE;
242 let instrument_id = InstrumentId::new(symbol, venue);
243
244 let raw_symbol = Symbol::new(def.raw_symbol);
249 let base_currency = get_currency(&def.base);
250 let quote_currency = get_currency(&def.quote);
251 let price_increment = Price::from(def.tick_size.to_string());
252 let size_increment = Quantity::from(def.lot_size.to_string());
253
254 match def.market_type {
255 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
256 instrument_id,
257 raw_symbol,
258 base_currency,
259 quote_currency,
260 def.price_decimals as u8,
261 def.size_decimals as u8,
262 price_increment,
263 size_increment,
264 None,
265 None,
266 None,
267 None,
268 None,
269 None,
270 None,
271 None,
272 None,
273 None,
274 None,
275 None,
276 None,
277 ts_init, ts_init,
279 ))),
280 HyperliquidMarketType::Perp => {
281 let settlement_currency = get_currency("USDC");
282
283 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
284 instrument_id,
285 raw_symbol,
286 base_currency,
287 quote_currency,
288 settlement_currency,
289 false,
290 def.price_decimals as u8,
291 def.size_decimals as u8,
292 price_increment,
293 size_increment,
294 None, None,
296 None,
297 None,
298 None,
299 None,
300 None,
301 None,
302 None,
303 None,
304 None,
305 None,
306 None,
307 ts_init, ts_init,
309 )))
310 }
311 }
312}
313
314#[must_use]
317pub fn instruments_from_defs(
318 defs: &[HyperliquidInstrumentDef],
319 ts_init: UnixNanos,
320) -> Vec<InstrumentAny> {
321 defs.iter()
322 .filter_map(|def| create_instrument_from_def(def, ts_init))
323 .collect()
324}
325
326#[must_use]
328pub fn instruments_from_defs_owned(defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
329 let clock = get_atomic_clock_realtime();
330 let ts_init = clock.get_time_ns();
331
332 defs.into_iter()
333 .filter_map(|def| create_instrument_from_def(&def, ts_init))
334 .collect()
335}
336
337fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
338 match side {
339 HyperliquidSide::Buy => OrderSide::Buy,
340 HyperliquidSide::Sell => OrderSide::Sell,
341 }
342}
343
344pub fn parse_order_status_report_from_ws(
350 order_data: &WsOrderData,
351 instrument: &dyn Instrument,
352 account_id: AccountId,
353 ts_init: UnixNanos,
354) -> anyhow::Result<OrderStatusReport> {
355 parse_order_status_report_from_basic(
356 &order_data.order,
357 &order_data.status,
358 instrument,
359 account_id,
360 ts_init,
361 )
362}
363
364pub fn parse_order_status_report_from_basic(
370 order: &WsBasicOrderData,
371 status: &HyperliquidOrderStatusEnum,
372 instrument: &dyn Instrument,
373 account_id: AccountId,
374 ts_init: UnixNanos,
375) -> anyhow::Result<OrderStatusReport> {
376 let instrument_id = instrument.id();
377 let venue_order_id = VenueOrderId::new(order.oid.to_string());
378 let order_side = OrderSide::from(order.side);
379
380 let order_type = if order.trigger_px.is_some() {
382 if order.is_market == Some(true) {
383 match order.tpsl.as_ref() {
385 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
386 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
387 _ => OrderType::StopMarket,
388 }
389 } else {
390 match order.tpsl.as_ref() {
391 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
392 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
393 _ => OrderType::StopLimit,
394 }
395 }
396 } else {
397 OrderType::Limit
398 };
399
400 let time_in_force = TimeInForce::Gtc;
401 let order_status = OrderStatus::from(*status);
402
403 let price_precision = instrument.price_precision();
404 let size_precision = instrument.size_precision();
405
406 let orig_sz: Decimal = order
407 .orig_sz
408 .parse()
409 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
410 let current_sz: Decimal = order
411 .sz
412 .parse()
413 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
414
415 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
416 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
417 let filled_sz = orig_sz.abs() - current_sz.abs();
418 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
419 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
420
421 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
422 let ts_last = ts_accepted;
423 let report_id = UUID4::new();
424
425 let mut report = OrderStatusReport::new(
426 account_id,
427 instrument_id,
428 None, venue_order_id,
430 order_side,
431 order_type,
432 time_in_force,
433 order_status,
434 quantity,
435 filled_qty,
436 ts_accepted,
437 ts_last,
438 ts_init,
439 Some(report_id),
440 );
441
442 if let Some(cloid) = &order.cloid {
444 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
445 }
446
447 if !matches!(
451 order_status,
452 OrderStatus::Filled | OrderStatus::PartiallyFilled
453 ) {
454 let limit_px: Decimal = order
455 .limit_px
456 .parse()
457 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
458 let price = Price::from_decimal_dp(limit_px, price_precision)
459 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
460 report = report.with_price(price);
461 }
462
463 if let Some(trigger_px) = &order.trigger_px {
465 let trig_px: Decimal = trigger_px
466 .parse()
467 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
468 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
469 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
470 report = report
471 .with_trigger_price(trigger_price)
472 .with_trigger_type(TriggerType::Default);
473 }
474
475 Ok(report)
476}
477
478pub fn parse_fill_report(
484 fill: &HyperliquidFill,
485 instrument: &dyn Instrument,
486 account_id: AccountId,
487 ts_init: UnixNanos,
488) -> anyhow::Result<FillReport> {
489 let instrument_id = instrument.id();
490 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
491
492 let trade_id = make_fill_trade_id(
493 &fill.hash,
494 fill.oid,
495 &fill.px,
496 &fill.sz,
497 fill.time,
498 &fill.start_position,
499 );
500 let order_side = parse_fill_side(&fill.side);
501
502 let price_precision = instrument.price_precision();
503 let size_precision = instrument.size_precision();
504
505 let px: Decimal = fill
506 .px
507 .parse()
508 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
509 let sz: Decimal = fill
510 .sz
511 .parse()
512 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
513
514 let last_px = Price::from_decimal_dp(px, price_precision)
515 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
516 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
517 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
518
519 let fee_amount: Decimal = fill
520 .fee
521 .parse()
522 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
523
524 let fee_currency: Currency = fill
525 .fee_token
526 .parse()
527 .map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
528 let commission = Money::from_decimal(fee_amount, fee_currency)
529 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
530
531 let liquidity_side = if fill.crossed {
533 LiquiditySide::Taker
534 } else {
535 LiquiditySide::Maker
536 };
537
538 let ts_event = UnixNanos::from(fill.time * 1_000_000);
539 let report_id = UUID4::new();
540
541 let report = FillReport::new(
542 account_id,
543 instrument_id,
544 venue_order_id,
545 trade_id,
546 order_side,
547 last_qty,
548 last_px,
549 commission,
550 liquidity_side,
551 None, None, ts_event,
554 ts_init,
555 Some(report_id),
556 );
557
558 Ok(report)
559}
560
561pub fn parse_position_status_report(
567 position_data: &serde_json::Value,
568 instrument: &dyn Instrument,
569 account_id: AccountId,
570 ts_init: UnixNanos,
571) -> anyhow::Result<PositionStatusReport> {
572 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
574 .context("failed to deserialize AssetPosition")?;
575
576 let position = &asset_position.position;
577 let instrument_id = instrument.id();
578
579 let (position_side, quantity_value) = if position.szi.is_zero() {
581 (PositionSideSpecified::Flat, Decimal::ZERO)
582 } else if position.szi.is_sign_positive() {
583 (PositionSideSpecified::Long, position.szi)
584 } else {
585 (PositionSideSpecified::Short, position.szi.abs())
586 };
587
588 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
589 .context("failed to create quantity from decimal")?;
590 let report_id = UUID4::new();
591 let ts_last = ts_init;
592 let avg_px_open = position.entry_px;
593
594 Ok(PositionStatusReport::new(
596 account_id,
597 instrument_id,
598 position_side,
599 quantity,
600 ts_last,
601 ts_init,
602 Some(report_id),
603 None, avg_px_open,
605 ))
606}
607
608#[cfg(test)]
609mod tests {
610 use rstest::rstest;
611 use rust_decimal_macros::dec;
612
613 use super::{
614 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
615 *,
616 };
617
618 #[rstest]
619 fn test_parse_fill_side() {
620 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
621 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
622 }
623
624 #[rstest]
625 fn test_pow10_neg() {
626 assert_eq!(pow10_neg(0).unwrap(), dec!(1));
627 assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
628 assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
629 }
630
631 #[rstest]
632 fn test_parse_perp_instruments() {
633 let meta = PerpMeta {
634 universe: vec![
635 PerpAsset {
636 name: "BTC".to_string(),
637 sz_decimals: 5,
638 max_leverage: Some(50),
639 only_isolated: None,
640 is_delisted: None,
641 },
642 PerpAsset {
643 name: "DELIST".to_string(),
644 sz_decimals: 3,
645 max_leverage: Some(10),
646 only_isolated: Some(true),
647 is_delisted: Some(true), },
649 ],
650 margin_tables: vec![],
651 };
652
653 let defs = parse_perp_instruments(&meta).unwrap();
654
655 assert_eq!(defs.len(), 2);
657
658 let btc = &defs[0];
659 assert_eq!(btc.symbol, "BTC-USD-PERP");
660 assert_eq!(btc.base, "BTC");
661 assert_eq!(btc.quote, "USD");
662 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
663 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
665 assert_eq!(btc.tick_size, dec!(0.1));
666 assert_eq!(btc.lot_size, dec!(0.00001));
667 assert_eq!(btc.max_leverage, Some(50));
668 assert!(!btc.only_isolated);
669 assert!(btc.active);
670
671 let delist = &defs[1];
672 assert_eq!(delist.symbol, "DELIST-USD-PERP");
673 assert_eq!(delist.base, "DELIST");
674 assert!(!delist.active); }
676
677 fn load_test_data<T>(filename: &str) -> T
678 where
679 T: serde::de::DeserializeOwned,
680 {
681 let path = format!("test_data/{filename}");
682 let content = std::fs::read_to_string(path).expect("Failed to read test data");
683 serde_json::from_str(&content).expect("Failed to parse test data")
684 }
685
686 #[rstest]
687 fn test_parse_perp_instruments_from_real_data() {
688 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
689
690 let defs = parse_perp_instruments(&meta).unwrap();
691
692 assert_eq!(defs.len(), 3);
694
695 let btc = &defs[0];
697 assert_eq!(btc.symbol, "BTC-USD-PERP");
698 assert_eq!(btc.base, "BTC");
699 assert_eq!(btc.quote, "USD");
700 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
701 assert_eq!(btc.size_decimals, 5);
702 assert_eq!(btc.max_leverage, Some(40));
703 assert!(btc.active);
704
705 let eth = &defs[1];
707 assert_eq!(eth.symbol, "ETH-USD-PERP");
708 assert_eq!(eth.base, "ETH");
709 assert_eq!(eth.size_decimals, 4);
710 assert_eq!(eth.max_leverage, Some(25));
711
712 let atom = &defs[2];
714 assert_eq!(atom.symbol, "ATOM-USD-PERP");
715 assert_eq!(atom.base, "ATOM");
716 assert_eq!(atom.size_decimals, 2);
717 assert_eq!(atom.max_leverage, Some(5));
718 }
719
720 #[rstest]
721 fn test_deserialize_l2_book_from_real_data() {
722 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
723
724 assert_eq!(book.coin, "BTC");
726 assert_eq!(book.levels.len(), 2); assert_eq!(book.levels[0].len(), 5); assert_eq!(book.levels[1].len(), 5); let bids = &book.levels[0];
732 let asks = &book.levels[1];
733
734 for i in 1..bids.len() {
736 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
737 let curr_price = bids[i].px.parse::<f64>().unwrap();
738 assert!(prev_price >= curr_price, "Bids should be descending");
739 }
740
741 for i in 1..asks.len() {
743 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
744 let curr_price = asks[i].px.parse::<f64>().unwrap();
745 assert!(prev_price <= curr_price, "Asks should be ascending");
746 }
747 }
748
749 #[rstest]
750 fn test_parse_spot_instruments() {
751 let tokens = vec![
752 SpotToken {
753 name: "USDC".to_string(),
754 sz_decimals: 6,
755 wei_decimals: 6,
756 index: 0,
757 token_id: "0x1".to_string(),
758 is_canonical: true,
759 evm_contract: None,
760 full_name: None,
761 deployer_trading_fee_share: None,
762 },
763 SpotToken {
764 name: "PURR".to_string(),
765 sz_decimals: 0,
766 wei_decimals: 5,
767 index: 1,
768 token_id: "0x2".to_string(),
769 is_canonical: true,
770 evm_contract: None,
771 full_name: None,
772 deployer_trading_fee_share: None,
773 },
774 ];
775
776 let pairs = vec![
777 SpotPair {
778 name: "PURR/USDC".to_string(),
779 tokens: [1, 0], index: 0,
781 is_canonical: true,
782 },
783 SpotPair {
784 name: "ALIAS".to_string(),
785 tokens: [1, 0],
786 index: 1,
787 is_canonical: false, },
789 ];
790
791 let meta = SpotMeta {
792 tokens,
793 universe: pairs,
794 };
795
796 let defs = parse_spot_instruments(&meta).unwrap();
797
798 assert_eq!(defs.len(), 2);
800
801 let purr_usdc = &defs[0];
802 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
803 assert_eq!(purr_usdc.base, "PURR");
804 assert_eq!(purr_usdc.quote, "USDC");
805 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
806 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
808 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
809 assert_eq!(purr_usdc.lot_size, dec!(1));
810 assert_eq!(purr_usdc.max_leverage, None);
811 assert!(!purr_usdc.only_isolated);
812 assert!(purr_usdc.active);
813
814 let alias = &defs[1];
815 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
816 assert_eq!(alias.base, "PURR");
817 assert!(!alias.active); }
819
820 #[rstest]
821 fn test_price_decimals_clamping() {
822 let meta = PerpMeta {
824 universe: vec![PerpAsset {
825 name: "HIGHPREC".to_string(),
826 sz_decimals: 10, max_leverage: Some(1),
828 only_isolated: None,
829 is_delisted: None,
830 }],
831 margin_tables: vec![],
832 };
833
834 let defs = parse_perp_instruments(&meta).unwrap();
835 assert_eq!(defs[0].price_decimals, 0);
836 assert_eq!(defs[0].tick_size, dec!(1));
837 }
838}