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, TradeId, VenueOrderId},
24 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{Currency, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::models::{HyperliquidFill, PerpMeta, SpotMeta};
33use crate::{
34 common::{
35 consts::HYPERLIQUID_VENUE,
36 enums::{
37 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
38 },
39 },
40 websocket::messages::{WsBasicOrderData, WsOrderData},
41};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum HyperliquidMarketType {
46 Perp,
48 Spot,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct HyperliquidInstrumentDef {
58 pub symbol: Ustr,
60 pub raw_symbol: Ustr,
64 pub base: Ustr,
66 pub quote: Ustr,
68 pub market_type: HyperliquidMarketType,
70 pub asset_index: u32,
74 pub price_decimals: u32,
76 pub size_decimals: u32,
78 pub tick_size: Decimal,
80 pub lot_size: Decimal,
82 pub max_leverage: Option<u32>,
84 pub only_isolated: bool,
86 pub active: bool,
88 pub raw_data: String,
90}
91
92pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
103 const PERP_MAX_DECIMALS: i32 = 6; let mut defs = Vec::new();
106
107 for (index, asset) in meta.universe.iter().enumerate() {
108 let is_delisted = asset.is_delisted.unwrap_or(false);
111
112 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
113 let tick_size = pow10_neg(price_decimals)?;
114 let lot_size = pow10_neg(asset.sz_decimals)?;
115
116 let symbol = format!("{}-USD-PERP", asset.name);
117
118 let raw_symbol: Ustr = asset.name.as_str().into();
120
121 let def = HyperliquidInstrumentDef {
122 symbol: symbol.into(),
123 raw_symbol,
124 base: asset.name.clone().into(),
125 quote: "USD".into(), market_type: HyperliquidMarketType::Perp,
127 asset_index: index as u32,
128 price_decimals,
129 size_decimals: asset.sz_decimals,
130 tick_size,
131 lot_size,
132 max_leverage: asset.max_leverage,
133 only_isolated: asset.only_isolated.unwrap_or(false),
134 active: !is_delisted,
135 raw_data: serde_json::to_string(asset).unwrap_or_default(),
136 };
137
138 defs.push(def);
139 }
140
141 Ok(defs)
142}
143
144pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
152 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
156
157 let mut tokens_by_index = ahash::AHashMap::new();
159 for token in &meta.tokens {
160 tokens_by_index.insert(token.index, token);
161 }
162
163 for pair in &meta.universe {
164 let base_token = tokens_by_index
168 .get(&pair.tokens[0])
169 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
170 let quote_token = tokens_by_index
171 .get(&pair.tokens[1])
172 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
173
174 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
175 let tick_size = pow10_neg(price_decimals)?;
176 let lot_size = pow10_neg(base_token.sz_decimals)?;
177
178 let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
179
180 let raw_symbol: Ustr = if base_token.name == "PURR" {
184 pair.name.as_str().into()
185 } else {
186 format!("@{}", pair.index).into()
187 };
188
189 let def = HyperliquidInstrumentDef {
190 symbol: symbol.into(),
191 raw_symbol,
192 base: base_token.name.clone().into(),
193 quote: quote_token.name.clone().into(),
194 market_type: HyperliquidMarketType::Spot,
195 asset_index: SPOT_INDEX_OFFSET + pair.index,
196 price_decimals,
197 size_decimals: base_token.sz_decimals,
198 tick_size,
199 lot_size,
200 max_leverage: None,
201 only_isolated: false,
202 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
204 };
205
206 defs.push(def);
207 }
208
209 Ok(defs)
210}
211
212fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
216 if decimals == 0 {
217 return Ok(Decimal::ONE);
218 }
219
220 Ok(Decimal::from_i128_with_scale(1, decimals))
222}
223
224pub fn get_currency(code: &str) -> Currency {
225 Currency::try_from_str(code).unwrap_or_else(|| {
226 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
227 if let Err(e) = Currency::register(currency, false) {
228 log::error!("Failed to register currency '{code}': {e}");
229 }
230 currency
231 })
232}
233
234#[must_use]
238pub fn create_instrument_from_def(
239 def: &HyperliquidInstrumentDef,
240 ts_init: UnixNanos,
241) -> Option<InstrumentAny> {
242 let symbol = Symbol::new(def.symbol);
243 let venue = *HYPERLIQUID_VENUE;
244 let instrument_id = InstrumentId::new(symbol, venue);
245
246 let raw_symbol = Symbol::new(def.raw_symbol);
251 let base_currency = get_currency(&def.base);
252 let quote_currency = get_currency(&def.quote);
253 let price_increment = Price::from(def.tick_size.to_string());
254 let size_increment = Quantity::from(def.lot_size.to_string());
255
256 match def.market_type {
257 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
258 instrument_id,
259 raw_symbol,
260 base_currency,
261 quote_currency,
262 def.price_decimals as u8,
263 def.size_decimals as u8,
264 price_increment,
265 size_increment,
266 None,
267 None,
268 None,
269 None,
270 None,
271 None,
272 None,
273 None,
274 None,
275 None,
276 None,
277 None,
278 ts_init, ts_init,
280 ))),
281 HyperliquidMarketType::Perp => {
282 let settlement_currency = get_currency("USDC");
283
284 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
285 instrument_id,
286 raw_symbol,
287 base_currency,
288 quote_currency,
289 settlement_currency,
290 false,
291 def.price_decimals as u8,
292 def.size_decimals as u8,
293 price_increment,
294 size_increment,
295 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 {
339 match side {
340 HyperliquidSide::Buy => OrderSide::Buy,
341 HyperliquidSide::Sell => OrderSide::Sell,
342 }
343}
344
345pub fn parse_order_status_report_from_ws(
351 order_data: &WsOrderData,
352 instrument: &dyn Instrument,
353 account_id: AccountId,
354 ts_init: UnixNanos,
355) -> anyhow::Result<OrderStatusReport> {
356 parse_order_status_report_from_basic(
357 &order_data.order,
358 &order_data.status,
359 instrument,
360 account_id,
361 ts_init,
362 )
363}
364
365pub fn parse_order_status_report_from_basic(
371 order: &WsBasicOrderData,
372 status: &HyperliquidOrderStatusEnum,
373 instrument: &dyn Instrument,
374 account_id: AccountId,
375 ts_init: UnixNanos,
376) -> anyhow::Result<OrderStatusReport> {
377 use nautilus_model::types::{Price, Quantity};
378 use rust_decimal::Decimal;
379
380 let instrument_id = instrument.id();
381 let venue_order_id = VenueOrderId::new(order.oid.to_string());
382 let order_side = OrderSide::from(order.side);
383
384 let order_type = if order.trigger_px.is_some() {
386 if order.is_market == Some(true) {
387 match order.tpsl.as_ref() {
389 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
390 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
391 _ => OrderType::StopMarket,
392 }
393 } else {
394 match order.tpsl.as_ref() {
395 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
396 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
397 _ => OrderType::StopLimit,
398 }
399 }
400 } else {
401 OrderType::Limit
402 };
403
404 let time_in_force = TimeInForce::Gtc;
405 let order_status = OrderStatus::from(*status);
406
407 let price_precision = instrument.price_precision();
408 let size_precision = instrument.size_precision();
409
410 let orig_sz: Decimal = order
411 .orig_sz
412 .parse()
413 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
414 let current_sz: Decimal = order
415 .sz
416 .parse()
417 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
418
419 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
420 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
421 let filled_sz = orig_sz.abs() - current_sz.abs();
422 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
423 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
424
425 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
426 let ts_last = ts_accepted;
427 let report_id = UUID4::new();
428
429 let mut report = OrderStatusReport::new(
430 account_id,
431 instrument_id,
432 None, venue_order_id,
434 order_side,
435 order_type,
436 time_in_force,
437 order_status,
438 quantity,
439 filled_qty,
440 ts_accepted,
441 ts_last,
442 ts_init,
443 Some(report_id),
444 );
445
446 if let Some(cloid) = &order.cloid {
448 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
449 }
450
451 if !matches!(
455 order_status,
456 OrderStatus::Filled | OrderStatus::PartiallyFilled
457 ) {
458 let limit_px: Decimal = order
459 .limit_px
460 .parse()
461 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
462 let price = Price::from_decimal_dp(limit_px, price_precision)
463 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
464 report = report.with_price(price);
465 }
466
467 if let Some(trigger_px) = &order.trigger_px {
469 let trig_px: Decimal = trigger_px
470 .parse()
471 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
472 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
473 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
474 report = report
475 .with_trigger_price(trigger_price)
476 .with_trigger_type(TriggerType::Default);
477 }
478
479 Ok(report)
480}
481
482pub fn parse_fill_report(
488 fill: &HyperliquidFill,
489 instrument: &dyn Instrument,
490 account_id: AccountId,
491 ts_init: UnixNanos,
492) -> anyhow::Result<FillReport> {
493 use nautilus_model::types::{Money, Price, Quantity};
494 use rust_decimal::Decimal;
495
496 let instrument_id = instrument.id();
497 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
498
499 let oid_str = fill.oid.to_string();
503 let hash_budget = 36 - oid_str.len() - 1; let hash_part = if fill.hash.len() > hash_budget {
505 &fill.hash[fill.hash.len() - hash_budget..]
506 } else {
507 &fill.hash
508 };
509 let trade_id = TradeId::new(format!("{hash_part}-{oid_str}"));
510 let order_side = parse_fill_side(&fill.side);
511
512 let price_precision = instrument.price_precision();
513 let size_precision = instrument.size_precision();
514
515 let px: Decimal = fill
516 .px
517 .parse()
518 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
519 let sz: Decimal = fill
520 .sz
521 .parse()
522 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
523
524 let last_px = Price::from_decimal_dp(px, price_precision)
525 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
526 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
527 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
528
529 let fee_amount: Decimal = fill
531 .fee
532 .parse()
533 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
534
535 let fee_currency = Currency::from("USDC");
537 let commission = Money::from_decimal(fee_amount, fee_currency)
538 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
539
540 let liquidity_side = if fill.crossed {
542 LiquiditySide::Taker
543 } else {
544 LiquiditySide::Maker
545 };
546
547 let ts_event = UnixNanos::from(fill.time * 1_000_000);
548 let report_id = UUID4::new();
549
550 let report = FillReport::new(
551 account_id,
552 instrument_id,
553 venue_order_id,
554 trade_id,
555 order_side,
556 last_qty,
557 last_px,
558 commission,
559 liquidity_side,
560 None, None, ts_event,
563 ts_init,
564 Some(report_id),
565 );
566
567 Ok(report)
568}
569
570pub fn parse_position_status_report(
576 position_data: &serde_json::Value,
577 instrument: &dyn Instrument,
578 account_id: AccountId,
579 ts_init: UnixNanos,
580) -> anyhow::Result<PositionStatusReport> {
581 use nautilus_model::types::Quantity;
582
583 use super::models::AssetPosition;
584
585 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
587 .context("failed to deserialize AssetPosition")?;
588
589 let position = &asset_position.position;
590 let instrument_id = instrument.id();
591
592 let (position_side, quantity_value) = if position.szi.is_zero() {
594 (PositionSideSpecified::Flat, Decimal::ZERO)
595 } else if position.szi.is_sign_positive() {
596 (PositionSideSpecified::Long, position.szi)
597 } else {
598 (PositionSideSpecified::Short, position.szi.abs())
599 };
600
601 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
602 .context("failed to create quantity from decimal")?;
603 let report_id = UUID4::new();
604 let ts_last = ts_init;
605 let avg_px_open = position.entry_px;
606
607 Ok(PositionStatusReport::new(
609 account_id,
610 instrument_id,
611 position_side,
612 quantity,
613 ts_last,
614 ts_init,
615 Some(report_id),
616 None, avg_px_open,
618 ))
619}
620
621#[cfg(test)]
622mod tests {
623 use rstest::rstest;
624 use rust_decimal_macros::dec;
625
626 use super::{
627 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
628 *,
629 };
630
631 #[rstest]
632 fn test_parse_fill_side() {
633 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
634 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
635 }
636
637 #[rstest]
638 fn test_pow10_neg() {
639 assert_eq!(pow10_neg(0).unwrap(), dec!(1));
640 assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
641 assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
642 }
643
644 #[rstest]
645 fn test_parse_perp_instruments() {
646 let meta = PerpMeta {
647 universe: vec![
648 PerpAsset {
649 name: "BTC".to_string(),
650 sz_decimals: 5,
651 max_leverage: Some(50),
652 only_isolated: None,
653 is_delisted: None,
654 },
655 PerpAsset {
656 name: "DELIST".to_string(),
657 sz_decimals: 3,
658 max_leverage: Some(10),
659 only_isolated: Some(true),
660 is_delisted: Some(true), },
662 ],
663 margin_tables: vec![],
664 };
665
666 let defs = parse_perp_instruments(&meta).unwrap();
667
668 assert_eq!(defs.len(), 2);
670
671 let btc = &defs[0];
672 assert_eq!(btc.symbol, "BTC-USD-PERP");
673 assert_eq!(btc.base, "BTC");
674 assert_eq!(btc.quote, "USD");
675 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
676 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
678 assert_eq!(btc.tick_size, dec!(0.1));
679 assert_eq!(btc.lot_size, dec!(0.00001));
680 assert_eq!(btc.max_leverage, Some(50));
681 assert!(!btc.only_isolated);
682 assert!(btc.active);
683
684 let delist = &defs[1];
685 assert_eq!(delist.symbol, "DELIST-USD-PERP");
686 assert_eq!(delist.base, "DELIST");
687 assert!(!delist.active); }
689
690 fn load_test_data<T>(filename: &str) -> T
691 where
692 T: serde::de::DeserializeOwned,
693 {
694 let path = format!("test_data/{filename}");
695 let content = std::fs::read_to_string(path).expect("Failed to read test data");
696 serde_json::from_str(&content).expect("Failed to parse test data")
697 }
698
699 #[rstest]
700 fn test_parse_perp_instruments_from_real_data() {
701 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
702
703 let defs = parse_perp_instruments(&meta).unwrap();
704
705 assert_eq!(defs.len(), 3);
707
708 let btc = &defs[0];
710 assert_eq!(btc.symbol, "BTC-USD-PERP");
711 assert_eq!(btc.base, "BTC");
712 assert_eq!(btc.quote, "USD");
713 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
714 assert_eq!(btc.size_decimals, 5);
715 assert_eq!(btc.max_leverage, Some(40));
716 assert!(btc.active);
717
718 let eth = &defs[1];
720 assert_eq!(eth.symbol, "ETH-USD-PERP");
721 assert_eq!(eth.base, "ETH");
722 assert_eq!(eth.size_decimals, 4);
723 assert_eq!(eth.max_leverage, Some(25));
724
725 let atom = &defs[2];
727 assert_eq!(atom.symbol, "ATOM-USD-PERP");
728 assert_eq!(atom.base, "ATOM");
729 assert_eq!(atom.size_decimals, 2);
730 assert_eq!(atom.max_leverage, Some(5));
731 }
732
733 #[rstest]
734 fn test_deserialize_l2_book_from_real_data() {
735 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
736
737 assert_eq!(book.coin, "BTC");
739 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];
745 let asks = &book.levels[1];
746
747 for i in 1..bids.len() {
749 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
750 let curr_price = bids[i].px.parse::<f64>().unwrap();
751 assert!(prev_price >= curr_price, "Bids should be descending");
752 }
753
754 for i in 1..asks.len() {
756 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
757 let curr_price = asks[i].px.parse::<f64>().unwrap();
758 assert!(prev_price <= curr_price, "Asks should be ascending");
759 }
760 }
761
762 #[rstest]
763 fn test_parse_spot_instruments() {
764 let tokens = vec![
765 SpotToken {
766 name: "USDC".to_string(),
767 sz_decimals: 6,
768 wei_decimals: 6,
769 index: 0,
770 token_id: "0x1".to_string(),
771 is_canonical: true,
772 evm_contract: None,
773 full_name: None,
774 deployer_trading_fee_share: None,
775 },
776 SpotToken {
777 name: "PURR".to_string(),
778 sz_decimals: 0,
779 wei_decimals: 5,
780 index: 1,
781 token_id: "0x2".to_string(),
782 is_canonical: true,
783 evm_contract: None,
784 full_name: None,
785 deployer_trading_fee_share: None,
786 },
787 ];
788
789 let pairs = vec![
790 SpotPair {
791 name: "PURR/USDC".to_string(),
792 tokens: [1, 0], index: 0,
794 is_canonical: true,
795 },
796 SpotPair {
797 name: "ALIAS".to_string(),
798 tokens: [1, 0],
799 index: 1,
800 is_canonical: false, },
802 ];
803
804 let meta = SpotMeta {
805 tokens,
806 universe: pairs,
807 };
808
809 let defs = parse_spot_instruments(&meta).unwrap();
810
811 assert_eq!(defs.len(), 2);
813
814 let purr_usdc = &defs[0];
815 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
816 assert_eq!(purr_usdc.base, "PURR");
817 assert_eq!(purr_usdc.quote, "USDC");
818 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
819 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
821 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
822 assert_eq!(purr_usdc.lot_size, dec!(1));
823 assert_eq!(purr_usdc.max_leverage, None);
824 assert!(!purr_usdc.only_isolated);
825 assert!(purr_usdc.active);
826
827 let alias = &defs[1];
828 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
829 assert_eq!(alias.base, "PURR");
830 assert!(!alias.active); }
832
833 #[rstest]
834 fn test_price_decimals_clamping() {
835 let meta = PerpMeta {
837 universe: vec![PerpAsset {
838 name: "HIGHPREC".to_string(),
839 sz_decimals: 10, max_leverage: Some(1),
841 only_isolated: None,
842 is_delisted: None,
843 }],
844 margin_tables: vec![],
845 };
846
847 let defs = parse_perp_instruments(&meta).unwrap();
848 assert_eq!(defs[0].price_decimals, 0);
849 assert_eq!(defs[0].tick_size, dec!(1));
850 }
851}