1use anyhow::Context;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20use super::models::{PerpMeta, SpotMeta};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum HyperliquidMarketType {
25 Perp,
27 Spot,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct HyperliquidInstrumentDef {
37 pub symbol: String,
39 pub base: String,
41 pub quote: String,
43 pub market_type: HyperliquidMarketType,
45 pub price_decimals: u32,
47 pub size_decimals: u32,
49 pub tick_size: Decimal,
51 pub lot_size: Decimal,
53 pub max_leverage: Option<u32>,
55 pub only_isolated: bool,
57 pub active: bool,
59 pub raw_data: String,
61}
62
63pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
74 const PERP_MAX_DECIMALS: i32 = 6; let mut defs = Vec::new();
77
78 for asset in meta.universe.iter() {
79 let is_delisted = asset.is_delisted.unwrap_or(false);
82
83 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
84 let tick_size = pow10_neg(price_decimals)?;
85 let lot_size = pow10_neg(asset.sz_decimals)?;
86
87 let symbol = format!("{}-USD-PERP", asset.name);
88
89 let def = HyperliquidInstrumentDef {
90 symbol,
91 base: asset.name.clone(),
92 quote: "USD".to_string(), market_type: HyperliquidMarketType::Perp,
94 price_decimals,
95 size_decimals: asset.sz_decimals,
96 tick_size,
97 lot_size,
98 max_leverage: asset.max_leverage,
99 only_isolated: asset.only_isolated.unwrap_or(false),
100 active: !is_delisted, raw_data: serde_json::to_string(asset).unwrap_or_default(),
102 };
103
104 defs.push(def);
105 }
106
107 Ok(defs)
108}
109
110pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
118 const SPOT_MAX_DECIMALS: i32 = 8; let mut defs = Vec::new();
121
122 let mut tokens_by_index = std::collections::HashMap::new();
124 for token in &meta.tokens {
125 tokens_by_index.insert(token.index, token);
126 }
127
128 for pair in &meta.universe {
129 let base_token = tokens_by_index
134 .get(&pair.tokens[0])
135 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
136 let quote_token = tokens_by_index
137 .get(&pair.tokens[1])
138 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
139
140 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
141 let tick_size = pow10_neg(price_decimals)?;
142 let lot_size = pow10_neg(base_token.sz_decimals)?;
143
144 let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
145
146 let def = HyperliquidInstrumentDef {
147 symbol,
148 base: base_token.name.clone(),
149 quote: quote_token.name.clone(),
150 market_type: HyperliquidMarketType::Spot,
151 price_decimals,
152 size_decimals: base_token.sz_decimals,
153 tick_size,
154 lot_size,
155 max_leverage: None,
156 only_isolated: false,
157 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
159 };
160
161 defs.push(def);
162 }
163
164 Ok(defs)
165}
166
167fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
171 if decimals == 0 {
172 return Ok(Decimal::ONE);
173 }
174
175 Ok(Decimal::from_i128_with_scale(1, decimals))
177}
178
179use nautilus_core::time::get_atomic_clock_realtime;
184use nautilus_model::{
185 currencies::CURRENCY_MAP,
186 enums::CurrencyType,
187 identifiers::{InstrumentId, Symbol},
188 instruments::{CryptoPerpetual, CurrencyPair, InstrumentAny},
189 types::{Currency, Price, Quantity},
190};
191
192use crate::common::consts::HYPERLIQUID_VENUE;
193
194fn get_currency(code: &str) -> Currency {
195 CURRENCY_MAP
196 .lock()
197 .expect("Failed to acquire CURRENCY_MAP lock")
198 .get(code)
199 .copied()
200 .unwrap_or_else(|| Currency::new(code, 8, 0, code, CurrencyType::Crypto))
201}
202
203#[must_use]
207pub fn create_instrument_from_def(def: &HyperliquidInstrumentDef) -> Option<InstrumentAny> {
208 let clock = get_atomic_clock_realtime();
209 let ts_event = clock.get_time_ns();
210 let ts_init = ts_event;
211
212 let symbol = Symbol::new(&def.symbol);
213 let venue = *HYPERLIQUID_VENUE;
214 let instrument_id = InstrumentId::new(symbol, venue);
215
216 let raw_symbol = Symbol::new(&def.symbol);
217 let base_currency = get_currency(&def.base);
218 let quote_currency = get_currency(&def.quote);
219 let price_increment = Price::from(&def.tick_size.to_string());
220 let size_increment = Quantity::from(&def.lot_size.to_string());
221
222 match def.market_type {
223 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
224 instrument_id,
225 raw_symbol,
226 base_currency,
227 quote_currency,
228 def.price_decimals as u8,
229 def.size_decimals as u8,
230 price_increment,
231 size_increment,
232 None,
233 None,
234 None,
235 None,
236 None,
237 None,
238 None,
239 None,
240 None,
241 None,
242 None,
243 None,
244 ts_event,
245 ts_init,
246 ))),
247 HyperliquidMarketType::Perp => {
248 let settlement_currency = get_currency("USDC");
249
250 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
251 instrument_id,
252 raw_symbol,
253 base_currency,
254 quote_currency,
255 settlement_currency,
256 false,
257 def.price_decimals as u8,
258 def.size_decimals as u8,
259 price_increment,
260 size_increment,
261 None,
262 None,
263 None,
264 None,
265 None,
266 None,
267 None,
268 None,
269 None,
270 None,
271 None,
272 None,
273 ts_event,
274 ts_init,
275 )))
276 }
277 }
278}
279
280#[must_use]
283pub fn instruments_from_defs(defs: &[HyperliquidInstrumentDef]) -> Vec<InstrumentAny> {
284 defs.iter().filter_map(create_instrument_from_def).collect()
285}
286
287#[must_use]
289pub fn instruments_from_defs_owned(defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
290 defs.into_iter()
291 .filter_map(|def| create_instrument_from_def(&def))
292 .collect()
293}
294
295#[cfg(test)]
300mod tests {
301 use std::str::FromStr;
302
303 use rstest::rstest;
304
305 use super::{
306 super::models::{PerpAsset, SpotPair, SpotToken},
307 *,
308 };
309
310 #[rstest]
311 fn test_pow10_neg() {
312 assert_eq!(pow10_neg(0).unwrap(), Decimal::from(1));
313 assert_eq!(pow10_neg(1).unwrap(), Decimal::from_str("0.1").unwrap());
314 assert_eq!(pow10_neg(5).unwrap(), Decimal::from_str("0.00001").unwrap());
315 }
316
317 #[test]
318 fn test_parse_perp_instruments() {
319 let meta = PerpMeta {
320 universe: vec![
321 PerpAsset {
322 name: "BTC".to_string(),
323 sz_decimals: 5,
324 max_leverage: Some(50),
325 only_isolated: None,
326 is_delisted: None,
327 },
328 PerpAsset {
329 name: "DELIST".to_string(),
330 sz_decimals: 3,
331 max_leverage: Some(10),
332 only_isolated: Some(true),
333 is_delisted: Some(true), },
335 ],
336 margin_tables: vec![],
337 };
338
339 let defs = parse_perp_instruments(&meta).unwrap();
340
341 assert_eq!(defs.len(), 2);
343
344 let btc = &defs[0];
345 assert_eq!(btc.symbol, "BTC-USD-PERP");
346 assert_eq!(btc.base, "BTC");
347 assert_eq!(btc.quote, "USD");
348 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
349 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
351 assert_eq!(btc.tick_size, Decimal::from_str("0.1").unwrap());
352 assert_eq!(btc.lot_size, Decimal::from_str("0.00001").unwrap());
353 assert_eq!(btc.max_leverage, Some(50));
354 assert!(!btc.only_isolated);
355 assert!(btc.active);
356
357 let delist = &defs[1];
358 assert_eq!(delist.symbol, "DELIST-USD-PERP");
359 assert_eq!(delist.base, "DELIST");
360 assert!(!delist.active); }
362
363 #[rstest]
364 fn test_parse_spot_instruments() {
365 let tokens = vec![
366 SpotToken {
367 name: "USDC".to_string(),
368 sz_decimals: 6,
369 wei_decimals: 6,
370 index: 0,
371 token_id: "0x1".to_string(),
372 is_canonical: true,
373 evm_contract: None,
374 full_name: None,
375 deployer_trading_fee_share: None,
376 },
377 SpotToken {
378 name: "PURR".to_string(),
379 sz_decimals: 0,
380 wei_decimals: 5,
381 index: 1,
382 token_id: "0x2".to_string(),
383 is_canonical: true,
384 evm_contract: None,
385 full_name: None,
386 deployer_trading_fee_share: None,
387 },
388 ];
389
390 let pairs = vec![
391 SpotPair {
392 name: "PURR/USDC".to_string(),
393 tokens: [1, 0], index: 0,
395 is_canonical: true,
396 },
397 SpotPair {
398 name: "ALIAS".to_string(),
399 tokens: [1, 0],
400 index: 1,
401 is_canonical: false, },
403 ];
404
405 let meta = SpotMeta {
406 tokens,
407 universe: pairs,
408 };
409
410 let defs = parse_spot_instruments(&meta).unwrap();
411
412 assert_eq!(defs.len(), 2);
414
415 let purr_usdc = &defs[0];
416 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
417 assert_eq!(purr_usdc.base, "PURR");
418 assert_eq!(purr_usdc.quote, "USDC");
419 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
420 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
422 assert_eq!(
423 purr_usdc.tick_size,
424 Decimal::from_str("0.00000001").unwrap()
425 );
426 assert_eq!(purr_usdc.lot_size, Decimal::from(1));
427 assert_eq!(purr_usdc.max_leverage, None);
428 assert!(!purr_usdc.only_isolated);
429 assert!(purr_usdc.active);
430
431 let alias = &defs[1];
432 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
433 assert_eq!(alias.base, "PURR");
434 assert!(!alias.active); }
436
437 #[rstest]
438 fn test_price_decimals_clamping() {
439 let meta = PerpMeta {
441 universe: vec![PerpAsset {
442 name: "HIGHPREC".to_string(),
443 sz_decimals: 10, max_leverage: Some(1),
445 only_isolated: None,
446 is_delisted: None,
447 }],
448 margin_tables: vec![],
449 };
450
451 let defs = parse_perp_instruments(&meta).unwrap();
452 assert_eq!(defs[0].price_decimals, 0);
453 assert_eq!(defs[0].tick_size, Decimal::from(1));
454 }
455}
456
457use nautilus_core::{UUID4, UnixNanos};
462use nautilus_model::{
463 enums::{
464 LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSideSpecified, TimeInForce,
465 TriggerType,
466 },
467 identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
468 instruments::Instrument,
469 reports::{FillReport, OrderStatusReport, PositionStatusReport},
470};
471use rust_decimal::prelude::ToPrimitive;
472
473use super::models::HyperliquidFill;
474use crate::{
475 common::enums::HyperliquidSide,
476 websocket::messages::{WsBasicOrderData, WsOrderData},
477};
478
479fn parse_order_side(side: &str) -> OrderSide {
481 match side.to_lowercase().as_str() {
482 "a" | "buy" => OrderSide::Buy,
483 "b" | "sell" => OrderSide::Sell,
484 _ => OrderSide::NoOrderSide,
485 }
486}
487
488fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
490 match side {
491 HyperliquidSide::Buy => OrderSide::Buy,
492 HyperliquidSide::Sell => OrderSide::Sell,
493 }
494}
495
496pub fn parse_order_status(status: &str) -> OrderStatus {
498 match status.to_lowercase().as_str() {
499 "open" => OrderStatus::Accepted,
500 "filled" => OrderStatus::Filled,
501 "canceled" | "cancelled" => OrderStatus::Canceled,
502 "rejected" => OrderStatus::Rejected,
503 "triggered" => OrderStatus::Triggered,
504 "partial_fill" | "partially_filled" => OrderStatus::PartiallyFilled,
505 _ => OrderStatus::Accepted, }
507}
508
509pub fn parse_order_status_report_from_ws(
515 order_data: &WsOrderData,
516 instrument: &dyn Instrument,
517 account_id: AccountId,
518 ts_init: UnixNanos,
519) -> anyhow::Result<OrderStatusReport> {
520 parse_order_status_report_from_basic(
521 &order_data.order,
522 &order_data.status,
523 instrument,
524 account_id,
525 ts_init,
526 )
527}
528
529pub fn parse_order_status_report_from_basic(
535 order: &WsBasicOrderData,
536 status_str: &str,
537 instrument: &dyn Instrument,
538 account_id: AccountId,
539 ts_init: UnixNanos,
540) -> anyhow::Result<OrderStatusReport> {
541 use nautilus_model::types::{Price, Quantity};
542 use rust_decimal::Decimal;
543
544 let instrument_id = instrument.id();
545 let venue_order_id = VenueOrderId::new(order.oid.to_string());
546 let order_side = parse_order_side(&order.side);
547
548 let order_type = if order.trigger_px.is_some() {
550 if order.is_market == Some(true) {
551 match order.tpsl.as_deref() {
553 Some("tp") => OrderType::MarketIfTouched,
554 Some("sl") => OrderType::StopMarket,
555 _ => OrderType::StopMarket,
556 }
557 } else {
558 match order.tpsl.as_deref() {
559 Some("tp") => OrderType::LimitIfTouched,
560 Some("sl") => OrderType::StopLimit,
561 _ => OrderType::StopLimit,
562 }
563 }
564 } else {
565 OrderType::Limit
566 };
567
568 let time_in_force = TimeInForce::Gtc; let order_status = parse_order_status(status_str);
570
571 let price_precision = instrument.price_precision();
573 let size_precision = instrument.size_precision();
574
575 let orig_sz: Decimal = order
576 .orig_sz
577 .parse()
578 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {}", e))?;
579 let current_sz: Decimal = order
580 .sz
581 .parse()
582 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {}", e))?;
583
584 let quantity = Quantity::new(orig_sz.abs().to_f64().unwrap_or(0.0), size_precision);
585 let filled_sz = orig_sz.abs() - current_sz.abs();
586 let filled_qty = Quantity::new(filled_sz.to_f64().unwrap_or(0.0), size_precision);
587
588 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000); let ts_last = ts_accepted;
591
592 let report_id = UUID4::new();
593
594 let mut report = OrderStatusReport::new(
595 account_id,
596 instrument_id,
597 None, venue_order_id,
599 order_side,
600 order_type,
601 time_in_force,
602 order_status,
603 quantity,
604 filled_qty,
605 ts_accepted,
606 ts_last,
607 ts_init,
608 Some(report_id),
609 );
610
611 if let Some(cloid) = &order.cloid {
613 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
614 }
615
616 let limit_px: Decimal = order
618 .limit_px
619 .parse()
620 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {}", e))?;
621 report = report.with_price(Price::new(
622 limit_px.to_f64().unwrap_or(0.0),
623 price_precision,
624 ));
625
626 if let Some(trigger_px) = &order.trigger_px {
628 let trig_px: Decimal = trigger_px
629 .parse()
630 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {}", e))?;
631 report = report
632 .with_trigger_price(Price::new(trig_px.to_f64().unwrap_or(0.0), price_precision))
633 .with_trigger_type(TriggerType::Default);
634 }
635
636 Ok(report)
637}
638
639pub fn parse_fill_report(
645 fill: &HyperliquidFill,
646 instrument: &dyn Instrument,
647 account_id: AccountId,
648 ts_init: UnixNanos,
649) -> anyhow::Result<FillReport> {
650 use nautilus_model::types::{Money, Price, Quantity};
651 use rust_decimal::Decimal;
652
653 let instrument_id = instrument.id();
654 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
655 let trade_id = TradeId::new(format!("{}-{}", fill.hash, fill.time));
656 let order_side = parse_fill_side(&fill.side);
657
658 let price_precision = instrument.price_precision();
660 let size_precision = instrument.size_precision();
661
662 let px: Decimal = fill
663 .px
664 .parse()
665 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {}", e))?;
666 let sz: Decimal = fill
667 .sz
668 .parse()
669 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {}", e))?;
670
671 let last_px = Price::new(px.to_f64().unwrap_or(0.0), price_precision);
672 let last_qty = Quantity::new(sz.abs().to_f64().unwrap_or(0.0), size_precision);
673
674 let fee_amount: Decimal = fill
676 .fee
677 .parse()
678 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {}", e))?;
679
680 let fee_currency = Currency::from("USDC");
682 let commission = Money::new(fee_amount.abs().to_f64().unwrap_or(0.0), fee_currency);
683
684 let liquidity_side = if fill.crossed {
686 LiquiditySide::Taker
687 } else {
688 LiquiditySide::Maker
689 };
690
691 let ts_event = UnixNanos::from(fill.time * 1_000_000); let report_id = UUID4::new();
695
696 let report = FillReport::new(
697 account_id,
698 instrument_id,
699 venue_order_id,
700 trade_id,
701 order_side,
702 last_qty,
703 last_px,
704 commission,
705 liquidity_side,
706 None, None, ts_event,
709 ts_init,
710 Some(report_id),
711 );
712
713 Ok(report)
714}
715
716pub fn parse_position_status_report(
722 position_data: &serde_json::Value,
723 instrument: &dyn Instrument,
724 account_id: AccountId,
725 ts_init: UnixNanos,
726) -> anyhow::Result<PositionStatusReport> {
727 use nautilus_model::types::Quantity;
728
729 use super::models::AssetPosition;
730
731 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
733 .context("Failed to deserialize AssetPosition")?;
734
735 let position = &asset_position.position;
736 let instrument_id = instrument.id();
737
738 let (position_side, quantity_value) = if position.szi.is_zero() {
740 (PositionSideSpecified::Flat, Decimal::ZERO)
741 } else if position.szi.is_sign_positive() {
742 (PositionSideSpecified::Long, position.szi)
743 } else {
744 (PositionSideSpecified::Short, position.szi.abs())
745 };
746
747 let quantity = Quantity::new(
749 quantity_value
750 .to_f64()
751 .context("Failed to convert quantity to f64")?,
752 instrument.size_precision(),
753 );
754
755 let report_id = UUID4::new();
757
758 let ts_last = ts_init;
760
761 let venue_position_id = Some(PositionId::new(format!("{}_{}", account_id, position.coin)));
763
764 let avg_px_open = position.entry_px;
766
767 Ok(PositionStatusReport::new(
768 account_id,
769 instrument_id,
770 position_side,
771 quantity,
772 ts_last,
773 ts_init,
774 Some(report_id),
775 venue_position_id,
776 avg_px_open,
777 ))
778}
779
780#[cfg(test)]
781mod reconciliation_tests {
782 use super::*;
783
784 #[test]
785 fn test_parse_order_side() {
786 assert_eq!(parse_order_side("A"), OrderSide::Buy);
787 assert_eq!(parse_order_side("buy"), OrderSide::Buy);
788 assert_eq!(parse_order_side("B"), OrderSide::Sell);
789 assert_eq!(parse_order_side("sell"), OrderSide::Sell);
790 assert_eq!(parse_order_side("unknown"), OrderSide::NoOrderSide);
791 }
792
793 #[test]
794 fn test_parse_order_status() {
795 assert_eq!(parse_order_status("open"), OrderStatus::Accepted);
796 assert_eq!(parse_order_status("filled"), OrderStatus::Filled);
797 assert_eq!(parse_order_status("canceled"), OrderStatus::Canceled);
798 assert_eq!(parse_order_status("cancelled"), OrderStatus::Canceled);
799 assert_eq!(parse_order_status("rejected"), OrderStatus::Rejected);
800 assert_eq!(parse_order_status("triggered"), OrderStatus::Triggered);
801 }
802
803 #[test]
804 fn test_parse_fill_side() {
805 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
806 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
807 }
808
809 #[test]
810 fn test_parse_order_side_case_insensitive() {
811 assert_eq!(parse_order_side("A"), OrderSide::Buy);
812 assert_eq!(parse_order_side("a"), OrderSide::Buy);
813 assert_eq!(parse_order_side("BUY"), OrderSide::Buy);
814 assert_eq!(parse_order_side("Buy"), OrderSide::Buy);
815 assert_eq!(parse_order_side("B"), OrderSide::Sell);
816 assert_eq!(parse_order_side("b"), OrderSide::Sell);
817 assert_eq!(parse_order_side("SELL"), OrderSide::Sell);
818 assert_eq!(parse_order_side("Sell"), OrderSide::Sell);
819 }
820
821 #[test]
822 fn test_parse_order_status_edge_cases() {
823 assert_eq!(parse_order_status("OPEN"), OrderStatus::Accepted);
824 assert_eq!(parse_order_status("FILLED"), OrderStatus::Filled);
825 assert_eq!(parse_order_status(""), OrderStatus::Accepted);
826 assert_eq!(parse_order_status("unknown_status"), OrderStatus::Accepted);
827 }
828}