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, 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 the instrument is active/tradeable.
88    pub active: bool,
89    /// Raw upstream data for debugging.
90    pub raw_data: String,
91}
92
93/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
94///
95/// Hyperliquid perps follow specific rules:
96/// - Quote is always USD (USDC settled)
97/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
98/// - Active = !is_delisted
99///
100/// **Important:** Delisted instruments are included in the returned list but marked as inactive.
101/// This is necessary to support parsing historical data (orders, fills, positions) for instruments
102/// that have been delisted but may still have associated trading history.
103pub fn parse_perp_instruments(meta: &PerpMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
104    const PERP_MAX_DECIMALS: i32 = 6; // Hyperliquid perps price decimal limit
105
106    let mut defs = Vec::new();
107
108    for (index, asset) in meta.universe.iter().enumerate() {
109        // Include delisted assets but mark them as inactive
110        // This allows parsing of historical data for delisted instruments
111        let is_delisted = asset.is_delisted.unwrap_or(false);
112
113        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
114        let tick_size = pow10_neg(price_decimals)?;
115        let lot_size = pow10_neg(asset.sz_decimals)?;
116
117        let symbol = format!("{}-USD-PERP", asset.name);
118
119        // Perps use base currency as raw_symbol (e.g., "BTC")
120        let raw_symbol: Ustr = asset.name.as_str().into();
121
122        let def = HyperliquidInstrumentDef {
123            symbol: symbol.into(),
124            raw_symbol,
125            base: asset.name.clone().into(),
126            quote: "USD".into(), // Hyperliquid perps are USD-quoted (USDC settled)
127            market_type: HyperliquidMarketType::Perp,
128            asset_index: index as u32,
129            price_decimals,
130            size_decimals: asset.sz_decimals,
131            tick_size,
132            lot_size,
133            max_leverage: asset.max_leverage,
134            only_isolated: asset.only_isolated.unwrap_or(false),
135            active: !is_delisted,
136            raw_data: serde_json::to_string(asset).unwrap_or_default(),
137        };
138
139        defs.push(def);
140    }
141
142    Ok(defs)
143}
144
145/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
146///
147/// Hyperliquid spot follows these rules:
148/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
149/// - Size decimals from base token
150/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
151///   for instruments that may have been traded
152pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
153    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
154    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
155
156    let mut defs = Vec::new();
157
158    // Build index -> token lookup
159    let mut tokens_by_index = ahash::AHashMap::new();
160    for token in &meta.tokens {
161        tokens_by_index.insert(token.index, token);
162    }
163
164    for pair in &meta.universe {
165        // Load all pairs (including non-canonical) to support parsing fills/positions
166        // for instruments that may have been traded but are not currently canonical
167
168        let base_token = tokens_by_index
169            .get(&pair.tokens[0])
170            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
171        let quote_token = tokens_by_index
172            .get(&pair.tokens[1])
173            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
174
175        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
176        let tick_size = pow10_neg(price_decimals)?;
177        let lot_size = pow10_neg(base_token.sz_decimals)?;
178
179        let symbol = format!("{}-{}-SPOT", base_token.name, quote_token.name);
180
181        // Hyperliquid spot raw_symbol formats (per API docs):
182        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
183        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
184        let raw_symbol: Ustr = if base_token.name == "PURR" {
185            pair.name.as_str().into()
186        } else {
187            format!("@{}", pair.index).into()
188        };
189
190        let def = HyperliquidInstrumentDef {
191            symbol: symbol.into(),
192            raw_symbol,
193            base: base_token.name.clone().into(),
194            quote: quote_token.name.clone().into(),
195            market_type: HyperliquidMarketType::Spot,
196            asset_index: SPOT_INDEX_OFFSET + pair.index,
197            price_decimals,
198            size_decimals: base_token.sz_decimals,
199            tick_size,
200            lot_size,
201            max_leverage: None,
202            only_isolated: false,
203            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
204            raw_data: serde_json::to_string(pair).unwrap_or_default(),
205        };
206
207        defs.push(def);
208    }
209
210    Ok(defs)
211}
212
213fn pow10_neg(decimals: u32) -> Result<Decimal, String> {
214    if decimals == 0 {
215        return Ok(Decimal::ONE);
216    }
217
218    // Build 1 / 10^decimals using integer arithmetic
219    Ok(Decimal::from_i128_with_scale(1, decimals))
220}
221
222pub fn get_currency(code: &str) -> Currency {
223    Currency::try_from_str(code).unwrap_or_else(|| {
224        let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
225        if let Err(e) = Currency::register(currency, false) {
226            log::error!("Failed to register currency '{code}': {e}");
227        }
228        currency
229    })
230}
231
232/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
233///
234/// Returns `None` if the conversion fails (e.g., unsupported market type).
235#[must_use]
236pub fn create_instrument_from_def(
237    def: &HyperliquidInstrumentDef,
238    ts_init: UnixNanos,
239) -> Option<InstrumentAny> {
240    let symbol = Symbol::new(def.symbol);
241    let venue = *HYPERLIQUID_VENUE;
242    let instrument_id = InstrumentId::new(symbol, venue);
243
244    // Use the raw_symbol from the definition which is format-specific:
245    // - Perps: base currency (e.g., "BTC")
246    // - Spot PURR: slash format (e.g., "PURR/USDC")
247    // - Spot others: @{index} format (e.g., "@107")
248    let raw_symbol = Symbol::new(def.raw_symbol);
249    let base_currency = get_currency(&def.base);
250    let quote_currency = get_currency(&def.quote);
251    let price_increment = Price::from(def.tick_size.to_string());
252    let size_increment = Quantity::from(def.lot_size.to_string());
253
254    match def.market_type {
255        HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
256            instrument_id,
257            raw_symbol,
258            base_currency,
259            quote_currency,
260            def.price_decimals as u8,
261            def.size_decimals as u8,
262            price_increment,
263            size_increment,
264            None,
265            None,
266            None,
267            None,
268            None,
269            None,
270            None,
271            None,
272            None,
273            None,
274            None,
275            None,
276            None,
277            ts_init, // Identical to ts_init for now
278            ts_init,
279        ))),
280        HyperliquidMarketType::Perp => {
281            let settlement_currency = get_currency("USDC");
282
283            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
284                instrument_id,
285                raw_symbol,
286                base_currency,
287                quote_currency,
288                settlement_currency,
289                false,
290                def.price_decimals as u8,
291                def.size_decimals as u8,
292                price_increment,
293                size_increment,
294                None, // multiplier
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
337fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
338    match side {
339        HyperliquidSide::Buy => OrderSide::Buy,
340        HyperliquidSide::Sell => OrderSide::Sell,
341    }
342}
343
344/// Parse WebSocket order data to OrderStatusReport.
345///
346/// # Errors
347///
348/// Returns an error if required fields are missing or invalid.
349pub fn parse_order_status_report_from_ws(
350    order_data: &WsOrderData,
351    instrument: &dyn Instrument,
352    account_id: AccountId,
353    ts_init: UnixNanos,
354) -> anyhow::Result<OrderStatusReport> {
355    parse_order_status_report_from_basic(
356        &order_data.order,
357        &order_data.status,
358        instrument,
359        account_id,
360        ts_init,
361    )
362}
363
364/// Parse basic order data to OrderStatusReport.
365///
366/// # Errors
367///
368/// Returns an error if required fields are missing or invalid.
369pub fn parse_order_status_report_from_basic(
370    order: &WsBasicOrderData,
371    status: &HyperliquidOrderStatusEnum,
372    instrument: &dyn Instrument,
373    account_id: AccountId,
374    ts_init: UnixNanos,
375) -> anyhow::Result<OrderStatusReport> {
376    let instrument_id = instrument.id();
377    let venue_order_id = VenueOrderId::new(order.oid.to_string());
378    let order_side = OrderSide::from(order.side);
379
380    // Determine order type based on trigger parameters
381    let order_type = if order.trigger_px.is_some() {
382        if order.is_market == Some(true) {
383            // Check if it's stop-loss or take-profit based on tpsl field
384            match order.tpsl.as_ref() {
385                Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
386                Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
387                _ => OrderType::StopMarket,
388            }
389        } else {
390            match order.tpsl.as_ref() {
391                Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
392                Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
393                _ => OrderType::StopLimit,
394            }
395        }
396    } else {
397        OrderType::Limit
398    };
399
400    let time_in_force = TimeInForce::Gtc;
401    let order_status = OrderStatus::from(*status);
402
403    let price_precision = instrument.price_precision();
404    let size_precision = instrument.size_precision();
405
406    let orig_sz: Decimal = order
407        .orig_sz
408        .parse()
409        .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
410    let current_sz: Decimal = order
411        .sz
412        .parse()
413        .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
414
415    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
416        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
417    let filled_sz = orig_sz.abs() - current_sz.abs();
418    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
419        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
420
421    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
422    let ts_last = ts_accepted;
423    let report_id = UUID4::new();
424
425    let mut report = OrderStatusReport::new(
426        account_id,
427        instrument_id,
428        None, // client_order_id - will be set if present
429        venue_order_id,
430        order_side,
431        order_type,
432        time_in_force,
433        order_status,
434        quantity,
435        filled_qty,
436        ts_accepted,
437        ts_last,
438        ts_init,
439        Some(report_id),
440    );
441
442    // Add client order ID if present
443    if let Some(cloid) = &order.cloid {
444        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
445    }
446
447    // Only set price for non-filled orders. For filled orders, the limit price is not
448    // the execution price, and setting it would cause bogus inferred fills to be created
449    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
450    if !matches!(
451        order_status,
452        OrderStatus::Filled | OrderStatus::PartiallyFilled
453    ) {
454        let limit_px: Decimal = order
455            .limit_px
456            .parse()
457            .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
458        let price = Price::from_decimal_dp(limit_px, price_precision)
459            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
460        report = report.with_price(price);
461    }
462
463    // Add trigger price if present
464    if let Some(trigger_px) = &order.trigger_px {
465        let trig_px: Decimal = trigger_px
466            .parse()
467            .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
468        let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
469            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
470        report = report
471            .with_trigger_price(trigger_price)
472            .with_trigger_type(TriggerType::Default);
473    }
474
475    Ok(report)
476}
477
478/// Parse Hyperliquid fill to FillReport.
479///
480/// # Errors
481///
482/// Returns an error if required fields are missing or invalid.
483pub fn parse_fill_report(
484    fill: &HyperliquidFill,
485    instrument: &dyn Instrument,
486    account_id: AccountId,
487    ts_init: UnixNanos,
488) -> anyhow::Result<FillReport> {
489    let instrument_id = instrument.id();
490    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
491
492    let trade_id = make_fill_trade_id(
493        &fill.hash,
494        fill.oid,
495        &fill.px,
496        &fill.sz,
497        fill.time,
498        &fill.start_position,
499    );
500    let order_side = parse_fill_side(&fill.side);
501
502    let price_precision = instrument.price_precision();
503    let size_precision = instrument.size_precision();
504
505    let px: Decimal = fill
506        .px
507        .parse()
508        .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
509    let sz: Decimal = fill
510        .sz
511        .parse()
512        .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
513
514    let last_px = Price::from_decimal_dp(px, price_precision)
515        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
516    let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
517        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
518
519    let fee_amount: Decimal = fill
520        .fee
521        .parse()
522        .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
523
524    let fee_currency: Currency = fill
525        .fee_token
526        .parse()
527        .map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
528    let commission = Money::from_decimal(fee_amount, fee_currency)
529        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
530
531    // Determine liquidity side based on 'crossed' flag
532    let liquidity_side = if fill.crossed {
533        LiquiditySide::Taker
534    } else {
535        LiquiditySide::Maker
536    };
537
538    let ts_event = UnixNanos::from(fill.time * 1_000_000);
539    let report_id = UUID4::new();
540
541    let report = FillReport::new(
542        account_id,
543        instrument_id,
544        venue_order_id,
545        trade_id,
546        order_side,
547        last_qty,
548        last_px,
549        commission,
550        liquidity_side,
551        None, // client_order_id - to be linked by execution engine
552        None, // venue_position_id
553        ts_event,
554        ts_init,
555        Some(report_id),
556    );
557
558    Ok(report)
559}
560
561/// Parse position data from clearinghouse state to PositionStatusReport.
562///
563/// # Errors
564///
565/// Returns an error if required fields are missing or invalid.
566pub fn parse_position_status_report(
567    position_data: &serde_json::Value,
568    instrument: &dyn Instrument,
569    account_id: AccountId,
570    ts_init: UnixNanos,
571) -> anyhow::Result<PositionStatusReport> {
572    // Deserialize the position data
573    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
574        .context("failed to deserialize AssetPosition")?;
575
576    let position = &asset_position.position;
577    let instrument_id = instrument.id();
578
579    // Determine position side based on size (szi)
580    let (position_side, quantity_value) = if position.szi.is_zero() {
581        (PositionSideSpecified::Flat, Decimal::ZERO)
582    } else if position.szi.is_sign_positive() {
583        (PositionSideSpecified::Long, position.szi)
584    } else {
585        (PositionSideSpecified::Short, position.szi.abs())
586    };
587
588    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
589        .context("failed to create quantity from decimal")?;
590    let report_id = UUID4::new();
591    let ts_last = ts_init;
592    let avg_px_open = position.entry_px;
593
594    // Hyperliquid uses netting (one position per instrument), not hedging
595    Ok(PositionStatusReport::new(
596        account_id,
597        instrument_id,
598        position_side,
599        quantity,
600        ts_last,
601        ts_init,
602        Some(report_id),
603        None, // No venue_position_id for netting positions
604        avg_px_open,
605    ))
606}
607
608#[cfg(test)]
609mod tests {
610    use rstest::rstest;
611    use rust_decimal_macros::dec;
612
613    use super::{
614        super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
615        *,
616    };
617
618    #[rstest]
619    fn test_parse_fill_side() {
620        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
621        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
622    }
623
624    #[rstest]
625    fn test_pow10_neg() {
626        assert_eq!(pow10_neg(0).unwrap(), dec!(1));
627        assert_eq!(pow10_neg(1).unwrap(), dec!(0.1));
628        assert_eq!(pow10_neg(5).unwrap(), dec!(0.00001));
629    }
630
631    #[rstest]
632    fn test_parse_perp_instruments() {
633        let meta = PerpMeta {
634            universe: vec![
635                PerpAsset {
636                    name: "BTC".to_string(),
637                    sz_decimals: 5,
638                    max_leverage: Some(50),
639                    only_isolated: None,
640                    is_delisted: None,
641                },
642                PerpAsset {
643                    name: "DELIST".to_string(),
644                    sz_decimals: 3,
645                    max_leverage: Some(10),
646                    only_isolated: Some(true),
647                    is_delisted: Some(true), // Should be included but marked as inactive
648                },
649            ],
650            margin_tables: vec![],
651        };
652
653        let defs = parse_perp_instruments(&meta).unwrap();
654
655        // Should have both BTC and DELIST (delisted instruments are included for historical data)
656        assert_eq!(defs.len(), 2);
657
658        let btc = &defs[0];
659        assert_eq!(btc.symbol, "BTC-USD-PERP");
660        assert_eq!(btc.base, "BTC");
661        assert_eq!(btc.quote, "USD");
662        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
663        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
664        assert_eq!(btc.size_decimals, 5);
665        assert_eq!(btc.tick_size, dec!(0.1));
666        assert_eq!(btc.lot_size, dec!(0.00001));
667        assert_eq!(btc.max_leverage, Some(50));
668        assert!(!btc.only_isolated);
669        assert!(btc.active);
670
671        let delist = &defs[1];
672        assert_eq!(delist.symbol, "DELIST-USD-PERP");
673        assert_eq!(delist.base, "DELIST");
674        assert!(!delist.active); // Delisted instruments are marked as inactive
675    }
676
677    fn load_test_data<T>(filename: &str) -> T
678    where
679        T: serde::de::DeserializeOwned,
680    {
681        let path = format!("test_data/{filename}");
682        let content = std::fs::read_to_string(path).expect("Failed to read test data");
683        serde_json::from_str(&content).expect("Failed to parse test data")
684    }
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).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        // Test that price decimals are clamped to >= 0
823        let meta = PerpMeta {
824            universe: vec![PerpAsset {
825                name: "HIGHPREC".to_string(),
826                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
827                max_leverage: Some(1),
828                only_isolated: None,
829                is_delisted: None,
830            }],
831            margin_tables: vec![],
832        };
833
834        let defs = parse_perp_instruments(&meta).unwrap();
835        assert_eq!(defs[0].price_decimals, 0);
836        assert_eq!(defs[0].tick_size, dec!(1));
837    }
838}