1use anyhow::Context;
17use nautilus_core::{UUID4, UnixNanos};
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, SpotBalance, SpotMeta};
33use crate::{
34 common::{
35 consts::HYPERLIQUID_VENUE,
36 enums::{
37 HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
38 HyperliquidSide, HyperliquidTpSl,
39 },
40 parse::make_fill_trade_id,
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 raw_symbol: Ustr,
66 pub base: Ustr,
68 pub quote: Ustr,
70 pub market_type: HyperliquidMarketType,
72 pub asset_index: u32,
76 pub price_decimals: u32,
78 pub size_decimals: u32,
80 pub tick_size: Decimal,
82 pub lot_size: Decimal,
84 pub max_leverage: Option<u32>,
86 pub only_isolated: bool,
88 pub is_hip3: bool,
90 pub active: bool,
92 pub raw_data: String,
94}
95
96#[must_use]
103fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
104 if value.bytes().any(|b| b == b'*' || b == b'?') {
105 let mut out = String::with_capacity(value.len());
106 for ch in value.chars() {
107 out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
108 }
109 std::borrow::Cow::Owned(out)
110 } else {
111 std::borrow::Cow::Borrowed(value)
112 }
113}
114
115pub fn parse_perp_instruments(
129 meta: &PerpMeta,
130 asset_index_base: u32,
131) -> Result<Vec<HyperliquidInstrumentDef>, String> {
132 const PERP_MAX_DECIMALS: i32 = 6;
133
134 let mut defs = Vec::new();
135
136 for (index, asset) in meta.universe.iter().enumerate() {
137 let is_delisted = asset.is_delisted.unwrap_or(false);
138
139 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
140 let tick_size = pow10_neg(price_decimals);
141 let lot_size = pow10_neg(asset.sz_decimals);
142
143 let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
144
145 let raw_symbol: Ustr = asset.name.as_str().into();
146
147 let def = HyperliquidInstrumentDef {
148 symbol: symbol.into(),
149 raw_symbol,
150 base: asset.name.clone().into(),
151 quote: "USD".into(),
152 market_type: HyperliquidMarketType::Perp,
153 asset_index: asset_index_base + index as u32,
154 price_decimals,
155 size_decimals: asset.sz_decimals,
156 tick_size,
157 lot_size,
158 max_leverage: asset.max_leverage,
159 only_isolated: asset.only_isolated.unwrap_or(false),
160 is_hip3: asset_index_base > 0,
161 active: !is_delisted,
162 raw_data: serde_json::to_string(asset).unwrap_or_default(),
163 };
164
165 defs.push(def);
166 }
167
168 Ok(defs)
169}
170
171pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
179 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
183
184 let mut tokens_by_index = ahash::AHashMap::new();
186 for token in &meta.tokens {
187 tokens_by_index.insert(token.index, token);
188 }
189
190 for pair in &meta.universe {
191 let base_token = tokens_by_index
195 .get(&pair.tokens[0])
196 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
197 let quote_token = tokens_by_index
198 .get(&pair.tokens[1])
199 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
200
201 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
202 let tick_size = pow10_neg(price_decimals);
203 let lot_size = pow10_neg(base_token.sz_decimals);
204
205 let symbol = format!(
206 "{}-{}-SPOT",
207 sanitize_symbol(&base_token.name),
208 sanitize_symbol("e_token.name),
209 );
210
211 let raw_symbol: Ustr = if base_token.name == "PURR" {
215 pair.name.as_str().into()
216 } else {
217 format!("@{}", pair.index).into()
218 };
219
220 let def = HyperliquidInstrumentDef {
221 symbol: symbol.into(),
222 raw_symbol,
223 base: base_token.name.clone().into(),
224 quote: quote_token.name.clone().into(),
225 market_type: HyperliquidMarketType::Spot,
226 asset_index: SPOT_INDEX_OFFSET + pair.index,
227 price_decimals,
228 size_decimals: base_token.sz_decimals,
229 tick_size,
230 lot_size,
231 max_leverage: None,
232 only_isolated: false,
233 is_hip3: false,
234 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
236 };
237
238 defs.push(def);
239 }
240
241 defs.sort_by(|a, b| {
246 b.active
247 .cmp(&a.active)
248 .then(a.asset_index.cmp(&b.asset_index))
249 });
250
251 Ok(defs)
252}
253
254fn pow10_neg(decimals: u32) -> Decimal {
255 if decimals == 0 {
256 return Decimal::ONE;
257 }
258
259 Decimal::from_i128_with_scale(1, decimals)
261}
262
263pub fn get_currency(code: &str) -> Currency {
264 Currency::try_from_str(code).unwrap_or_else(|| {
265 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
266 if let Err(e) = Currency::register(currency, false) {
267 log::error!("Failed to register currency '{code}': {e}");
268 }
269 currency
270 })
271}
272
273#[must_use]
277pub fn create_instrument_from_def(
278 def: &HyperliquidInstrumentDef,
279 ts_init: UnixNanos,
280) -> Option<InstrumentAny> {
281 let symbol = Symbol::new(def.symbol);
282 let venue = *HYPERLIQUID_VENUE;
283 let instrument_id = InstrumentId::new(symbol, venue);
284
285 let raw_symbol = Symbol::new(def.raw_symbol);
290 let base_currency = get_currency(&def.base);
291 let quote_currency = get_currency(&def.quote);
292 let price_increment = Price::from(def.tick_size.to_string());
293 let size_increment = Quantity::from(def.lot_size.to_string());
294
295 match def.market_type {
296 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
297 instrument_id,
298 raw_symbol,
299 base_currency,
300 quote_currency,
301 def.price_decimals as u8,
302 def.size_decimals as u8,
303 price_increment,
304 size_increment,
305 None,
306 None,
307 None,
308 None,
309 None,
310 None,
311 None,
312 None,
313 None,
314 None,
315 None,
316 None,
317 None,
318 ts_init, ts_init,
320 ))),
321 HyperliquidMarketType::Perp => {
322 let settlement_currency = get_currency("USDC");
323
324 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
325 instrument_id,
326 raw_symbol,
327 base_currency,
328 quote_currency,
329 settlement_currency,
330 false,
331 def.price_decimals as u8,
332 def.size_decimals as u8,
333 price_increment,
334 size_increment,
335 None, None,
337 None,
338 None,
339 None,
340 None,
341 None,
342 None,
343 None,
344 None,
345 None,
346 None,
347 None,
348 ts_init, ts_init,
350 )))
351 }
352 }
353}
354
355#[must_use]
358pub fn instruments_from_defs(
359 defs: &[HyperliquidInstrumentDef],
360 ts_init: UnixNanos,
361) -> Vec<InstrumentAny> {
362 defs.iter()
363 .filter_map(|def| create_instrument_from_def(def, ts_init))
364 .collect()
365}
366
367#[must_use]
369pub fn instruments_from_defs_owned(
370 defs: Vec<HyperliquidInstrumentDef>,
371 ts_init: UnixNanos,
372) -> Vec<InstrumentAny> {
373 defs.into_iter()
374 .filter_map(|def| create_instrument_from_def(&def, ts_init))
375 .collect()
376}
377
378fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
379 match side {
380 HyperliquidSide::Buy => OrderSide::Buy,
381 HyperliquidSide::Sell => OrderSide::Sell,
382 }
383}
384
385pub fn parse_order_status_report_from_ws(
391 order_data: &WsOrderData,
392 instrument: &dyn Instrument,
393 account_id: AccountId,
394 ts_init: UnixNanos,
395) -> anyhow::Result<OrderStatusReport> {
396 parse_order_status_report_from_basic(
397 &order_data.order,
398 &order_data.status,
399 instrument,
400 account_id,
401 ts_init,
402 )
403}
404
405pub fn parse_order_status_report_from_basic(
411 order: &WsBasicOrderData,
412 status: &HyperliquidOrderStatusEnum,
413 instrument: &dyn Instrument,
414 account_id: AccountId,
415 ts_init: UnixNanos,
416) -> anyhow::Result<OrderStatusReport> {
417 let instrument_id = instrument.id();
418 let venue_order_id = VenueOrderId::new(order.oid.to_string());
419 let order_side = OrderSide::from(order.side);
420
421 let order_type = if order.trigger_px.is_some() {
423 if order.is_market == Some(true) {
424 match order.tpsl.as_ref() {
426 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
427 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
428 _ => OrderType::StopMarket,
429 }
430 } else {
431 match order.tpsl.as_ref() {
432 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
433 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
434 _ => OrderType::StopLimit,
435 }
436 }
437 } else {
438 OrderType::Limit
439 };
440
441 let time_in_force = TimeInForce::Gtc;
442 let order_status = OrderStatus::from(*status);
443
444 let price_precision = instrument.price_precision();
445 let size_precision = instrument.size_precision();
446
447 let orig_sz: Decimal = order
448 .orig_sz
449 .parse()
450 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
451 let current_sz: Decimal = order
452 .sz
453 .parse()
454 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
455
456 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
457 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
458 let filled_sz = orig_sz.abs() - current_sz.abs();
459 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
460 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
461
462 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
463 let ts_last = ts_accepted;
464 let report_id = UUID4::new();
465
466 let mut report = OrderStatusReport::new(
467 account_id,
468 instrument_id,
469 None, venue_order_id,
471 order_side,
472 order_type,
473 time_in_force,
474 order_status,
475 quantity,
476 filled_qty,
477 ts_accepted,
478 ts_last,
479 ts_init,
480 Some(report_id),
481 );
482
483 if let Some(cloid) = &order.cloid {
485 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
486 }
487
488 if !matches!(
492 order_status,
493 OrderStatus::Filled | OrderStatus::PartiallyFilled
494 ) {
495 let limit_px: Decimal = order
496 .limit_px
497 .parse()
498 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
499 let price = Price::from_decimal_dp(limit_px, price_precision)
500 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
501 report = report.with_price(price);
502 }
503
504 if let Some(trigger_px) = &order.trigger_px {
506 let trig_px: Decimal = trigger_px
507 .parse()
508 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
509 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
510 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
511 report = report
512 .with_trigger_price(trigger_price)
513 .with_trigger_type(TriggerType::Default);
514 }
515
516 Ok(report)
517}
518
519pub fn parse_fill_report(
525 fill: &HyperliquidFill,
526 instrument: &dyn Instrument,
527 account_id: AccountId,
528 ts_init: UnixNanos,
529) -> anyhow::Result<FillReport> {
530 let instrument_id = instrument.id();
531 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
532
533 if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
534 log::warn!(
535 "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
536 fill.oid,
537 fill.px,
538 fill.sz,
539 );
540 }
541
542 let trade_id = make_fill_trade_id(
543 &fill.hash,
544 fill.oid,
545 &fill.px,
546 &fill.sz,
547 fill.time,
548 &fill.start_position,
549 );
550 let order_side = parse_fill_side(&fill.side);
551
552 let price_precision = instrument.price_precision();
553 let size_precision = instrument.size_precision();
554
555 let px: Decimal = fill
556 .px
557 .parse()
558 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
559 let sz: Decimal = fill
560 .sz
561 .parse()
562 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
563
564 let last_px = Price::from_decimal_dp(px, price_precision)
565 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
566 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
567 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
568
569 let fee_amount: Decimal = fill
570 .fee
571 .parse()
572 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
573
574 let fee_currency: Currency = fill
575 .fee_token
576 .parse()
577 .map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
578 let commission = Money::from_decimal(fee_amount, fee_currency)
579 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
580
581 let liquidity_side = if fill.crossed {
583 LiquiditySide::Taker
584 } else {
585 LiquiditySide::Maker
586 };
587
588 let ts_event = UnixNanos::from(fill.time * 1_000_000);
589 let report_id = UUID4::new();
590
591 let report = FillReport::new(
592 account_id,
593 instrument_id,
594 venue_order_id,
595 trade_id,
596 order_side,
597 last_qty,
598 last_px,
599 commission,
600 liquidity_side,
601 None, None, ts_event,
604 ts_init,
605 Some(report_id),
606 );
607
608 Ok(report)
609}
610
611pub fn parse_position_status_report(
617 position_data: &serde_json::Value,
618 instrument: &dyn Instrument,
619 account_id: AccountId,
620 ts_init: UnixNanos,
621) -> anyhow::Result<PositionStatusReport> {
622 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
624 .context("failed to deserialize AssetPosition")?;
625
626 let position = &asset_position.position;
627 let instrument_id = instrument.id();
628
629 let (position_side, quantity_value) = if position.szi.is_zero() {
631 (PositionSideSpecified::Flat, Decimal::ZERO)
632 } else if position.szi.is_sign_positive() {
633 (PositionSideSpecified::Long, position.szi)
634 } else {
635 (PositionSideSpecified::Short, position.szi.abs())
636 };
637
638 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
639 .context("failed to create quantity from decimal")?;
640 let report_id = UUID4::new();
641 let ts_last = ts_init;
642 let avg_px_open = position.entry_px;
643
644 Ok(PositionStatusReport::new(
646 account_id,
647 instrument_id,
648 position_side,
649 quantity,
650 ts_last,
651 ts_init,
652 Some(report_id),
653 None, avg_px_open,
655 ))
656}
657
658pub fn parse_spot_position_status_report(
668 balance: &SpotBalance,
669 instrument: &dyn Instrument,
670 account_id: AccountId,
671 ts_init: UnixNanos,
672) -> anyhow::Result<PositionStatusReport> {
673 let (position_side, quantity_value) = if balance.total.is_zero() {
674 (PositionSideSpecified::Flat, Decimal::ZERO)
675 } else {
676 (PositionSideSpecified::Long, balance.total)
677 };
678
679 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
680 .context("failed to create spot quantity from decimal")?;
681
682 Ok(PositionStatusReport::new(
683 account_id,
684 instrument.id(),
685 position_side,
686 quantity,
687 ts_init,
688 ts_init,
689 Some(UUID4::new()),
690 None,
691 balance.avg_entry_px(),
692 ))
693}
694
695#[cfg(test)]
696mod tests {
697 use rstest::rstest;
698 use rust_decimal_macros::dec;
699
700 use super::{
701 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
702 *,
703 };
704
705 #[rstest]
706 fn test_parse_fill_side() {
707 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
708 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
709 }
710
711 #[rstest]
712 fn test_pow10_neg() {
713 assert_eq!(pow10_neg(0), dec!(1));
714 assert_eq!(pow10_neg(1), dec!(0.1));
715 assert_eq!(pow10_neg(5), dec!(0.00001));
716 }
717
718 #[rstest]
719 fn test_parse_perp_instruments() {
720 let meta = PerpMeta {
721 universe: vec![
722 PerpAsset {
723 name: "BTC".to_string(),
724 sz_decimals: 5,
725 max_leverage: Some(50),
726 ..Default::default()
727 },
728 PerpAsset {
729 name: "DELIST".to_string(),
730 sz_decimals: 3,
731 max_leverage: Some(10),
732 only_isolated: Some(true),
733 is_delisted: Some(true),
734 ..Default::default()
735 },
736 ],
737 margin_tables: vec![],
738 };
739
740 let defs = parse_perp_instruments(&meta, 0).unwrap();
741
742 assert_eq!(defs.len(), 2);
744
745 let btc = &defs[0];
746 assert_eq!(btc.symbol, "BTC-USD-PERP");
747 assert_eq!(btc.base, "BTC");
748 assert_eq!(btc.quote, "USD");
749 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
750 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
752 assert_eq!(btc.tick_size, dec!(0.1));
753 assert_eq!(btc.lot_size, dec!(0.00001));
754 assert_eq!(btc.max_leverage, Some(50));
755 assert!(!btc.only_isolated);
756 assert!(btc.active);
757
758 let delist = &defs[1];
759 assert_eq!(delist.symbol, "DELIST-USD-PERP");
760 assert_eq!(delist.base, "DELIST");
761 assert!(!delist.active); }
763
764 use crate::common::testing::load_test_data;
765
766 #[rstest]
767 fn test_parse_perp_instruments_from_real_data() {
768 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
769
770 let defs = parse_perp_instruments(&meta, 0).unwrap();
771
772 assert_eq!(defs.len(), 3);
774
775 let btc = &defs[0];
777 assert_eq!(btc.symbol, "BTC-USD-PERP");
778 assert_eq!(btc.base, "BTC");
779 assert_eq!(btc.quote, "USD");
780 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
781 assert_eq!(btc.size_decimals, 5);
782 assert_eq!(btc.max_leverage, Some(40));
783 assert!(btc.active);
784
785 let eth = &defs[1];
787 assert_eq!(eth.symbol, "ETH-USD-PERP");
788 assert_eq!(eth.base, "ETH");
789 assert_eq!(eth.size_decimals, 4);
790 assert_eq!(eth.max_leverage, Some(25));
791
792 let atom = &defs[2];
794 assert_eq!(atom.symbol, "ATOM-USD-PERP");
795 assert_eq!(atom.base, "ATOM");
796 assert_eq!(atom.size_decimals, 2);
797 assert_eq!(atom.max_leverage, Some(5));
798 }
799
800 #[rstest]
801 fn test_deserialize_l2_book_from_real_data() {
802 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
803
804 assert_eq!(book.coin, "BTC");
806 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];
812 let asks = &book.levels[1];
813
814 for i in 1..bids.len() {
816 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
817 let curr_price = bids[i].px.parse::<f64>().unwrap();
818 assert!(prev_price >= curr_price, "Bids should be descending");
819 }
820
821 for i in 1..asks.len() {
823 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
824 let curr_price = asks[i].px.parse::<f64>().unwrap();
825 assert!(prev_price <= curr_price, "Asks should be ascending");
826 }
827 }
828
829 #[rstest]
830 fn test_parse_spot_instruments() {
831 let tokens = vec![
832 SpotToken {
833 name: "USDC".to_string(),
834 sz_decimals: 6,
835 wei_decimals: 6,
836 index: 0,
837 token_id: "0x1".to_string(),
838 is_canonical: true,
839 evm_contract: None,
840 full_name: None,
841 deployer_trading_fee_share: None,
842 },
843 SpotToken {
844 name: "PURR".to_string(),
845 sz_decimals: 0,
846 wei_decimals: 5,
847 index: 1,
848 token_id: "0x2".to_string(),
849 is_canonical: true,
850 evm_contract: None,
851 full_name: None,
852 deployer_trading_fee_share: None,
853 },
854 ];
855
856 let pairs = vec![
857 SpotPair {
858 name: "PURR/USDC".to_string(),
859 tokens: [1, 0], index: 0,
861 is_canonical: true,
862 },
863 SpotPair {
864 name: "ALIAS".to_string(),
865 tokens: [1, 0],
866 index: 1,
867 is_canonical: false, },
869 ];
870
871 let meta = SpotMeta {
872 tokens,
873 universe: pairs,
874 };
875
876 let defs = parse_spot_instruments(&meta).unwrap();
877
878 assert_eq!(defs.len(), 2);
880
881 let purr_usdc = &defs[0];
882 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
883 assert_eq!(purr_usdc.base, "PURR");
884 assert_eq!(purr_usdc.quote, "USDC");
885 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
886 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
888 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
889 assert_eq!(purr_usdc.lot_size, dec!(1));
890 assert_eq!(purr_usdc.max_leverage, None);
891 assert!(!purr_usdc.only_isolated);
892 assert!(purr_usdc.active);
893
894 let alias = &defs[1];
895 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
896 assert_eq!(alias.base, "PURR");
897 assert!(!alias.active); }
899
900 #[rstest]
901 fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
902 let tokens = vec![
906 SpotToken {
907 name: "USDC".to_string(),
908 sz_decimals: 6,
909 wei_decimals: 6,
910 index: 0,
911 token_id: "0x1".to_string(),
912 is_canonical: true,
913 evm_contract: None,
914 full_name: None,
915 deployer_trading_fee_share: None,
916 },
917 SpotToken {
918 name: "HYPE".to_string(),
919 sz_decimals: 2,
920 wei_decimals: 8,
921 index: 150,
922 token_id: "0x2".to_string(),
923 is_canonical: true,
924 evm_contract: None,
925 full_name: None,
926 deployer_trading_fee_share: None,
927 },
928 ];
929
930 let pairs = vec![
931 SpotPair {
932 name: "HYPE_OLD".to_string(),
933 tokens: [150, 0],
934 index: 3,
935 is_canonical: false,
936 },
937 SpotPair {
938 name: "HYPE".to_string(),
939 tokens: [150, 0],
940 index: 107,
941 is_canonical: true,
942 },
943 ];
944
945 let defs = parse_spot_instruments(&SpotMeta {
946 tokens,
947 universe: pairs,
948 })
949 .unwrap();
950
951 assert_eq!(defs.len(), 2);
952 assert!(defs[0].active, "canonical must sort first");
953 assert_eq!(defs[0].asset_index, 10000 + 107);
954 assert!(!defs[1].active);
955 assert_eq!(defs[1].asset_index, 10000 + 3);
956 }
957
958 #[rstest]
959 fn test_price_decimals_clamping() {
960 let meta = PerpMeta {
961 universe: vec![PerpAsset {
962 name: "HIGHPREC".to_string(),
963 sz_decimals: 10, max_leverage: Some(1),
965 ..Default::default()
966 }],
967 margin_tables: vec![],
968 };
969
970 let defs = parse_perp_instruments(&meta, 0).unwrap();
971 assert_eq!(defs[0].price_decimals, 0);
972 assert_eq!(defs[0].tick_size, dec!(1));
973 }
974
975 #[rstest]
976 fn test_parse_perp_instruments_hip3_dex() {
977 let meta = PerpMeta {
979 universe: vec![
980 PerpAsset {
981 name: "xyz:TSLA".to_string(),
982 sz_decimals: 3,
983 max_leverage: Some(10),
984 only_isolated: None,
985 is_delisted: None,
986 growth_mode: Some("enabled".to_string()),
987 margin_mode: Some("strictIsolated".to_string()),
988 },
989 PerpAsset {
990 name: "xyz:NVDA".to_string(),
991 sz_decimals: 3,
992 max_leverage: Some(20),
993 only_isolated: None,
994 is_delisted: None,
995 growth_mode: None,
996 margin_mode: None,
997 },
998 ],
999 margin_tables: vec![],
1000 };
1001
1002 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1003 assert_eq!(defs.len(), 2);
1004
1005 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1007 assert!(defs[0].symbol.contains(':'));
1008 assert_eq!(defs[0].base, "xyz:TSLA");
1009 assert_eq!(defs[0].asset_index, 110_000);
1010 assert!(defs[0].active);
1011
1012 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1013 assert_eq!(defs[1].asset_index, 110_001);
1014 }
1015
1016 #[rstest]
1017 #[case("BTC", "BTC")]
1018 #[case("kPEPE", "kPEPE")]
1019 #[case("xyz:TSLA", "xyz:TSLA")]
1020 #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1021 #[case("ABC?", "ABCx")]
1022 #[case("a*b?c", "axbxc")]
1023 fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1024 assert_eq!(sanitize_symbol(input), expected);
1025 }
1026
1027 #[rstest]
1028 fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1029 let tokens = vec![
1033 SpotToken {
1034 name: "USDC".to_string(),
1035 sz_decimals: 6,
1036 wei_decimals: 6,
1037 index: 0,
1038 token_id: "0x1".to_string(),
1039 is_canonical: true,
1040 evm_contract: None,
1041 full_name: None,
1042 deployer_trading_fee_share: None,
1043 },
1044 SpotToken {
1045 name: "ABC?".to_string(),
1046 sz_decimals: 4,
1047 wei_decimals: 4,
1048 index: 1,
1049 token_id: "0x2".to_string(),
1050 is_canonical: true,
1051 evm_contract: None,
1052 full_name: None,
1053 deployer_trading_fee_share: None,
1054 },
1055 ];
1056
1057 let pairs = vec![SpotPair {
1058 name: "ABC?/USDC".to_string(),
1059 tokens: [1, 0],
1060 index: 50,
1061 is_canonical: true,
1062 }];
1063
1064 let meta = SpotMeta {
1065 tokens,
1066 universe: pairs,
1067 };
1068
1069 let defs = parse_spot_instruments(&meta).unwrap();
1070 assert_eq!(defs.len(), 1);
1071 assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1072 assert_eq!(defs[0].base, "ABC?");
1073 assert_eq!(defs[0].quote, "USDC");
1074 }
1075
1076 #[rstest]
1077 fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1078 let meta = PerpMeta {
1079 universe: vec![PerpAsset {
1080 name: "dex:STREAMABCD****".to_string(),
1081 sz_decimals: 3,
1082 max_leverage: Some(10),
1083 only_isolated: None,
1084 is_delisted: None,
1085 growth_mode: None,
1086 margin_mode: None,
1087 }],
1088 margin_tables: vec![],
1089 };
1090
1091 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1092 assert_eq!(defs.len(), 1);
1093 assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1094 assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1095 assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1096 }
1097}