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::{
24 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, VenueOrderId,
25 },
26 instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
27 reports::{FillReport, OrderStatusReport, PositionStatusReport},
28 types::{Currency, Price, Quantity},
29};
30use rust_decimal::{Decimal, prelude::ToPrimitive};
31use serde::{Deserialize, Serialize};
32use ustr::Ustr;
33
34use super::models::{HyperliquidFill, PerpMeta, SpotMeta};
35use crate::{
36 common::{
37 consts::HYPERLIQUID_VENUE,
38 enums::{
39 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
40 },
41 },
42 websocket::messages::{WsBasicOrderData, WsOrderData},
43};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47pub enum HyperliquidMarketType {
48 Perp,
50 Spot,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct HyperliquidInstrumentDef {
60 pub symbol: Ustr,
62 pub base: Ustr,
64 pub quote: Ustr,
66 pub market_type: HyperliquidMarketType,
68 pub price_decimals: u32,
70 pub size_decimals: u32,
72 pub tick_size: Decimal,
74 pub lot_size: Decimal,
76 pub max_leverage: Option<u32>,
78 pub only_isolated: bool,
80 pub active: bool,
82 pub raw_data: String,
84}
85
86pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
97 const PERP_MAX_DECIMALS: i32 = 6; let mut defs = Vec::new();
100
101 for asset in &meta.universe {
102 let is_delisted = asset.is_delisted.unwrap_or(false);
105
106 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
107 let tick_size = pow10_neg(price_decimals)?;
108 let lot_size = pow10_neg(asset.sz_decimals)?;
109
110 let symbol = format!("{}-USD-PERP", asset.name);
111
112 let def = HyperliquidInstrumentDef {
113 symbol: symbol.into(),
114 base: asset.name.clone().into(),
115 quote: "USD".into(), market_type: HyperliquidMarketType::Perp,
117 price_decimals,
118 size_decimals: asset.sz_decimals,
119 tick_size,
120 lot_size,
121 max_leverage: asset.max_leverage,
122 only_isolated: asset.only_isolated.unwrap_or(false),
123 active: !is_delisted, raw_data: serde_json::to_string(asset).unwrap_or_default(),
125 };
126
127 defs.push(def);
128 }
129
130 Ok(defs)
131}
132
133pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
141 const SPOT_MAX_DECIMALS: i32 = 8; let mut defs = Vec::new();
144
145 let mut tokens_by_index = std::collections::HashMap::new();
147 for token in &meta.tokens {
148 tokens_by_index.insert(token.index, token);
149 }
150
151 for pair in &meta.universe {
152 let base_token = tokens_by_index
157 .get(&pair.tokens[0])
158 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
159 let quote_token = tokens_by_index
160 .get(&pair.tokens[1])
161 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
162
163 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
164 let tick_size = pow10_neg(price_decimals)?;
165 let lot_size = pow10_neg(base_token.sz_decimals)?;
166
167 let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
168
169 let def = HyperliquidInstrumentDef {
170 symbol: symbol.into(),
171 base: base_token.name.clone().into(),
172 quote: quote_token.name.clone().into(),
173 market_type: HyperliquidMarketType::Spot,
174 price_decimals,
175 size_decimals: base_token.sz_decimals,
176 tick_size,
177 lot_size,
178 max_leverage: None,
179 only_isolated: false,
180 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
182 };
183
184 defs.push(def);
185 }
186
187 Ok(defs)
188}
189
190fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
194 if decimals == 0 {
195 return Ok(Decimal::ONE);
196 }
197
198 Ok(Decimal::from_i128_with_scale(1, decimals))
200}
201
202pub fn get_currency(code: &str) -> Currency {
203 Currency::try_from_str(code).unwrap_or_else(|| {
204 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
205 if let Err(e) = Currency::register(currency, false) {
206 tracing::error!("Failed to register currency '{code}': {e}");
207 }
208 currency
209 })
210}
211
212#[must_use]
216pub fn create_instrument_from_def(
217 def: &HyperliquidInstrumentDef,
218 ts_init: UnixNanos,
219) -> Option<InstrumentAny> {
220 let symbol = Symbol::new(def.symbol);
221 let venue = *HYPERLIQUID_VENUE;
222 let instrument_id = InstrumentId::new(symbol, venue);
223
224 let raw_symbol = Symbol::new(def.base);
227 let base_currency = get_currency(&def.base);
228 let quote_currency = get_currency(&def.quote);
229 let price_increment = Price::from(def.tick_size.to_string());
230 let size_increment = Quantity::from(def.lot_size.to_string());
231
232 match def.market_type {
233 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
234 instrument_id,
235 raw_symbol,
236 base_currency,
237 quote_currency,
238 def.price_decimals as u8,
239 def.size_decimals as u8,
240 price_increment,
241 size_increment,
242 None,
243 None,
244 None,
245 None,
246 None,
247 None,
248 None,
249 None,
250 None,
251 None,
252 None,
253 None,
254 ts_init, ts_init,
256 ))),
257 HyperliquidMarketType::Perp => {
258 let settlement_currency = get_currency("USDC");
259
260 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
261 instrument_id,
262 raw_symbol,
263 base_currency,
264 quote_currency,
265 settlement_currency,
266 false,
267 def.price_decimals as u8,
268 def.size_decimals as u8,
269 price_increment,
270 size_increment,
271 None,
272 None,
273 None,
274 None,
275 None,
276 None,
277 None,
278 None,
279 None,
280 None,
281 None,
282 None,
283 ts_init, ts_init,
285 )))
286 }
287 }
288}
289
290#[must_use]
293pub fn instruments_from_defs(
294 defs: &[HyperliquidInstrumentDef],
295 ts_init: UnixNanos,
296) -> Vec<InstrumentAny> {
297 defs.iter()
298 .filter_map(|def| create_instrument_from_def(def, ts_init))
299 .collect()
300}
301
302#[must_use]
304pub fn instruments_from_defs_owned(defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
305 let clock = get_atomic_clock_realtime();
306 let ts_init = clock.get_time_ns();
307
308 defs.into_iter()
309 .filter_map(|def| create_instrument_from_def(&def, ts_init))
310 .collect()
311}
312
313fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
315 match side {
316 HyperliquidSide::Buy => OrderSide::Buy,
317 HyperliquidSide::Sell => OrderSide::Sell,
318 }
319}
320
321pub fn parse_order_status_report_from_ws(
327 order_data: &WsOrderData,
328 instrument: &dyn Instrument,
329 account_id: AccountId,
330 ts_init: UnixNanos,
331) -> anyhow::Result<OrderStatusReport> {
332 parse_order_status_report_from_basic(
333 &order_data.order,
334 &order_data.status,
335 instrument,
336 account_id,
337 ts_init,
338 )
339}
340
341pub fn parse_order_status_report_from_basic(
347 order: &WsBasicOrderData,
348 status: &HyperliquidOrderStatusEnum,
349 instrument: &dyn Instrument,
350 account_id: AccountId,
351 ts_init: UnixNanos,
352) -> anyhow::Result<OrderStatusReport> {
353 use nautilus_model::types::{Price, Quantity};
354 use rust_decimal::Decimal;
355
356 let instrument_id = instrument.id();
357 let venue_order_id = VenueOrderId::new(order.oid.to_string());
358 let order_side = OrderSide::from(order.side);
359
360 let order_type = if order.trigger_px.is_some() {
362 if order.is_market == Some(true) {
363 match order.tpsl.as_ref() {
365 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
366 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
367 _ => OrderType::StopMarket,
368 }
369 } else {
370 match order.tpsl.as_ref() {
371 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
372 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
373 _ => OrderType::StopLimit,
374 }
375 }
376 } else {
377 OrderType::Limit
378 };
379
380 let time_in_force = TimeInForce::Gtc; let order_status = OrderStatus::from(*status);
382
383 let price_precision = instrument.price_precision();
385 let size_precision = instrument.size_precision();
386
387 let orig_sz: Decimal = order
388 .orig_sz
389 .parse()
390 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
391 let current_sz: Decimal = order
392 .sz
393 .parse()
394 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
395
396 let quantity = Quantity::new(orig_sz.abs().to_f64().unwrap_or(0.0), size_precision);
397 let filled_sz = orig_sz.abs() - current_sz.abs();
398 let filled_qty = Quantity::new(filled_sz.to_f64().unwrap_or(0.0), size_precision);
399
400 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000); let ts_last = ts_accepted;
403
404 let report_id = UUID4::new();
405
406 let mut report = OrderStatusReport::new(
407 account_id,
408 instrument_id,
409 None, venue_order_id,
411 order_side,
412 order_type,
413 time_in_force,
414 order_status,
415 quantity,
416 filled_qty,
417 ts_accepted,
418 ts_last,
419 ts_init,
420 Some(report_id),
421 );
422
423 if let Some(cloid) = &order.cloid {
425 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
426 }
427
428 let limit_px: Decimal = order
430 .limit_px
431 .parse()
432 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
433 report = report.with_price(Price::new(
434 limit_px.to_f64().unwrap_or(0.0),
435 price_precision,
436 ));
437
438 if let Some(trigger_px) = &order.trigger_px {
440 let trig_px: Decimal = trigger_px
441 .parse()
442 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
443 report = report
444 .with_trigger_price(Price::new(trig_px.to_f64().unwrap_or(0.0), price_precision))
445 .with_trigger_type(TriggerType::Default);
446 }
447
448 Ok(report)
449}
450
451pub fn parse_fill_report(
457 fill: &HyperliquidFill,
458 instrument: &dyn Instrument,
459 account_id: AccountId,
460 ts_init: UnixNanos,
461) -> anyhow::Result<FillReport> {
462 use nautilus_model::types::{Money, Price, Quantity};
463 use rust_decimal::Decimal;
464
465 let instrument_id = instrument.id();
466 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
467
468 let trade_id_str = format!("{}-{}", fill.hash, fill.time);
470 tracing::debug!(
471 "Parsing fill: hash={}, time={}, trade_id_str='{}', len={}",
472 fill.hash,
473 fill.time,
474 trade_id_str,
475 trade_id_str.len()
476 );
477
478 let trade_id = TradeId::new(trade_id_str);
479 let order_side = parse_fill_side(&fill.side);
480
481 let price_precision = instrument.price_precision();
483 let size_precision = instrument.size_precision();
484
485 let px: Decimal = fill
486 .px
487 .parse()
488 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
489 let sz: Decimal = fill
490 .sz
491 .parse()
492 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
493
494 let last_px = Price::new(px.to_f64().unwrap_or(0.0), price_precision);
495 let last_qty = Quantity::new(sz.abs().to_f64().unwrap_or(0.0), size_precision);
496
497 let fee_amount: Decimal = fill
499 .fee
500 .parse()
501 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
502
503 let fee_currency = Currency::from("USDC");
505 let commission = Money::new(fee_amount.abs().to_f64().unwrap_or(0.0), fee_currency);
506
507 let liquidity_side = if fill.crossed {
509 LiquiditySide::Taker
510 } else {
511 LiquiditySide::Maker
512 };
513
514 let ts_event = UnixNanos::from(fill.time * 1_000_000); let report_id = UUID4::new();
518
519 let report = FillReport::new(
520 account_id,
521 instrument_id,
522 venue_order_id,
523 trade_id,
524 order_side,
525 last_qty,
526 last_px,
527 commission,
528 liquidity_side,
529 None, None, ts_event,
532 ts_init,
533 Some(report_id),
534 );
535
536 Ok(report)
537}
538
539pub fn parse_position_status_report(
545 position_data: &serde_json::Value,
546 instrument: &dyn Instrument,
547 account_id: AccountId,
548 ts_init: UnixNanos,
549) -> anyhow::Result<PositionStatusReport> {
550 use nautilus_model::types::Quantity;
551
552 use super::models::AssetPosition;
553
554 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
556 .context("failed to deserialize AssetPosition")?;
557
558 let position = &asset_position.position;
559 let instrument_id = instrument.id();
560
561 let (position_side, quantity_value) = if position.szi.is_zero() {
563 (PositionSideSpecified::Flat, Decimal::ZERO)
564 } else if position.szi.is_sign_positive() {
565 (PositionSideSpecified::Long, position.szi)
566 } else {
567 (PositionSideSpecified::Short, position.szi.abs())
568 };
569
570 let quantity = Quantity::new(
572 quantity_value
573 .to_f64()
574 .context("failed to convert quantity to f64")?,
575 instrument.size_precision(),
576 );
577
578 let report_id = UUID4::new();
580
581 let ts_last = ts_init;
583
584 let venue_position_id = Some(PositionId::new(format!("{}_{}", account_id, position.coin)));
586
587 let avg_px_open = position.entry_px;
589
590 Ok(PositionStatusReport::new(
591 account_id,
592 instrument_id,
593 position_side,
594 quantity,
595 ts_last,
596 ts_init,
597 Some(report_id),
598 venue_position_id,
599 avg_px_open,
600 ))
601}
602
603#[cfg(test)]
604mod tests {
605 use rstest::rstest;
606 use rust_decimal_macros::dec;
607
608 use super::{
609 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
610 *,
611 };
612
613 #[rstest]
614 fn test_parse_fill_side() {
615 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
616 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
617 }
618
619 #[rstest]
620 fn test_pow10_neg() {
621 assert_eq!(pow10_neg(0).unwrap(), dec!(1));
622 assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
623 assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
624 }
625
626 #[rstest]
627 fn test_parse_perp_instruments() {
628 let meta = PerpMeta {
629 universe: vec![
630 PerpAsset {
631 name: "BTC".to_string(),
632 sz_decimals: 5,
633 max_leverage: Some(50),
634 only_isolated: None,
635 is_delisted: None,
636 },
637 PerpAsset {
638 name: "DELIST".to_string(),
639 sz_decimals: 3,
640 max_leverage: Some(10),
641 only_isolated: Some(true),
642 is_delisted: Some(true), },
644 ],
645 margin_tables: vec![],
646 };
647
648 let defs = parse_perp_instruments(&meta).unwrap();
649
650 assert_eq!(defs.len(), 2);
652
653 let btc = &defs[0];
654 assert_eq!(btc.symbol, "BTC-USD-PERP");
655 assert_eq!(btc.base, "BTC");
656 assert_eq!(btc.quote, "USD");
657 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
658 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
660 assert_eq!(btc.tick_size, dec!(0.1));
661 assert_eq!(btc.lot_size, dec!(0.00001));
662 assert_eq!(btc.max_leverage, Some(50));
663 assert!(!btc.only_isolated);
664 assert!(btc.active);
665
666 let delist = &defs[1];
667 assert_eq!(delist.symbol, "DELIST-USD-PERP");
668 assert_eq!(delist.base, "DELIST");
669 assert!(!delist.active); }
671
672 fn load_test_data<T>(filename: &str) -> T
673 where
674 T: serde::de::DeserializeOwned,
675 {
676 let path = format!("test_data/{filename}");
677 let content = std::fs::read_to_string(path).expect("Failed to read test data");
678 serde_json::from_str(&content).expect("Failed to parse test data")
679 }
680
681 #[rstest]
682 fn test_parse_perp_instruments_from_real_data() {
683 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
684
685 let defs = parse_perp_instruments(&meta).unwrap();
686
687 assert_eq!(defs.len(), 3);
689
690 let btc = &defs[0];
692 assert_eq!(btc.symbol, "BTC-USD-PERP");
693 assert_eq!(btc.base, "BTC");
694 assert_eq!(btc.quote, "USD");
695 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
696 assert_eq!(btc.size_decimals, 5);
697 assert_eq!(btc.max_leverage, Some(40));
698 assert!(btc.active);
699
700 let eth = &defs[1];
702 assert_eq!(eth.symbol, "ETH-USD-PERP");
703 assert_eq!(eth.base, "ETH");
704 assert_eq!(eth.size_decimals, 4);
705 assert_eq!(eth.max_leverage, Some(25));
706
707 let atom = &defs[2];
709 assert_eq!(atom.symbol, "ATOM-USD-PERP");
710 assert_eq!(atom.base, "ATOM");
711 assert_eq!(atom.size_decimals, 2);
712 assert_eq!(atom.max_leverage, Some(5));
713 }
714
715 #[rstest]
716 fn test_deserialize_l2_book_from_real_data() {
717 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
718
719 assert_eq!(book.coin, "BTC");
721 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];
727 let asks = &book.levels[1];
728
729 for i in 1..bids.len() {
731 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
732 let curr_price = bids[i].px.parse::<f64>().unwrap();
733 assert!(prev_price >= curr_price, "Bids should be descending");
734 }
735
736 for i in 1..asks.len() {
738 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
739 let curr_price = asks[i].px.parse::<f64>().unwrap();
740 assert!(prev_price <= curr_price, "Asks should be ascending");
741 }
742 }
743
744 #[rstest]
745 fn test_parse_spot_instruments() {
746 let tokens = vec![
747 SpotToken {
748 name: "USDC".to_string(),
749 sz_decimals: 6,
750 wei_decimals: 6,
751 index: 0,
752 token_id: "0x1".to_string(),
753 is_canonical: true,
754 evm_contract: None,
755 full_name: None,
756 deployer_trading_fee_share: None,
757 },
758 SpotToken {
759 name: "PURR".to_string(),
760 sz_decimals: 0,
761 wei_decimals: 5,
762 index: 1,
763 token_id: "0x2".to_string(),
764 is_canonical: true,
765 evm_contract: None,
766 full_name: None,
767 deployer_trading_fee_share: None,
768 },
769 ];
770
771 let pairs = vec![
772 SpotPair {
773 name: "PURR/USDC".to_string(),
774 tokens: [1, 0], index: 0,
776 is_canonical: true,
777 },
778 SpotPair {
779 name: "ALIAS".to_string(),
780 tokens: [1, 0],
781 index: 1,
782 is_canonical: false, },
784 ];
785
786 let meta = SpotMeta {
787 tokens,
788 universe: pairs,
789 };
790
791 let defs = parse_spot_instruments(&meta).unwrap();
792
793 assert_eq!(defs.len(), 2);
795
796 let purr_usdc = &defs[0];
797 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
798 assert_eq!(purr_usdc.base, "PURR");
799 assert_eq!(purr_usdc.quote, "USDC");
800 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
801 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
803 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
804 assert_eq!(purr_usdc.lot_size, dec!(1));
805 assert_eq!(purr_usdc.max_leverage, None);
806 assert!(!purr_usdc.only_isolated);
807 assert!(purr_usdc.active);
808
809 let alias = &defs[1];
810 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
811 assert_eq!(alias.base, "PURR");
812 assert!(!alias.active); }
814
815 #[rstest]
816 fn test_price_decimals_clamping() {
817 let meta = PerpMeta {
819 universe: vec![PerpAsset {
820 name: "HIGHPREC".to_string(),
821 sz_decimals: 10, max_leverage: Some(1),
823 only_isolated: None,
824 is_delisted: None,
825 }],
826 margin_tables: vec![],
827 };
828
829 let defs = parse_perp_instruments(&meta).unwrap();
830 assert_eq!(defs[0].price_decimals, 0);
831 assert_eq!(defs[0].tick_size, dec!(1));
832 }
833}