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, SpotMeta};
33use crate::{
34 common::{
35 consts::HYPERLIQUID_VENUE,
36 enums::{
37 HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
38 },
39 parse::make_fill_trade_id,
40 },
41 websocket::messages::{WsBasicOrderData, WsOrderData},
42};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum HyperliquidMarketType {
47 Perp,
49 Spot,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct HyperliquidInstrumentDef {
59 pub symbol: Ustr,
61 pub raw_symbol: Ustr,
65 pub base: Ustr,
67 pub quote: Ustr,
69 pub market_type: HyperliquidMarketType,
71 pub asset_index: u32,
75 pub price_decimals: u32,
77 pub size_decimals: u32,
79 pub tick_size: Decimal,
81 pub lot_size: Decimal,
83 pub max_leverage: Option<u32>,
85 pub only_isolated: bool,
87 pub is_hip3: bool,
89 pub active: bool,
91 pub raw_data: String,
93}
94
95pub fn parse_perp_instruments(
109 meta: &PerpMeta,
110 asset_index_base: u32,
111) -> Result<Vec<HyperliquidInstrumentDef>, String> {
112 const PERP_MAX_DECIMALS: i32 = 6;
113
114 let mut defs = Vec::new();
115
116 for (index, asset) in meta.universe.iter().enumerate() {
117 let is_delisted = asset.is_delisted.unwrap_or(false);
118
119 let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
120 let tick_size = pow10_neg(price_decimals);
121 let lot_size = pow10_neg(asset.sz_decimals);
122
123 let symbol = format!("{}-USD-PERP", asset.name);
124
125 let raw_symbol: Ustr = asset.name.as_str().into();
126
127 let def = HyperliquidInstrumentDef {
128 symbol: symbol.into(),
129 raw_symbol,
130 base: asset.name.clone().into(),
131 quote: "USD".into(),
132 market_type: HyperliquidMarketType::Perp,
133 asset_index: asset_index_base + index as u32,
134 price_decimals,
135 size_decimals: asset.sz_decimals,
136 tick_size,
137 lot_size,
138 max_leverage: asset.max_leverage,
139 only_isolated: asset.only_isolated.unwrap_or(false),
140 is_hip3: asset_index_base > 0,
141 active: !is_delisted,
142 raw_data: serde_json::to_string(asset).unwrap_or_default(),
143 };
144
145 defs.push(def);
146 }
147
148 Ok(defs)
149}
150
151pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
159 const SPOT_MAX_DECIMALS: i32 = 8; const SPOT_INDEX_OFFSET: u32 = 10000; let mut defs = Vec::new();
163
164 let mut tokens_by_index = ahash::AHashMap::new();
166 for token in &meta.tokens {
167 tokens_by_index.insert(token.index, token);
168 }
169
170 for pair in &meta.universe {
171 let base_token = tokens_by_index
175 .get(&pair.tokens[0])
176 .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
177 let quote_token = tokens_by_index
178 .get(&pair.tokens[1])
179 .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
180
181 let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
182 let tick_size = pow10_neg(price_decimals);
183 let lot_size = pow10_neg(base_token.sz_decimals);
184
185 let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
186
187 let raw_symbol: Ustr = if base_token.name == "PURR" {
191 pair.name.as_str().into()
192 } else {
193 format!("@{}", pair.index).into()
194 };
195
196 let def = HyperliquidInstrumentDef {
197 symbol: symbol.into(),
198 raw_symbol,
199 base: base_token.name.clone().into(),
200 quote: quote_token.name.clone().into(),
201 market_type: HyperliquidMarketType::Spot,
202 asset_index: SPOT_INDEX_OFFSET + pair.index,
203 price_decimals,
204 size_decimals: base_token.sz_decimals,
205 tick_size,
206 lot_size,
207 max_leverage: None,
208 only_isolated: false,
209 is_hip3: false,
210 active: pair.is_canonical, raw_data: serde_json::to_string(pair).unwrap_or_default(),
212 };
213
214 defs.push(def);
215 }
216
217 Ok(defs)
218}
219
220fn pow10_neg(decimals: u32) -> Decimal {
221 if decimals == 0 {
222 return Decimal::ONE;
223 }
224
225 Decimal::from_i128_with_scale(1, decimals)
227}
228
229pub fn get_currency(code: &str) -> Currency {
230 Currency::try_from_str(code).unwrap_or_else(|| {
231 let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
232 if let Err(e) = Currency::register(currency, false) {
233 log::error!("Failed to register currency '{code}': {e}");
234 }
235 currency
236 })
237}
238
239#[must_use]
243pub fn create_instrument_from_def(
244 def: &HyperliquidInstrumentDef,
245 ts_init: UnixNanos,
246) -> Option<InstrumentAny> {
247 let symbol = Symbol::new(def.symbol);
248 let venue = *HYPERLIQUID_VENUE;
249 let instrument_id = InstrumentId::new(symbol, venue);
250
251 let raw_symbol = Symbol::new(def.raw_symbol);
256 let base_currency = get_currency(&def.base);
257 let quote_currency = get_currency(&def.quote);
258 let price_increment = Price::from(def.tick_size.to_string());
259 let size_increment = Quantity::from(def.lot_size.to_string());
260
261 match def.market_type {
262 HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
263 instrument_id,
264 raw_symbol,
265 base_currency,
266 quote_currency,
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 None,
284 ts_init, ts_init,
286 ))),
287 HyperliquidMarketType::Perp => {
288 let settlement_currency = get_currency("USDC");
289
290 Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
291 instrument_id,
292 raw_symbol,
293 base_currency,
294 quote_currency,
295 settlement_currency,
296 false,
297 def.price_decimals as u8,
298 def.size_decimals as u8,
299 price_increment,
300 size_increment,
301 None, None,
303 None,
304 None,
305 None,
306 None,
307 None,
308 None,
309 None,
310 None,
311 None,
312 None,
313 None,
314 ts_init, ts_init,
316 )))
317 }
318 }
319}
320
321#[must_use]
324pub fn instruments_from_defs(
325 defs: &[HyperliquidInstrumentDef],
326 ts_init: UnixNanos,
327) -> Vec<InstrumentAny> {
328 defs.iter()
329 .filter_map(|def| create_instrument_from_def(def, ts_init))
330 .collect()
331}
332
333#[must_use]
335pub fn instruments_from_defs_owned(
336 defs: Vec<HyperliquidInstrumentDef>,
337 ts_init: UnixNanos,
338) -> Vec<InstrumentAny> {
339 defs.into_iter()
340 .filter_map(|def| create_instrument_from_def(&def, ts_init))
341 .collect()
342}
343
344fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
345 match side {
346 HyperliquidSide::Buy => OrderSide::Buy,
347 HyperliquidSide::Sell => OrderSide::Sell,
348 }
349}
350
351pub fn parse_order_status_report_from_ws(
357 order_data: &WsOrderData,
358 instrument: &dyn Instrument,
359 account_id: AccountId,
360 ts_init: UnixNanos,
361) -> anyhow::Result<OrderStatusReport> {
362 parse_order_status_report_from_basic(
363 &order_data.order,
364 &order_data.status,
365 instrument,
366 account_id,
367 ts_init,
368 )
369}
370
371pub fn parse_order_status_report_from_basic(
377 order: &WsBasicOrderData,
378 status: &HyperliquidOrderStatusEnum,
379 instrument: &dyn Instrument,
380 account_id: AccountId,
381 ts_init: UnixNanos,
382) -> anyhow::Result<OrderStatusReport> {
383 let instrument_id = instrument.id();
384 let venue_order_id = VenueOrderId::new(order.oid.to_string());
385 let order_side = OrderSide::from(order.side);
386
387 let order_type = if order.trigger_px.is_some() {
389 if order.is_market == Some(true) {
390 match order.tpsl.as_ref() {
392 Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
393 Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
394 _ => OrderType::StopMarket,
395 }
396 } else {
397 match order.tpsl.as_ref() {
398 Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
399 Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
400 _ => OrderType::StopLimit,
401 }
402 }
403 } else {
404 OrderType::Limit
405 };
406
407 let time_in_force = TimeInForce::Gtc;
408 let order_status = OrderStatus::from(*status);
409
410 let price_precision = instrument.price_precision();
411 let size_precision = instrument.size_precision();
412
413 let orig_sz: Decimal = order
414 .orig_sz
415 .parse()
416 .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
417 let current_sz: Decimal = order
418 .sz
419 .parse()
420 .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
421
422 let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
423 .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
424 let filled_sz = orig_sz.abs() - current_sz.abs();
425 let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
426 .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
427
428 let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
429 let ts_last = ts_accepted;
430 let report_id = UUID4::new();
431
432 let mut report = OrderStatusReport::new(
433 account_id,
434 instrument_id,
435 None, venue_order_id,
437 order_side,
438 order_type,
439 time_in_force,
440 order_status,
441 quantity,
442 filled_qty,
443 ts_accepted,
444 ts_last,
445 ts_init,
446 Some(report_id),
447 );
448
449 if let Some(cloid) = &order.cloid {
451 report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
452 }
453
454 if !matches!(
458 order_status,
459 OrderStatus::Filled | OrderStatus::PartiallyFilled
460 ) {
461 let limit_px: Decimal = order
462 .limit_px
463 .parse()
464 .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
465 let price = Price::from_decimal_dp(limit_px, price_precision)
466 .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
467 report = report.with_price(price);
468 }
469
470 if let Some(trigger_px) = &order.trigger_px {
472 let trig_px: Decimal = trigger_px
473 .parse()
474 .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
475 let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
476 .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
477 report = report
478 .with_trigger_price(trigger_price)
479 .with_trigger_type(TriggerType::Default);
480 }
481
482 Ok(report)
483}
484
485pub fn parse_fill_report(
491 fill: &HyperliquidFill,
492 instrument: &dyn Instrument,
493 account_id: AccountId,
494 ts_init: UnixNanos,
495) -> anyhow::Result<FillReport> {
496 let instrument_id = instrument.id();
497 let venue_order_id = VenueOrderId::new(fill.oid.to_string());
498
499 let trade_id = make_fill_trade_id(
500 &fill.hash,
501 fill.oid,
502 &fill.px,
503 &fill.sz,
504 fill.time,
505 &fill.start_position,
506 );
507 let order_side = parse_fill_side(&fill.side);
508
509 let price_precision = instrument.price_precision();
510 let size_precision = instrument.size_precision();
511
512 let px: Decimal = fill
513 .px
514 .parse()
515 .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
516 let sz: Decimal = fill
517 .sz
518 .parse()
519 .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
520
521 let last_px = Price::from_decimal_dp(px, price_precision)
522 .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
523 let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
524 .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
525
526 let fee_amount: Decimal = fill
527 .fee
528 .parse()
529 .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
530
531 let fee_currency: Currency = fill
532 .fee_token
533 .parse()
534 .map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
535 let commission = Money::from_decimal(fee_amount, fee_currency)
536 .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
537
538 let liquidity_side = if fill.crossed {
540 LiquiditySide::Taker
541 } else {
542 LiquiditySide::Maker
543 };
544
545 let ts_event = UnixNanos::from(fill.time * 1_000_000);
546 let report_id = UUID4::new();
547
548 let report = FillReport::new(
549 account_id,
550 instrument_id,
551 venue_order_id,
552 trade_id,
553 order_side,
554 last_qty,
555 last_px,
556 commission,
557 liquidity_side,
558 None, None, ts_event,
561 ts_init,
562 Some(report_id),
563 );
564
565 Ok(report)
566}
567
568pub fn parse_position_status_report(
574 position_data: &serde_json::Value,
575 instrument: &dyn Instrument,
576 account_id: AccountId,
577 ts_init: UnixNanos,
578) -> anyhow::Result<PositionStatusReport> {
579 let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
581 .context("failed to deserialize AssetPosition")?;
582
583 let position = &asset_position.position;
584 let instrument_id = instrument.id();
585
586 let (position_side, quantity_value) = if position.szi.is_zero() {
588 (PositionSideSpecified::Flat, Decimal::ZERO)
589 } else if position.szi.is_sign_positive() {
590 (PositionSideSpecified::Long, position.szi)
591 } else {
592 (PositionSideSpecified::Short, position.szi.abs())
593 };
594
595 let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
596 .context("failed to create quantity from decimal")?;
597 let report_id = UUID4::new();
598 let ts_last = ts_init;
599 let avg_px_open = position.entry_px;
600
601 Ok(PositionStatusReport::new(
603 account_id,
604 instrument_id,
605 position_side,
606 quantity,
607 ts_last,
608 ts_init,
609 Some(report_id),
610 None, avg_px_open,
612 ))
613}
614
615#[cfg(test)]
616mod tests {
617 use rstest::rstest;
618 use rust_decimal_macros::dec;
619
620 use super::{
621 super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
622 *,
623 };
624
625 #[rstest]
626 fn test_parse_fill_side() {
627 assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
628 assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
629 }
630
631 #[rstest]
632 fn test_pow10_neg() {
633 assert_eq!(pow10_neg(0), dec!(1));
634 assert_eq!(pow10_neg(1), dec!(0.1));
635 assert_eq!(pow10_neg(5), dec!(0.00001));
636 }
637
638 #[rstest]
639 fn test_parse_perp_instruments() {
640 let meta = PerpMeta {
641 universe: vec![
642 PerpAsset {
643 name: "BTC".to_string(),
644 sz_decimals: 5,
645 max_leverage: Some(50),
646 ..Default::default()
647 },
648 PerpAsset {
649 name: "DELIST".to_string(),
650 sz_decimals: 3,
651 max_leverage: Some(10),
652 only_isolated: Some(true),
653 is_delisted: Some(true),
654 ..Default::default()
655 },
656 ],
657 margin_tables: vec![],
658 };
659
660 let defs = parse_perp_instruments(&meta, 0).unwrap();
661
662 assert_eq!(defs.len(), 2);
664
665 let btc = &defs[0];
666 assert_eq!(btc.symbol, "BTC-USD-PERP");
667 assert_eq!(btc.base, "BTC");
668 assert_eq!(btc.quote, "USD");
669 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
670 assert_eq!(btc.price_decimals, 1); assert_eq!(btc.size_decimals, 5);
672 assert_eq!(btc.tick_size, dec!(0.1));
673 assert_eq!(btc.lot_size, dec!(0.00001));
674 assert_eq!(btc.max_leverage, Some(50));
675 assert!(!btc.only_isolated);
676 assert!(btc.active);
677
678 let delist = &defs[1];
679 assert_eq!(delist.symbol, "DELIST-USD-PERP");
680 assert_eq!(delist.base, "DELIST");
681 assert!(!delist.active); }
683
684 use crate::common::testing::load_test_data;
685
686 #[rstest]
687 fn test_parse_perp_instruments_from_real_data() {
688 let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
689
690 let defs = parse_perp_instruments(&meta, 0).unwrap();
691
692 assert_eq!(defs.len(), 3);
694
695 let btc = &defs[0];
697 assert_eq!(btc.symbol, "BTC-USD-PERP");
698 assert_eq!(btc.base, "BTC");
699 assert_eq!(btc.quote, "USD");
700 assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
701 assert_eq!(btc.size_decimals, 5);
702 assert_eq!(btc.max_leverage, Some(40));
703 assert!(btc.active);
704
705 let eth = &defs[1];
707 assert_eq!(eth.symbol, "ETH-USD-PERP");
708 assert_eq!(eth.base, "ETH");
709 assert_eq!(eth.size_decimals, 4);
710 assert_eq!(eth.max_leverage, Some(25));
711
712 let atom = &defs[2];
714 assert_eq!(atom.symbol, "ATOM-USD-PERP");
715 assert_eq!(atom.base, "ATOM");
716 assert_eq!(atom.size_decimals, 2);
717 assert_eq!(atom.max_leverage, Some(5));
718 }
719
720 #[rstest]
721 fn test_deserialize_l2_book_from_real_data() {
722 let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
723
724 assert_eq!(book.coin, "BTC");
726 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];
732 let asks = &book.levels[1];
733
734 for i in 1..bids.len() {
736 let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
737 let curr_price = bids[i].px.parse::<f64>().unwrap();
738 assert!(prev_price >= curr_price, "Bids should be descending");
739 }
740
741 for i in 1..asks.len() {
743 let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
744 let curr_price = asks[i].px.parse::<f64>().unwrap();
745 assert!(prev_price <= curr_price, "Asks should be ascending");
746 }
747 }
748
749 #[rstest]
750 fn test_parse_spot_instruments() {
751 let tokens = vec![
752 SpotToken {
753 name: "USDC".to_string(),
754 sz_decimals: 6,
755 wei_decimals: 6,
756 index: 0,
757 token_id: "0x1".to_string(),
758 is_canonical: true,
759 evm_contract: None,
760 full_name: None,
761 deployer_trading_fee_share: None,
762 },
763 SpotToken {
764 name: "PURR".to_string(),
765 sz_decimals: 0,
766 wei_decimals: 5,
767 index: 1,
768 token_id: "0x2".to_string(),
769 is_canonical: true,
770 evm_contract: None,
771 full_name: None,
772 deployer_trading_fee_share: None,
773 },
774 ];
775
776 let pairs = vec![
777 SpotPair {
778 name: "PURR/USDC".to_string(),
779 tokens: [1, 0], index: 0,
781 is_canonical: true,
782 },
783 SpotPair {
784 name: "ALIAS".to_string(),
785 tokens: [1, 0],
786 index: 1,
787 is_canonical: false, },
789 ];
790
791 let meta = SpotMeta {
792 tokens,
793 universe: pairs,
794 };
795
796 let defs = parse_spot_instruments(&meta).unwrap();
797
798 assert_eq!(defs.len(), 2);
800
801 let purr_usdc = &defs[0];
802 assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
803 assert_eq!(purr_usdc.base, "PURR");
804 assert_eq!(purr_usdc.quote, "USDC");
805 assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
806 assert_eq!(purr_usdc.price_decimals, 8); assert_eq!(purr_usdc.size_decimals, 0);
808 assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
809 assert_eq!(purr_usdc.lot_size, dec!(1));
810 assert_eq!(purr_usdc.max_leverage, None);
811 assert!(!purr_usdc.only_isolated);
812 assert!(purr_usdc.active);
813
814 let alias = &defs[1];
815 assert_eq!(alias.symbol, "PURR-USDC-SPOT");
816 assert_eq!(alias.base, "PURR");
817 assert!(!alias.active); }
819
820 #[rstest]
821 fn test_price_decimals_clamping() {
822 let meta = PerpMeta {
823 universe: vec![PerpAsset {
824 name: "HIGHPREC".to_string(),
825 sz_decimals: 10, max_leverage: Some(1),
827 ..Default::default()
828 }],
829 margin_tables: vec![],
830 };
831
832 let defs = parse_perp_instruments(&meta, 0).unwrap();
833 assert_eq!(defs[0].price_decimals, 0);
834 assert_eq!(defs[0].tick_size, dec!(1));
835 }
836
837 #[rstest]
838 fn test_parse_perp_instruments_hip3_dex() {
839 let meta = PerpMeta {
841 universe: vec![
842 PerpAsset {
843 name: "xyz:TSLA".to_string(),
844 sz_decimals: 3,
845 max_leverage: Some(10),
846 only_isolated: None,
847 is_delisted: None,
848 growth_mode: Some("enabled".to_string()),
849 margin_mode: Some("strictIsolated".to_string()),
850 },
851 PerpAsset {
852 name: "xyz:NVDA".to_string(),
853 sz_decimals: 3,
854 max_leverage: Some(20),
855 only_isolated: None,
856 is_delisted: None,
857 growth_mode: None,
858 margin_mode: None,
859 },
860 ],
861 margin_tables: vec![],
862 };
863
864 let defs = parse_perp_instruments(&meta, 110_000).unwrap();
865 assert_eq!(defs.len(), 2);
866
867 assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
869 assert!(defs[0].symbol.contains(':'));
870 assert_eq!(defs[0].base, "xyz:TSLA");
871 assert_eq!(defs[0].asset_index, 110_000);
872 assert!(defs[0].active);
873
874 assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
875 assert_eq!(defs[1].asset_index, 110_001);
876 }
877}