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};
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/// Market type enumeration for normalized instrument definitions.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum HyperliquidMarketType {
47    /// Perpetual futures contract.
48    Perp,
49    /// Spot trading pair.
50    Spot,
51}
52
53/// Normalized instrument definition produced by this parser.
54///
55/// This deliberately avoids any tight coupling to Nautilus' Cython types.
56/// The InstrumentProvider can later convert this into Nautilus `Instrument`s.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct HyperliquidInstrumentDef {
59    /// Human-readable symbol (e.g., "BTC-USD-PERP", "PURR-USDC-SPOT").
60    pub symbol: Ustr,
61    /// Raw symbol used in Hyperliquid WebSocket subscriptions/messages.
62    /// For perps: base currency (e.g., "BTC").
63    /// For spot: `@{pair_index}` format (e.g., "@107" for HYPE-USDC).
64    pub raw_symbol: Ustr,
65    /// Base currency/asset (e.g., "BTC", "PURR").
66    pub base: Ustr,
67    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
68    pub quote: Ustr,
69    /// Market type (perpetual or spot).
70    pub market_type: HyperliquidMarketType,
71    /// Asset index used for order submission.
72    /// For perps: index in meta.universe (0, 1, 2, ...).
73    /// For spot: 10000 + index in spotMeta.universe.
74    pub asset_index: u32,
75    /// Number of decimal places for price precision.
76    pub price_decimals: u32,
77    /// Number of decimal places for size precision.
78    pub size_decimals: u32,
79    /// Price tick size as decimal.
80    pub tick_size: Decimal,
81    /// Size lot increment as decimal.
82    pub lot_size: Decimal,
83    /// Maximum leverage (for perps).
84    pub max_leverage: Option<u32>,
85    /// Whether requires isolated margin only.
86    pub only_isolated: bool,
87    /// Whether this is a HIP-3 builder-deployed perpetual.
88    pub is_hip3: bool,
89    /// Whether the instrument is active/tradeable.
90    pub active: bool,
91    /// Raw upstream data for debugging.
92    pub raw_data: String,
93}
94
95/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
96///
97/// Hyperliquid perps follow specific rules:
98/// - Quote is always USD (USDC settled)
99/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
100/// - Active = !is_delisted
101///
102/// `asset_index_base` controls the starting offset for asset IDs:
103/// - Standard perps (dex 0): base = 0
104/// - HIP-3 dexes: base = 100_000 + dex_index * 10_000
105///
106/// Delisted instruments are included but marked as inactive to support
107/// parsing historical data for instruments that may still have trading history.
108pub 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
151/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
152///
153/// Hyperliquid spot follows these rules:
154/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
155/// - Size decimals from base token
156/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
157///   for instruments that may have been traded
158pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
159    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
160    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
161
162    let mut defs = Vec::new();
163
164    // Build index -> token lookup
165    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        // Load all pairs (including non-canonical) to support parsing fills/positions
172        // for instruments that may have been traded but are not currently canonical
173
174        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        // Hyperliquid spot raw_symbol formats (per API docs):
188        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
189        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
190        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, // Use canonical status to indicate if pair is actively tradeable
211            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    // Build 1 / 10^decimals using integer arithmetic
226    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/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
240///
241/// Returns `None` if the conversion fails (e.g., unsupported market type).
242#[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    // Use the raw_symbol from the definition which is format-specific:
252    // - Perps: base currency (e.g., "BTC")
253    // - Spot PURR: slash format (e.g., "PURR/USDC")
254    // - Spot others: @{index} format (e.g., "@107")
255    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, // Identical to ts_init for now
285            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, // multiplier
302                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, // Identical to ts_init for now
315                ts_init,
316            )))
317        }
318    }
319}
320
321/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
322/// discarding any definitions that fail to convert.
323#[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/// Convert owned definitions into Nautilus instruments, consuming the input vector.
334#[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
351/// Parse WebSocket order data to OrderStatusReport.
352///
353/// # Errors
354///
355/// Returns an error if required fields are missing or invalid.
356pub 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
371/// Parse basic order data to OrderStatusReport.
372///
373/// # Errors
374///
375/// Returns an error if required fields are missing or invalid.
376pub 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    // Determine order type based on trigger parameters
388    let order_type = if order.trigger_px.is_some() {
389        if order.is_market == Some(true) {
390            // Check if it's stop-loss or take-profit based on tpsl field
391            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, // client_order_id - will be set if present
436        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    // Add client order ID if present
450    if let Some(cloid) = &order.cloid {
451        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
452    }
453
454    // Only set price for non-filled orders. For filled orders, the limit price is not
455    // the execution price, and setting it would cause bogus inferred fills to be created
456    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
457    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    // Add trigger price if present
471    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
485/// Parse Hyperliquid fill to FillReport.
486///
487/// # Errors
488///
489/// Returns an error if required fields are missing or invalid.
490pub 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    // Determine liquidity side based on 'crossed' flag
539    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, // client_order_id - to be linked by execution engine
559        None, // venue_position_id
560        ts_event,
561        ts_init,
562        Some(report_id),
563    );
564
565    Ok(report)
566}
567
568/// Parse position data from clearinghouse state to PositionStatusReport.
569///
570/// # Errors
571///
572/// Returns an error if required fields are missing or invalid.
573pub 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    // Deserialize the position data
580    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    // Determine position side based on size (szi)
587    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    // Hyperliquid uses netting (one position per instrument), not hedging
602    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, // No venue_position_id for netting positions
611        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        // Should have both BTC and DELIST (delisted instruments are included for historical data)
663        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); // 6 - 5 = 1
671        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); // Delisted instruments are marked as inactive
682    }
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        // Should have 3 instruments (BTC, ETH, ATOM)
693        assert_eq!(defs.len(), 3);
694
695        // Validate BTC
696        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        // Validate ETH
706        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        // Validate ATOM
713        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        // Validate basic structure
725        assert_eq!(book.coin, "BTC");
726        assert_eq!(book.levels.len(), 2); // [bids, asks]
727        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
728        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
729
730        // Verify bids and asks are properly ordered
731        let bids = &book.levels[0];
732        let asks = &book.levels[1];
733
734        // Bids should be descending (highest first)
735        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        // Asks should be ascending (lowest first)
742        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], // PURR base, USDC quote
780                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, // Should be included but marked as inactive
788            },
789        ];
790
791        let meta = SpotMeta {
792            tokens,
793            universe: pairs,
794        };
795
796        let defs = parse_spot_instruments(&meta).unwrap();
797
798        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
799        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); // 8 - 0 = 8 (PURR sz_decimals = 0)
807        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); // Non-canonical pairs are marked as inactive
818    }
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, // 6 - 10 = -4, should clamp to 0
826                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        // HIP-3 dex at index 1: asset_index_base = 100_000 + 1 * 10_000 = 110_000
840        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        // HIP-3 asset: colon in symbol, offset asset index
868        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}