Skip to main content

nautilus_hyperliquid/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
24    instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25    reports::{FillReport, OrderStatusReport, PositionStatusReport},
26    types::{Currency, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::models::{HyperliquidFill, PerpMeta, SpotMeta};
33use crate::{
34    common::{
35        consts::HYPERLIQUID_VENUE,
36        enums::{
37            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidSide, HyperliquidTpSl,
38        },
39    },
40    websocket::messages::{WsBasicOrderData, WsOrderData},
41};
42
43/// Market type enumeration for normalized instrument definitions.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum HyperliquidMarketType {
46    /// Perpetual futures contract.
47    Perp,
48    /// Spot trading pair.
49    Spot,
50}
51
52/// Normalized instrument definition produced by this parser.
53///
54/// This deliberately avoids any tight coupling to Nautilus' Cython types.
55/// The InstrumentProvider can later convert this into Nautilus `Instrument`s.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct HyperliquidInstrumentDef {
58    /// Human-readable symbol (e.g., "BTC-USD-PERP", "PURR-USDC-SPOT").
59    pub symbol: Ustr,
60    /// Raw symbol used in Hyperliquid WebSocket subscriptions/messages.
61    /// For perps: base currency (e.g., "BTC").
62    /// For spot: `@{pair_index}` format (e.g., "@107" for HYPE-USDC).
63    pub raw_symbol: Ustr,
64    /// Base currency/asset (e.g., "BTC", "PURR").
65    pub base: Ustr,
66    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
67    pub quote: Ustr,
68    /// Market type (perpetual or spot).
69    pub market_type: HyperliquidMarketType,
70    /// Asset index used for order submission.
71    /// For perps: index in meta.universe (0, 1, 2, ...).
72    /// For spot: 10000 + index in spotMeta.universe.
73    pub asset_index: u32,
74    /// Number of decimal places for price precision.
75    pub price_decimals: u32,
76    /// Number of decimal places for size precision.
77    pub size_decimals: u32,
78    /// Price tick size as decimal.
79    pub tick_size: Decimal,
80    /// Size lot increment as decimal.
81    pub lot_size: Decimal,
82    /// Maximum leverage (for perps).
83    pub max_leverage: Option<u32>,
84    /// Whether requires isolated margin only.
85    pub only_isolated: bool,
86    /// Whether the instrument is active/tradeable.
87    pub active: bool,
88    /// Raw upstream data for debugging.
89    pub raw_data: String,
90}
91
92/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
93///
94/// Hyperliquid perps follow specific rules:
95/// - Quote is always USD (USDC settled)
96/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
97/// - Active = !is_delisted
98///
99/// **Important:** Delisted instruments are included in the returned list but marked as inactive.
100/// This is necessary to support parsing historical data (orders, fills, positions) for instruments
101/// that have been delisted but may still have associated trading history.
102pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
103    const PERP_MAX_DECIMALS: i32 = 6; // Hyperliquid perps price decimal limit
104
105    let mut defs = Vec::new();
106
107    for (index, asset) in meta.universe.iter().enumerate() {
108        // Include delisted assets but mark them as inactive
109        // This allows parsing of historical data for delisted instruments
110        let is_delisted = asset.is_delisted.unwrap_or(false);
111
112        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
113        let tick_size = pow10_neg(price_decimals)?;
114        let lot_size = pow10_neg(asset.sz_decimals)?;
115
116        let symbol = format!("{}-USD-PERP", asset.name);
117
118        // Perps use base currency as raw_symbol (e.g., "BTC")
119        let raw_symbol: Ustr = asset.name.as_str().into();
120
121        let def = HyperliquidInstrumentDef {
122            symbol: symbol.into(),
123            raw_symbol,
124            base: asset.name.clone().into(),
125            quote: "USD".into(), // Hyperliquid perps are USD-quoted (USDC settled)
126            market_type: HyperliquidMarketType::Perp,
127            asset_index: index as u32,
128            price_decimals,
129            size_decimals: asset.sz_decimals,
130            tick_size,
131            lot_size,
132            max_leverage: asset.max_leverage,
133            only_isolated: asset.only_isolated.unwrap_or(false),
134            active: !is_delisted,
135            raw_data: serde_json::to_string(asset).unwrap_or_default(),
136        };
137
138        defs.push(def);
139    }
140
141    Ok(defs)
142}
143
144/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
145///
146/// Hyperliquid spot follows these rules:
147/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
148/// - Size decimals from base token
149/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
150///   for instruments that may have been traded
151pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
152    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
153    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
154
155    let mut defs = Vec::new();
156
157    // Build index -> token lookup
158    let mut tokens_by_index = ahash::AHashMap::new();
159    for token in &meta.tokens {
160        tokens_by_index.insert(token.index, token);
161    }
162
163    for pair in &meta.universe {
164        // Load all pairs (including non-canonical) to support parsing fills/positions
165        // for instruments that may have been traded but are not currently canonical
166
167        let base_token = tokens_by_index
168            .get(&pair.tokens[0])
169            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
170        let quote_token = tokens_by_index
171            .get(&pair.tokens[1])
172            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
173
174        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
175        let tick_size = pow10_neg(price_decimals)?;
176        let lot_size = pow10_neg(base_token.sz_decimals)?;
177
178        let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
179
180        // Hyperliquid spot raw_symbol formats (per API docs):
181        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
182        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
183        let raw_symbol: Ustr = if base_token.name == "PURR" {
184            pair.name.as_str().into()
185        } else {
186            format!("@{}", pair.index).into()
187        };
188
189        let def = HyperliquidInstrumentDef {
190            symbol: symbol.into(),
191            raw_symbol,
192            base: base_token.name.clone().into(),
193            quote: quote_token.name.clone().into(),
194            market_type: HyperliquidMarketType::Spot,
195            asset_index: SPOT_INDEX_OFFSET + pair.index,
196            price_decimals,
197            size_decimals: base_token.sz_decimals,
198            tick_size,
199            lot_size,
200            max_leverage: None,
201            only_isolated: false,
202            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
203            raw_data: serde_json::to_string(pair).unwrap_or_default(),
204        };
205
206        defs.push(def);
207    }
208
209    Ok(defs)
210}
211
212/// Compute 10^(-decimals) as a Decimal.
213///
214/// This uses integer arithmetic to avoid floating-point precision issues.
215fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
216    if decimals == 0 {
217        return Ok(Decimal::ONE);
218    }
219
220    // Build 1 / 10^decimals using integer arithmetic
221    Ok(Decimal::from_i128_with_scale(1, decimals))
222}
223
224pub fn get_currency(code: &str) -> Currency {
225    Currency::try_from_str(code).unwrap_or_else(|| {
226        let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
227        if let Err(e) = Currency::register(currency, false) {
228            log::error!("Failed to register currency '{code}': {e}");
229        }
230        currency
231    })
232}
233
234/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
235///
236/// Returns `None` if the conversion fails (e.g., unsupported market type).
237#[must_use]
238pub fn create_instrument_from_def(
239    def: &HyperliquidInstrumentDef,
240    ts_init: UnixNanos,
241) -> Option<InstrumentAny> {
242    let symbol = Symbol::new(def.symbol);
243    let venue = *HYPERLIQUID_VENUE;
244    let instrument_id = InstrumentId::new(symbol, venue);
245
246    // Use the raw_symbol from the definition which is format-specific:
247    // - Perps: base currency (e.g., "BTC")
248    // - Spot PURR: slash format (e.g., "PURR/USDC")
249    // - Spot others: @{index} format (e.g., "@107")
250    let raw_symbol = Symbol::new(def.raw_symbol);
251    let base_currency = get_currency(&def.base);
252    let quote_currency = get_currency(&def.quote);
253    let price_increment = Price::from(def.tick_size.to_string());
254    let size_increment = Quantity::from(def.lot_size.to_string());
255
256    match def.market_type {
257        HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
258            instrument_id,
259            raw_symbol,
260            base_currency,
261            quote_currency,
262            def.price_decimals as u8,
263            def.size_decimals as u8,
264            price_increment,
265            size_increment,
266            None,
267            None,
268            None,
269            None,
270            None,
271            None,
272            None,
273            None,
274            None,
275            None,
276            None,
277            None,
278            ts_init, // Identical to ts_init for now
279            ts_init,
280        ))),
281        HyperliquidMarketType::Perp => {
282            let settlement_currency = get_currency("USDC");
283
284            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
285                instrument_id,
286                raw_symbol,
287                base_currency,
288                quote_currency,
289                settlement_currency,
290                false,
291                def.price_decimals as u8,
292                def.size_decimals as u8,
293                price_increment,
294                size_increment,
295                None,
296                None,
297                None,
298                None,
299                None,
300                None,
301                None,
302                None,
303                None,
304                None,
305                None,
306                None,
307                ts_init, // Identical to ts_init for now
308                ts_init,
309            )))
310        }
311    }
312}
313
314/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
315/// discarding any definitions that fail to convert.
316#[must_use]
317pub fn instruments_from_defs(
318    defs: &[HyperliquidInstrumentDef],
319    ts_init: UnixNanos,
320) -> Vec<InstrumentAny> {
321    defs.iter()
322        .filter_map(|def| create_instrument_from_def(def, ts_init))
323        .collect()
324}
325
326/// Convert owned definitions into Nautilus instruments, consuming the input vector.
327#[must_use]
328pub fn instruments_from_defs_owned(defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
329    let clock = get_atomic_clock_realtime();
330    let ts_init = clock.get_time_ns();
331
332    defs.into_iter()
333        .filter_map(|def| create_instrument_from_def(&def, ts_init))
334        .collect()
335}
336
337/// Map Hyperliquid fill side to Nautilus OrderSide.
338fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
339    match side {
340        HyperliquidSide::Buy => OrderSide::Buy,
341        HyperliquidSide::Sell => OrderSide::Sell,
342    }
343}
344
345/// Parse WebSocket order data to OrderStatusReport.
346///
347/// # Errors
348///
349/// Returns an error if required fields are missing or invalid.
350pub fn parse_order_status_report_from_ws(
351    order_data: &WsOrderData,
352    instrument: &dyn Instrument,
353    account_id: AccountId,
354    ts_init: UnixNanos,
355) -> anyhow::Result<OrderStatusReport> {
356    parse_order_status_report_from_basic(
357        &order_data.order,
358        &order_data.status,
359        instrument,
360        account_id,
361        ts_init,
362    )
363}
364
365/// Parse basic order data to OrderStatusReport.
366///
367/// # Errors
368///
369/// Returns an error if required fields are missing or invalid.
370pub fn parse_order_status_report_from_basic(
371    order: &WsBasicOrderData,
372    status: &HyperliquidOrderStatusEnum,
373    instrument: &dyn Instrument,
374    account_id: AccountId,
375    ts_init: UnixNanos,
376) -> anyhow::Result<OrderStatusReport> {
377    use nautilus_model::types::{Price, Quantity};
378    use rust_decimal::Decimal;
379
380    let instrument_id = instrument.id();
381    let venue_order_id = VenueOrderId::new(order.oid.to_string());
382    let order_side = OrderSide::from(order.side);
383
384    // Determine order type based on trigger parameters
385    let order_type = if order.trigger_px.is_some() {
386        if order.is_market == Some(true) {
387            // Check if it's stop-loss or take-profit based on tpsl field
388            match order.tpsl.as_ref() {
389                Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
390                Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
391                _ => OrderType::StopMarket,
392            }
393        } else {
394            match order.tpsl.as_ref() {
395                Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
396                Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
397                _ => OrderType::StopLimit,
398            }
399        }
400    } else {
401        OrderType::Limit
402    };
403
404    let time_in_force = TimeInForce::Gtc;
405    let order_status = OrderStatus::from(*status);
406
407    let price_precision = instrument.price_precision();
408    let size_precision = instrument.size_precision();
409
410    let orig_sz: Decimal = order
411        .orig_sz
412        .parse()
413        .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
414    let current_sz: Decimal = order
415        .sz
416        .parse()
417        .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
418
419    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
420        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
421    let filled_sz = orig_sz.abs() - current_sz.abs();
422    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
423        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
424
425    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
426    let ts_last = ts_accepted;
427    let report_id = UUID4::new();
428
429    let mut report = OrderStatusReport::new(
430        account_id,
431        instrument_id,
432        None, // client_order_id - will be set if present
433        venue_order_id,
434        order_side,
435        order_type,
436        time_in_force,
437        order_status,
438        quantity,
439        filled_qty,
440        ts_accepted,
441        ts_last,
442        ts_init,
443        Some(report_id),
444    );
445
446    // Add client order ID if present
447    if let Some(cloid) = &order.cloid {
448        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
449    }
450
451    // Only set price for non-filled orders. For filled orders, the limit price is not
452    // the execution price, and setting it would cause bogus inferred fills to be created
453    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
454    if !matches!(
455        order_status,
456        OrderStatus::Filled | OrderStatus::PartiallyFilled
457    ) {
458        let limit_px: Decimal = order
459            .limit_px
460            .parse()
461            .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
462        let price = Price::from_decimal_dp(limit_px, price_precision)
463            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
464        report = report.with_price(price);
465    }
466
467    // Add trigger price if present
468    if let Some(trigger_px) = &order.trigger_px {
469        let trig_px: Decimal = trigger_px
470            .parse()
471            .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
472        let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
473            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
474        report = report
475            .with_trigger_price(trigger_price)
476            .with_trigger_type(TriggerType::Default);
477    }
478
479    Ok(report)
480}
481
482/// Parse Hyperliquid fill to FillReport.
483///
484/// # Errors
485///
486/// Returns an error if required fields are missing or invalid.
487pub fn parse_fill_report(
488    fill: &HyperliquidFill,
489    instrument: &dyn Instrument,
490    account_id: AccountId,
491    ts_init: UnixNanos,
492) -> anyhow::Result<FillReport> {
493    use nautilus_model::types::{Money, Price, Quantity};
494    use rust_decimal::Decimal;
495
496    let instrument_id = instrument.id();
497    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
498
499    // Combine hash + oid for unique TradeId (max 36 chars).
500    // Multiple fills can share the same hash (same transaction, funding fills)
501    // so the oid suffix ensures uniqueness per fill.
502    let oid_str = fill.oid.to_string();
503    let hash_budget = 36 - oid_str.len() - 1; // -1 for separator
504    let hash_part = if fill.hash.len() > hash_budget {
505        &fill.hash[fill.hash.len() - hash_budget..]
506    } else {
507        &fill.hash
508    };
509    let trade_id = TradeId::new(format!("{hash_part}-{oid_str}"));
510    let order_side = parse_fill_side(&fill.side);
511
512    let price_precision = instrument.price_precision();
513    let size_precision = instrument.size_precision();
514
515    let px: Decimal = fill
516        .px
517        .parse()
518        .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
519    let sz: Decimal = fill
520        .sz
521        .parse()
522        .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
523
524    let last_px = Price::from_decimal_dp(px, price_precision)
525        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
526    let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
527        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
528
529    // Parse fee - Hyperliquid fees are typically in USDC for perps
530    let fee_amount: Decimal = fill
531        .fee
532        .parse()
533        .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
534
535    // Determine fee currency - Hyperliquid perp fees are in USDC
536    let fee_currency = Currency::from("USDC");
537    let commission = Money::from_decimal(fee_amount, fee_currency)
538        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
539
540    // Determine liquidity side based on 'crossed' flag
541    let liquidity_side = if fill.crossed {
542        LiquiditySide::Taker
543    } else {
544        LiquiditySide::Maker
545    };
546
547    let ts_event = UnixNanos::from(fill.time * 1_000_000);
548    let report_id = UUID4::new();
549
550    let report = FillReport::new(
551        account_id,
552        instrument_id,
553        venue_order_id,
554        trade_id,
555        order_side,
556        last_qty,
557        last_px,
558        commission,
559        liquidity_side,
560        None, // client_order_id - to be linked by execution engine
561        None, // venue_position_id
562        ts_event,
563        ts_init,
564        Some(report_id),
565    );
566
567    Ok(report)
568}
569
570/// Parse position data from clearinghouse state to PositionStatusReport.
571///
572/// # Errors
573///
574/// Returns an error if required fields are missing or invalid.
575pub fn parse_position_status_report(
576    position_data: &serde_json::Value,
577    instrument: &dyn Instrument,
578    account_id: AccountId,
579    ts_init: UnixNanos,
580) -> anyhow::Result<PositionStatusReport> {
581    use nautilus_model::types::Quantity;
582
583    use super::models::AssetPosition;
584
585    // Deserialize the position data
586    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
587        .context("failed to deserialize AssetPosition")?;
588
589    let position = &asset_position.position;
590    let instrument_id = instrument.id();
591
592    // Determine position side based on size (szi)
593    let (position_side, quantity_value) = if position.szi.is_zero() {
594        (PositionSideSpecified::Flat, Decimal::ZERO)
595    } else if position.szi.is_sign_positive() {
596        (PositionSideSpecified::Long, position.szi)
597    } else {
598        (PositionSideSpecified::Short, position.szi.abs())
599    };
600
601    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
602        .context("failed to create quantity from decimal")?;
603    let report_id = UUID4::new();
604    let ts_last = ts_init;
605    let avg_px_open = position.entry_px;
606
607    // Hyperliquid uses netting (one position per instrument), not hedging
608    Ok(PositionStatusReport::new(
609        account_id,
610        instrument_id,
611        position_side,
612        quantity,
613        ts_last,
614        ts_init,
615        Some(report_id),
616        None, // No venue_position_id for netting positions
617        avg_px_open,
618    ))
619}
620
621#[cfg(test)]
622mod tests {
623    use rstest::rstest;
624    use rust_decimal_macros::dec;
625
626    use super::{
627        super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
628        *,
629    };
630
631    #[rstest]
632    fn test_parse_fill_side() {
633        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
634        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
635    }
636
637    #[rstest]
638    fn test_pow10_neg() {
639        assert_eq!(pow10_neg(0).unwrap(), dec!(1));
640        assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
641        assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
642    }
643
644    #[rstest]
645    fn test_parse_perp_instruments() {
646        let meta = PerpMeta {
647            universe: vec![
648                PerpAsset {
649                    name: "BTC".to_string(),
650                    sz_decimals: 5,
651                    max_leverage: Some(50),
652                    only_isolated: None,
653                    is_delisted: None,
654                },
655                PerpAsset {
656                    name: "DELIST".to_string(),
657                    sz_decimals: 3,
658                    max_leverage: Some(10),
659                    only_isolated: Some(true),
660                    is_delisted: Some(true), // Should be included but marked as inactive
661                },
662            ],
663            margin_tables: vec![],
664        };
665
666        let defs = parse_perp_instruments(&meta).unwrap();
667
668        // Should have both BTC and DELIST (delisted instruments are included for historical data)
669        assert_eq!(defs.len(), 2);
670
671        let btc = &defs[0];
672        assert_eq!(btc.symbol, "BTC-USD-PERP");
673        assert_eq!(btc.base, "BTC");
674        assert_eq!(btc.quote, "USD");
675        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
676        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
677        assert_eq!(btc.size_decimals, 5);
678        assert_eq!(btc.tick_size, dec!(0.1));
679        assert_eq!(btc.lot_size, dec!(0.00001));
680        assert_eq!(btc.max_leverage, Some(50));
681        assert!(!btc.only_isolated);
682        assert!(btc.active);
683
684        let delist = &defs[1];
685        assert_eq!(delist.symbol, "DELIST-USD-PERP");
686        assert_eq!(delist.base, "DELIST");
687        assert!(!delist.active); // Delisted instruments are marked as inactive
688    }
689
690    fn load_test_data<T>(filename: &str) -> T
691    where
692        T: serde::de::DeserializeOwned,
693    {
694        let path = format!("test_data/{filename}");
695        let content = std::fs::read_to_string(path).expect("Failed to read test data");
696        serde_json::from_str(&content).expect("Failed to parse test data")
697    }
698
699    #[rstest]
700    fn test_parse_perp_instruments_from_real_data() {
701        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
702
703        let defs = parse_perp_instruments(&meta).unwrap();
704
705        // Should have 3 instruments (BTC, ETH, ATOM)
706        assert_eq!(defs.len(), 3);
707
708        // Validate BTC
709        let btc = &defs[0];
710        assert_eq!(btc.symbol, "BTC-USD-PERP");
711        assert_eq!(btc.base, "BTC");
712        assert_eq!(btc.quote, "USD");
713        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
714        assert_eq!(btc.size_decimals, 5);
715        assert_eq!(btc.max_leverage, Some(40));
716        assert!(btc.active);
717
718        // Validate ETH
719        let eth = &defs[1];
720        assert_eq!(eth.symbol, "ETH-USD-PERP");
721        assert_eq!(eth.base, "ETH");
722        assert_eq!(eth.size_decimals, 4);
723        assert_eq!(eth.max_leverage, Some(25));
724
725        // Validate ATOM
726        let atom = &defs[2];
727        assert_eq!(atom.symbol, "ATOM-USD-PERP");
728        assert_eq!(atom.base, "ATOM");
729        assert_eq!(atom.size_decimals, 2);
730        assert_eq!(atom.max_leverage, Some(5));
731    }
732
733    #[rstest]
734    fn test_deserialize_l2_book_from_real_data() {
735        let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
736
737        // Validate basic structure
738        assert_eq!(book.coin, "BTC");
739        assert_eq!(book.levels.len(), 2); // [bids, asks]
740        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
741        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
742
743        // Verify bids and asks are properly ordered
744        let bids = &book.levels[0];
745        let asks = &book.levels[1];
746
747        // Bids should be descending (highest first)
748        for i in 1..bids.len() {
749            let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
750            let curr_price = bids[i].px.parse::<f64>().unwrap();
751            assert!(prev_price >= curr_price, "Bids should be descending");
752        }
753
754        // Asks should be ascending (lowest first)
755        for i in 1..asks.len() {
756            let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
757            let curr_price = asks[i].px.parse::<f64>().unwrap();
758            assert!(prev_price <= curr_price, "Asks should be ascending");
759        }
760    }
761
762    #[rstest]
763    fn test_parse_spot_instruments() {
764        let tokens = vec![
765            SpotToken {
766                name: "USDC".to_string(),
767                sz_decimals: 6,
768                wei_decimals: 6,
769                index: 0,
770                token_id: "0x1".to_string(),
771                is_canonical: true,
772                evm_contract: None,
773                full_name: None,
774                deployer_trading_fee_share: None,
775            },
776            SpotToken {
777                name: "PURR".to_string(),
778                sz_decimals: 0,
779                wei_decimals: 5,
780                index: 1,
781                token_id: "0x2".to_string(),
782                is_canonical: true,
783                evm_contract: None,
784                full_name: None,
785                deployer_trading_fee_share: None,
786            },
787        ];
788
789        let pairs = vec![
790            SpotPair {
791                name: "PURR/USDC".to_string(),
792                tokens: [1, 0], // PURR base, USDC quote
793                index: 0,
794                is_canonical: true,
795            },
796            SpotPair {
797                name: "ALIAS".to_string(),
798                tokens: [1, 0],
799                index: 1,
800                is_canonical: false, // Should be included but marked as inactive
801            },
802        ];
803
804        let meta = SpotMeta {
805            tokens,
806            universe: pairs,
807        };
808
809        let defs = parse_spot_instruments(&meta).unwrap();
810
811        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
812        assert_eq!(defs.len(), 2);
813
814        let purr_usdc = &defs[0];
815        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
816        assert_eq!(purr_usdc.base, "PURR");
817        assert_eq!(purr_usdc.quote, "USDC");
818        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
819        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
820        assert_eq!(purr_usdc.size_decimals, 0);
821        assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
822        assert_eq!(purr_usdc.lot_size, dec!(1));
823        assert_eq!(purr_usdc.max_leverage, None);
824        assert!(!purr_usdc.only_isolated);
825        assert!(purr_usdc.active);
826
827        let alias = &defs[1];
828        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
829        assert_eq!(alias.base, "PURR");
830        assert!(!alias.active); // Non-canonical pairs are marked as inactive
831    }
832
833    #[rstest]
834    fn test_price_decimals_clamping() {
835        // Test that price decimals are clamped to >= 0
836        let meta = PerpMeta {
837            universe: vec![PerpAsset {
838                name: "HIGHPREC".to_string(),
839                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
840                max_leverage: Some(1),
841                only_isolated: None,
842                is_delisted: None,
843            }],
844            margin_tables: vec![],
845        };
846
847        let defs = parse_perp_instruments(&meta).unwrap();
848        assert_eq!(defs[0].price_decimals, 0);
849        assert_eq!(defs[0].tick_size, dec!(1));
850    }
851}