nautilus_hyperliquid/http/
parse.rs

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