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.iter() {
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)]
608mod tests {
609 use rstest::rstest;
610 use rust_decimal_macros::dec;
611
612 use super::{
613 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
614 *,
615 };
616
617 #[rstest]
618 fn test_parse_fill_side() {
619 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
620 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
621 }
622
623 #[rstest]
624 fn test_pow10_neg() {
625 assert_eq!(pow10_neg(0).unwrap(), dec!(1));
626 assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
627 assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
628 }
629
630 #[rstest]
631 fn test_parse_perp_instruments() {
632 let meta = PerpMeta {
633 universe: vec![
634 PerpAsset {
635 name: "BTC".to_string(),
636 sz_decimals: 5,
637 max_leverage: Some(50),
638 only_isolated: None,
639 is_delisted: None,
640 },
641 PerpAsset {
642 name: "DELIST".to_string(),
643 sz_decimals: 3,
644 max_leverage: Some(10),
645 only_isolated: Some(true),
646 is_delisted: Some(true), },
648 ],
649 margin_tables: vec![],
650 };
651
652 let defs = parse_perp_instruments(&meta).unwrap();
653
654 assert_eq!(defs.len(), 2);
656
657 let btc = &defs[0];
658 assert_eq!(btc.symbol, "BTC-USD-PERP");
659 assert_eq!(btc.base, "BTC");
660 assert_eq!(btc.quote, "USD");
661 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
662 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
664 assert_eq!(btc.tick_size, dec!(0.1));
665 assert_eq!(btc.lot_size, dec!(0.00001));
666 assert_eq!(btc.max_leverage, Some(50));
667 assert!(!btc.only_isolated);
668 assert!(btc.active);
669
670 let delist = &defs[1];
671 assert_eq!(delist.symbol, "DELIST-USD-PERP");
672 assert_eq!(delist.base, "DELIST");
673 assert!(!delist.active); }
675
676 fn load_test_data<T>(filename: &str) -> T
677 where
678 T: serde::de::DeserializeOwned,
679 {
680 let path = format!("test_data/{filename}");
681 let content = std::fs::read_to_string(path).expect("Failed to read test data");
682 serde_json::from_str(&content).expect("Failed to parse test data")
683 }
684
685 #[rstest]
686 fn test_parse_perp_instruments_from_real_data() {
687 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
688
689 let defs = parse_perp_instruments(&meta).unwrap();
690
691 assert_eq!(defs.len(), 3);
693
694 let btc = &defs[0];
696 assert_eq!(btc.symbol, "BTC-USD-PERP");
697 assert_eq!(btc.base, "BTC");
698 assert_eq!(btc.quote, "USD");
699 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
700 assert_eq!(btc.size_decimals, 5);
701 assert_eq!(btc.max_leverage, Some(40));
702 assert!(btc.active);
703
704 let eth = &defs[1];
706 assert_eq!(eth.symbol, "ETH-USD-PERP");
707 assert_eq!(eth.base, "ETH");
708 assert_eq!(eth.size_decimals, 4);
709 assert_eq!(eth.max_leverage, Some(25));
710
711 let atom = &defs[2];
713 assert_eq!(atom.symbol, "ATOM-USD-PERP");
714 assert_eq!(atom.base, "ATOM");
715 assert_eq!(atom.size_decimals, 2);
716 assert_eq!(atom.max_leverage, Some(5));
717 }
718
719 #[rstest]
720 fn test_deserialize_l2_book_from_real_data() {
721 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
722
723 assert_eq!(book.coin, "BTC");
725 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];
731 let asks = &book.levels[1];
732
733 for i in 1..bids.len() {
735 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
736 let curr_price = bids[i].px.parse::<f64>().unwrap();
737 assert!(prev_price >= curr_price, "Bids should be descending");
738 }
739
740 for i in 1..asks.len() {
742 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
743 let curr_price = asks[i].px.parse::<f64>().unwrap();
744 assert!(prev_price <= curr_price, "Asks should be ascending");
745 }
746 }
747
748 #[rstest]
749 fn test_parse_spot_instruments() {
750 let tokens = vec![
751 SpotToken {
752 name: "USDC".to_string(),
753 sz_decimals: 6,
754 wei_decimals: 6,
755 index: 0,
756 token_id: "0x1".to_string(),
757 is_canonical: true,
758 evm_contract: None,
759 full_name: None,
760 deployer_trading_fee_share: None,
761 },
762 SpotToken {
763 name: "PURR".to_string(),
764 sz_decimals: 0,
765 wei_decimals: 5,
766 index: 1,
767 token_id: "0x2".to_string(),
768 is_canonical: true,
769 evm_contract: None,
770 full_name: None,
771 deployer_trading_fee_share: None,
772 },
773 ];
774
775 let pairs = vec![
776 SpotPair {
777 name: "PURR/USDC".to_string(),
778 tokens: [1, 0], index: 0,
780 is_canonical: true,
781 },
782 SpotPair {
783 name: "ALIAS".to_string(),
784 tokens: [1, 0],
785 index: 1,
786 is_canonical: false, },
788 ];
789
790 let meta = SpotMeta {
791 tokens,
792 universe: pairs,
793 };
794
795 let defs = parse_spot_instruments(&meta).unwrap();
796
797 assert_eq!(defs.len(), 2);
799
800 let purr_usdc = &defs[0];
801 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
802 assert_eq!(purr_usdc.base, "PURR");
803 assert_eq!(purr_usdc.quote, "USDC");
804 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
805 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
807 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
808 assert_eq!(purr_usdc.lot_size, dec!(1));
809 assert_eq!(purr_usdc.max_leverage, None);
810 assert!(!purr_usdc.only_isolated);
811 assert!(purr_usdc.active);
812
813 let alias = &defs[1];
814 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
815 assert_eq!(alias.base, "PURR");
816 assert!(!alias.active); }
818
819 #[rstest]
820 fn test_price_decimals_clamping() {
821 let meta = PerpMeta {
823 universe: vec![PerpAsset {
824 name: "HIGHPREC".to_string(),
825 sz_decimals: 10, max_leverage: Some(1),
827 only_isolated: None,
828 is_delisted: None,
829 }],
830 margin_tables: vec![],
831 };
832
833 let defs = parse_perp_instruments(&meta).unwrap();
834 assert_eq!(defs[0].price_decimals, 0);
835 assert_eq!(defs[0].tick_size, dec!(1));
836 }
837}