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, SpotBalance, SpotMeta};
33use crate::{
34    common::{
35        consts::HYPERLIQUID_VENUE,
36        enums::{
37            HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
38            HyperliquidSide, HyperliquidTpSl,
39        },
40        parse::make_fill_trade_id,
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    /// Raw symbol used in Hyperliquid WebSocket subscriptions/messages.
63    /// For perps: base currency (e.g., "BTC").
64    /// For spot: `@{pair_index}` format (e.g., "@107" for HYPE-USDC).
65    pub raw_symbol: Ustr,
66    /// Base currency/asset (e.g., "BTC", "PURR").
67    pub base: Ustr,
68    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
69    pub quote: Ustr,
70    /// Market type (perpetual or spot).
71    pub market_type: HyperliquidMarketType,
72    /// Asset index used for order submission.
73    /// For perps: index in meta.universe (0, 1, 2, ...).
74    /// For spot: 10000 + index in spotMeta.universe.
75    pub asset_index: u32,
76    /// Number of decimal places for price precision.
77    pub price_decimals: u32,
78    /// Number of decimal places for size precision.
79    pub size_decimals: u32,
80    /// Price tick size as decimal.
81    pub tick_size: Decimal,
82    /// Size lot increment as decimal.
83    pub lot_size: Decimal,
84    /// Maximum leverage (for perps).
85    pub max_leverage: Option<u32>,
86    /// Whether requires isolated margin only.
87    pub only_isolated: bool,
88    /// Whether this is a HIP-3 builder-deployed perpetual.
89    pub is_hip3: bool,
90    /// Whether the instrument is active/tradeable.
91    pub active: bool,
92    /// Raw upstream data for debugging.
93    pub raw_data: String,
94}
95
96// Replace wildcard bytes (`*`, `?`) in a venue-supplied symbol component with
97// `x` so the value is safe to embed in a Nautilus `InstrumentId`. HIP-3
98// perpetual names from Hyperliquid (e.g. `dex:STREAMABCD****-USD-PERP`)
99// collide with msgbus pattern syntax; the venue-official name is preserved on
100// `raw_symbol` for HTTP/WS wire calls, and orders use the numeric
101// `asset_index` so they do not see the substitution.
102#[must_use]
103fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
104    if value.bytes().any(|b| b == b'*' || b == b'?') {
105        let mut out = String::with_capacity(value.len());
106        for ch in value.chars() {
107            out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
108        }
109        std::borrow::Cow::Owned(out)
110    } else {
111        std::borrow::Cow::Borrowed(value)
112    }
113}
114
115/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
116///
117/// Hyperliquid perps follow specific rules:
118/// - Quote is always USD (USDC settled)
119/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
120/// - Active = !is_delisted
121///
122/// `asset_index_base` controls the starting offset for asset IDs:
123/// - Standard perps (dex 0): base = 0
124/// - HIP-3 dexes: base = 100_000 + dex_index * 10_000
125///
126/// Delisted instruments are included but marked as inactive to support
127/// parsing historical data for instruments that may still have trading history.
128pub fn parse_perp_instruments(
129    meta: &PerpMeta,
130    asset_index_base: u32,
131) -> Result<Vec<HyperliquidInstrumentDef>, String> {
132    const PERP_MAX_DECIMALS: i32 = 6;
133
134    let mut defs = Vec::new();
135
136    for (index, asset) in meta.universe.iter().enumerate() {
137        let is_delisted = asset.is_delisted.unwrap_or(false);
138
139        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
140        let tick_size = pow10_neg(price_decimals);
141        let lot_size = pow10_neg(asset.sz_decimals);
142
143        let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
144
145        let raw_symbol: Ustr = asset.name.as_str().into();
146
147        let def = HyperliquidInstrumentDef {
148            symbol: symbol.into(),
149            raw_symbol,
150            base: asset.name.clone().into(),
151            quote: "USD".into(),
152            market_type: HyperliquidMarketType::Perp,
153            asset_index: asset_index_base + index as u32,
154            price_decimals,
155            size_decimals: asset.sz_decimals,
156            tick_size,
157            lot_size,
158            max_leverage: asset.max_leverage,
159            only_isolated: asset.only_isolated.unwrap_or(false),
160            is_hip3: asset_index_base > 0,
161            active: !is_delisted,
162            raw_data: serde_json::to_string(asset).unwrap_or_default(),
163        };
164
165        defs.push(def);
166    }
167
168    Ok(defs)
169}
170
171/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
172///
173/// Hyperliquid spot follows these rules:
174/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
175/// - Size decimals from base token
176/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
177///   for instruments that may have been traded
178pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
179    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
180    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
181
182    let mut defs = Vec::new();
183
184    // Build index -> token lookup
185    let mut tokens_by_index = ahash::AHashMap::new();
186    for token in &meta.tokens {
187        tokens_by_index.insert(token.index, token);
188    }
189
190    for pair in &meta.universe {
191        // Load all pairs (including non-canonical) to support parsing fills/positions
192        // for instruments that may have been traded but are not currently canonical
193
194        let base_token = tokens_by_index
195            .get(&pair.tokens[0])
196            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
197        let quote_token = tokens_by_index
198            .get(&pair.tokens[1])
199            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
200
201        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
202        let tick_size = pow10_neg(price_decimals);
203        let lot_size = pow10_neg(base_token.sz_decimals);
204
205        let symbol = format!(
206            "{}-{}-SPOT",
207            sanitize_symbol(&base_token.name),
208            sanitize_symbol(&quote_token.name),
209        );
210
211        // Hyperliquid spot raw_symbol formats (per API docs):
212        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
213        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
214        let raw_symbol: Ustr = if base_token.name == "PURR" {
215            pair.name.as_str().into()
216        } else {
217            format!("@{}", pair.index).into()
218        };
219
220        let def = HyperliquidInstrumentDef {
221            symbol: symbol.into(),
222            raw_symbol,
223            base: base_token.name.clone().into(),
224            quote: quote_token.name.clone().into(),
225            market_type: HyperliquidMarketType::Spot,
226            asset_index: SPOT_INDEX_OFFSET + pair.index,
227            price_decimals,
228            size_decimals: base_token.sz_decimals,
229            tick_size,
230            lot_size,
231            max_leverage: None,
232            only_isolated: false,
233            is_hip3: false,
234            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
235            raw_data: serde_json::to_string(pair).unwrap_or_default(),
236        };
237
238        defs.push(def);
239    }
240
241    // Canonical pairs must be cached first so the base-token alias (e.g.
242    // "PURR" -> PURR-USDC-SPOT) resolves to the canonical instrument when
243    // non-canonical pairs share the same base. Secondary key keeps the
244    // order stable within each bucket.
245    defs.sort_by(|a, b| {
246        b.active
247            .cmp(&a.active)
248            .then(a.asset_index.cmp(&b.asset_index))
249    });
250
251    Ok(defs)
252}
253
254fn pow10_neg(decimals: u32) -> Decimal {
255    if decimals == 0 {
256        return Decimal::ONE;
257    }
258
259    // Build 1 / 10^decimals using integer arithmetic
260    Decimal::from_i128_with_scale(1, decimals)
261}
262
263pub fn get_currency(code: &str) -> Currency {
264    Currency::try_from_str(code).unwrap_or_else(|| {
265        let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
266        if let Err(e) = Currency::register(currency, false) {
267            log::error!("Failed to register currency '{code}': {e}");
268        }
269        currency
270    })
271}
272
273/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
274///
275/// Returns `None` if the conversion fails (e.g., unsupported market type).
276#[must_use]
277pub fn create_instrument_from_def(
278    def: &HyperliquidInstrumentDef,
279    ts_init: UnixNanos,
280) -> Option<InstrumentAny> {
281    let symbol = Symbol::new(def.symbol);
282    let venue = *HYPERLIQUID_VENUE;
283    let instrument_id = InstrumentId::new(symbol, venue);
284
285    // Use the raw_symbol from the definition which is format-specific:
286    // - Perps: base currency (e.g., "BTC")
287    // - Spot PURR: slash format (e.g., "PURR/USDC")
288    // - Spot others: @{index} format (e.g., "@107")
289    let raw_symbol = Symbol::new(def.raw_symbol);
290    let base_currency = get_currency(&def.base);
291    let quote_currency = get_currency(&def.quote);
292    let price_increment = Price::from(def.tick_size.to_string());
293    let size_increment = Quantity::from(def.lot_size.to_string());
294
295    match def.market_type {
296        HyperliquidMarketType::Spot => Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
297            instrument_id,
298            raw_symbol,
299            base_currency,
300            quote_currency,
301            def.price_decimals as u8,
302            def.size_decimals as u8,
303            price_increment,
304            size_increment,
305            None,
306            None,
307            None,
308            None,
309            None,
310            None,
311            None,
312            None,
313            None,
314            None,
315            None,
316            None,
317            None,
318            ts_init, // Identical to ts_init for now
319            ts_init,
320        ))),
321        HyperliquidMarketType::Perp => {
322            let settlement_currency = get_currency("USDC");
323
324            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
325                instrument_id,
326                raw_symbol,
327                base_currency,
328                quote_currency,
329                settlement_currency,
330                false,
331                def.price_decimals as u8,
332                def.size_decimals as u8,
333                price_increment,
334                size_increment,
335                None, // multiplier
336                None,
337                None,
338                None,
339                None,
340                None,
341                None,
342                None,
343                None,
344                None,
345                None,
346                None,
347                None,
348                ts_init, // Identical to ts_init for now
349                ts_init,
350            )))
351        }
352    }
353}
354
355/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
356/// discarding any definitions that fail to convert.
357#[must_use]
358pub fn instruments_from_defs(
359    defs: &[HyperliquidInstrumentDef],
360    ts_init: UnixNanos,
361) -> Vec<InstrumentAny> {
362    defs.iter()
363        .filter_map(|def| create_instrument_from_def(def, ts_init))
364        .collect()
365}
366
367/// Convert owned definitions into Nautilus instruments, consuming the input vector.
368#[must_use]
369pub fn instruments_from_defs_owned(
370    defs: Vec<HyperliquidInstrumentDef>,
371    ts_init: UnixNanos,
372) -> Vec<InstrumentAny> {
373    defs.into_iter()
374        .filter_map(|def| create_instrument_from_def(&def, ts_init))
375        .collect()
376}
377
378fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
379    match side {
380        HyperliquidSide::Buy => OrderSide::Buy,
381        HyperliquidSide::Sell => OrderSide::Sell,
382    }
383}
384
385/// Parse WebSocket order data to OrderStatusReport.
386///
387/// # Errors
388///
389/// Returns an error if required fields are missing or invalid.
390pub fn parse_order_status_report_from_ws(
391    order_data: &WsOrderData,
392    instrument: &dyn Instrument,
393    account_id: AccountId,
394    ts_init: UnixNanos,
395) -> anyhow::Result<OrderStatusReport> {
396    parse_order_status_report_from_basic(
397        &order_data.order,
398        &order_data.status,
399        instrument,
400        account_id,
401        ts_init,
402    )
403}
404
405/// Parse basic order data to OrderStatusReport.
406///
407/// # Errors
408///
409/// Returns an error if required fields are missing or invalid.
410pub fn parse_order_status_report_from_basic(
411    order: &WsBasicOrderData,
412    status: &HyperliquidOrderStatusEnum,
413    instrument: &dyn Instrument,
414    account_id: AccountId,
415    ts_init: UnixNanos,
416) -> anyhow::Result<OrderStatusReport> {
417    let instrument_id = instrument.id();
418    let venue_order_id = VenueOrderId::new(order.oid.to_string());
419    let order_side = OrderSide::from(order.side);
420
421    // Determine order type based on trigger parameters
422    let order_type = if order.trigger_px.is_some() {
423        if order.is_market == Some(true) {
424            // Check if it's stop-loss or take-profit based on tpsl field
425            match order.tpsl.as_ref() {
426                Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
427                Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
428                _ => OrderType::StopMarket,
429            }
430        } else {
431            match order.tpsl.as_ref() {
432                Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
433                Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
434                _ => OrderType::StopLimit,
435            }
436        }
437    } else {
438        OrderType::Limit
439    };
440
441    let time_in_force = TimeInForce::Gtc;
442    let order_status = OrderStatus::from(*status);
443
444    let price_precision = instrument.price_precision();
445    let size_precision = instrument.size_precision();
446
447    let orig_sz: Decimal = order
448        .orig_sz
449        .parse()
450        .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
451    let current_sz: Decimal = order
452        .sz
453        .parse()
454        .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
455
456    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
457        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
458    let filled_sz = orig_sz.abs() - current_sz.abs();
459    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
460        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
461
462    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
463    let ts_last = ts_accepted;
464    let report_id = UUID4::new();
465
466    let mut report = OrderStatusReport::new(
467        account_id,
468        instrument_id,
469        None, // client_order_id - will be set if present
470        venue_order_id,
471        order_side,
472        order_type,
473        time_in_force,
474        order_status,
475        quantity,
476        filled_qty,
477        ts_accepted,
478        ts_last,
479        ts_init,
480        Some(report_id),
481    );
482
483    // Add client order ID if present
484    if let Some(cloid) = &order.cloid {
485        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
486    }
487
488    // Only set price for non-filled orders. For filled orders, the limit price is not
489    // the execution price, and setting it would cause bogus inferred fills to be created
490    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
491    if !matches!(
492        order_status,
493        OrderStatus::Filled | OrderStatus::PartiallyFilled
494    ) {
495        let limit_px: Decimal = order
496            .limit_px
497            .parse()
498            .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
499        let price = Price::from_decimal_dp(limit_px, price_precision)
500            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
501        report = report.with_price(price);
502    }
503
504    // Add trigger price if present
505    if let Some(trigger_px) = &order.trigger_px {
506        let trig_px: Decimal = trigger_px
507            .parse()
508            .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
509        let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
510            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
511        report = report
512            .with_trigger_price(trigger_price)
513            .with_trigger_type(TriggerType::Default);
514    }
515
516    Ok(report)
517}
518
519/// Parse Hyperliquid fill to FillReport.
520///
521/// # Errors
522///
523/// Returns an error if required fields are missing or invalid.
524pub fn parse_fill_report(
525    fill: &HyperliquidFill,
526    instrument: &dyn Instrument,
527    account_id: AccountId,
528    ts_init: UnixNanos,
529) -> anyhow::Result<FillReport> {
530    let instrument_id = instrument.id();
531    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
532
533    if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
534        log::warn!(
535            "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
536            fill.oid,
537            fill.px,
538            fill.sz,
539        );
540    }
541
542    let trade_id = make_fill_trade_id(
543        &fill.hash,
544        fill.oid,
545        &fill.px,
546        &fill.sz,
547        fill.time,
548        &fill.start_position,
549    );
550    let order_side = parse_fill_side(&fill.side);
551
552    let price_precision = instrument.price_precision();
553    let size_precision = instrument.size_precision();
554
555    let px: Decimal = fill
556        .px
557        .parse()
558        .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
559    let sz: Decimal = fill
560        .sz
561        .parse()
562        .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
563
564    let last_px = Price::from_decimal_dp(px, price_precision)
565        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
566    let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
567        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
568
569    let fee_amount: Decimal = fill
570        .fee
571        .parse()
572        .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
573
574    let fee_currency: Currency = fill
575        .fee_token
576        .parse()
577        .map_err(|e| anyhow::anyhow!("Unknown fee token '{}': {e}", fill.fee_token))?;
578    let commission = Money::from_decimal(fee_amount, fee_currency)
579        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
580
581    // Determine liquidity side based on 'crossed' flag
582    let liquidity_side = if fill.crossed {
583        LiquiditySide::Taker
584    } else {
585        LiquiditySide::Maker
586    };
587
588    let ts_event = UnixNanos::from(fill.time * 1_000_000);
589    let report_id = UUID4::new();
590
591    let report = FillReport::new(
592        account_id,
593        instrument_id,
594        venue_order_id,
595        trade_id,
596        order_side,
597        last_qty,
598        last_px,
599        commission,
600        liquidity_side,
601        None, // client_order_id - to be linked by execution engine
602        None, // venue_position_id
603        ts_event,
604        ts_init,
605        Some(report_id),
606    );
607
608    Ok(report)
609}
610
611/// Parse position data from clearinghouse state to PositionStatusReport.
612///
613/// # Errors
614///
615/// Returns an error if required fields are missing or invalid.
616pub fn parse_position_status_report(
617    position_data: &serde_json::Value,
618    instrument: &dyn Instrument,
619    account_id: AccountId,
620    ts_init: UnixNanos,
621) -> anyhow::Result<PositionStatusReport> {
622    // Deserialize the position data
623    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
624        .context("failed to deserialize AssetPosition")?;
625
626    let position = &asset_position.position;
627    let instrument_id = instrument.id();
628
629    // Determine position side based on size (szi)
630    let (position_side, quantity_value) = if position.szi.is_zero() {
631        (PositionSideSpecified::Flat, Decimal::ZERO)
632    } else if position.szi.is_sign_positive() {
633        (PositionSideSpecified::Long, position.szi)
634    } else {
635        (PositionSideSpecified::Short, position.szi.abs())
636    };
637
638    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
639        .context("failed to create quantity from decimal")?;
640    let report_id = UUID4::new();
641    let ts_last = ts_init;
642    let avg_px_open = position.entry_px;
643
644    // Hyperliquid uses netting (one position per instrument), not hedging
645    Ok(PositionStatusReport::new(
646        account_id,
647        instrument_id,
648        position_side,
649        quantity,
650        ts_last,
651        ts_init,
652        Some(report_id),
653        None, // No venue_position_id for netting positions
654        avg_px_open,
655    ))
656}
657
658/// Parse a spot token balance into a [`PositionStatusReport`] against the spot instrument.
659///
660/// Spot holdings are always Long (Hyperliquid spot has no short exposure). The average
661/// entry price is derived from `entry_ntl / total` when both are non-zero; otherwise it
662/// is omitted.
663///
664/// # Errors
665///
666/// Returns an error if the quantity cannot be constructed at the instrument's precision.
667pub fn parse_spot_position_status_report(
668    balance: &SpotBalance,
669    instrument: &dyn Instrument,
670    account_id: AccountId,
671    ts_init: UnixNanos,
672) -> anyhow::Result<PositionStatusReport> {
673    let (position_side, quantity_value) = if balance.total.is_zero() {
674        (PositionSideSpecified::Flat, Decimal::ZERO)
675    } else {
676        (PositionSideSpecified::Long, balance.total)
677    };
678
679    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
680        .context("failed to create spot quantity from decimal")?;
681
682    Ok(PositionStatusReport::new(
683        account_id,
684        instrument.id(),
685        position_side,
686        quantity,
687        ts_init,
688        ts_init,
689        Some(UUID4::new()),
690        None,
691        balance.avg_entry_px(),
692    ))
693}
694
695#[cfg(test)]
696mod tests {
697    use rstest::rstest;
698    use rust_decimal_macros::dec;
699
700    use super::{
701        super::models::{HyperliquidL2Book, PerpAsset, SpotPair, SpotToken},
702        *,
703    };
704
705    #[rstest]
706    fn test_parse_fill_side() {
707        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
708        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
709    }
710
711    #[rstest]
712    fn test_pow10_neg() {
713        assert_eq!(pow10_neg(0), dec!(1));
714        assert_eq!(pow10_neg(1), dec!(0.1));
715        assert_eq!(pow10_neg(5), dec!(0.00001));
716    }
717
718    #[rstest]
719    fn test_parse_perp_instruments() {
720        let meta = PerpMeta {
721            universe: vec![
722                PerpAsset {
723                    name: "BTC".to_string(),
724                    sz_decimals: 5,
725                    max_leverage: Some(50),
726                    ..Default::default()
727                },
728                PerpAsset {
729                    name: "DELIST".to_string(),
730                    sz_decimals: 3,
731                    max_leverage: Some(10),
732                    only_isolated: Some(true),
733                    is_delisted: Some(true),
734                    ..Default::default()
735                },
736            ],
737            margin_tables: vec![],
738        };
739
740        let defs = parse_perp_instruments(&meta, 0).unwrap();
741
742        // Should have both BTC and DELIST (delisted instruments are included for historical data)
743        assert_eq!(defs.len(), 2);
744
745        let btc = &defs[0];
746        assert_eq!(btc.symbol, "BTC-USD-PERP");
747        assert_eq!(btc.base, "BTC");
748        assert_eq!(btc.quote, "USD");
749        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
750        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
751        assert_eq!(btc.size_decimals, 5);
752        assert_eq!(btc.tick_size, dec!(0.1));
753        assert_eq!(btc.lot_size, dec!(0.00001));
754        assert_eq!(btc.max_leverage, Some(50));
755        assert!(!btc.only_isolated);
756        assert!(btc.active);
757
758        let delist = &defs[1];
759        assert_eq!(delist.symbol, "DELIST-USD-PERP");
760        assert_eq!(delist.base, "DELIST");
761        assert!(!delist.active); // Delisted instruments are marked as inactive
762    }
763
764    use crate::common::testing::load_test_data;
765
766    #[rstest]
767    fn test_parse_perp_instruments_from_real_data() {
768        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
769
770        let defs = parse_perp_instruments(&meta, 0).unwrap();
771
772        // Should have 3 instruments (BTC, ETH, ATOM)
773        assert_eq!(defs.len(), 3);
774
775        // Validate BTC
776        let btc = &defs[0];
777        assert_eq!(btc.symbol, "BTC-USD-PERP");
778        assert_eq!(btc.base, "BTC");
779        assert_eq!(btc.quote, "USD");
780        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
781        assert_eq!(btc.size_decimals, 5);
782        assert_eq!(btc.max_leverage, Some(40));
783        assert!(btc.active);
784
785        // Validate ETH
786        let eth = &defs[1];
787        assert_eq!(eth.symbol, "ETH-USD-PERP");
788        assert_eq!(eth.base, "ETH");
789        assert_eq!(eth.size_decimals, 4);
790        assert_eq!(eth.max_leverage, Some(25));
791
792        // Validate ATOM
793        let atom = &defs[2];
794        assert_eq!(atom.symbol, "ATOM-USD-PERP");
795        assert_eq!(atom.base, "ATOM");
796        assert_eq!(atom.size_decimals, 2);
797        assert_eq!(atom.max_leverage, Some(5));
798    }
799
800    #[rstest]
801    fn test_deserialize_l2_book_from_real_data() {
802        let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
803
804        // Validate basic structure
805        assert_eq!(book.coin, "BTC");
806        assert_eq!(book.levels.len(), 2); // [bids, asks]
807        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
808        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
809
810        // Verify bids and asks are properly ordered
811        let bids = &book.levels[0];
812        let asks = &book.levels[1];
813
814        // Bids should be descending (highest first)
815        for i in 1..bids.len() {
816            let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
817            let curr_price = bids[i].px.parse::<f64>().unwrap();
818            assert!(prev_price >= curr_price, "Bids should be descending");
819        }
820
821        // Asks should be ascending (lowest first)
822        for i in 1..asks.len() {
823            let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
824            let curr_price = asks[i].px.parse::<f64>().unwrap();
825            assert!(prev_price <= curr_price, "Asks should be ascending");
826        }
827    }
828
829    #[rstest]
830    fn test_parse_spot_instruments() {
831        let tokens = vec![
832            SpotToken {
833                name: "USDC".to_string(),
834                sz_decimals: 6,
835                wei_decimals: 6,
836                index: 0,
837                token_id: "0x1".to_string(),
838                is_canonical: true,
839                evm_contract: None,
840                full_name: None,
841                deployer_trading_fee_share: None,
842            },
843            SpotToken {
844                name: "PURR".to_string(),
845                sz_decimals: 0,
846                wei_decimals: 5,
847                index: 1,
848                token_id: "0x2".to_string(),
849                is_canonical: true,
850                evm_contract: None,
851                full_name: None,
852                deployer_trading_fee_share: None,
853            },
854        ];
855
856        let pairs = vec![
857            SpotPair {
858                name: "PURR/USDC".to_string(),
859                tokens: [1, 0], // PURR base, USDC quote
860                index: 0,
861                is_canonical: true,
862            },
863            SpotPair {
864                name: "ALIAS".to_string(),
865                tokens: [1, 0],
866                index: 1,
867                is_canonical: false, // Should be included but marked as inactive
868            },
869        ];
870
871        let meta = SpotMeta {
872            tokens,
873            universe: pairs,
874        };
875
876        let defs = parse_spot_instruments(&meta).unwrap();
877
878        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
879        assert_eq!(defs.len(), 2);
880
881        let purr_usdc = &defs[0];
882        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
883        assert_eq!(purr_usdc.base, "PURR");
884        assert_eq!(purr_usdc.quote, "USDC");
885        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
886        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
887        assert_eq!(purr_usdc.size_decimals, 0);
888        assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
889        assert_eq!(purr_usdc.lot_size, dec!(1));
890        assert_eq!(purr_usdc.max_leverage, None);
891        assert!(!purr_usdc.only_isolated);
892        assert!(purr_usdc.active);
893
894        let alias = &defs[1];
895        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
896        assert_eq!(alias.base, "PURR");
897        assert!(!alias.active); // Non-canonical pairs are marked as inactive
898    }
899
900    #[rstest]
901    fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
902        // Non-canonical pair uses a lower pair index than the canonical one;
903        // the sort must still put canonical first so the base-token alias in
904        // cache_instrument resolves to the canonical instrument.
905        let tokens = vec![
906            SpotToken {
907                name: "USDC".to_string(),
908                sz_decimals: 6,
909                wei_decimals: 6,
910                index: 0,
911                token_id: "0x1".to_string(),
912                is_canonical: true,
913                evm_contract: None,
914                full_name: None,
915                deployer_trading_fee_share: None,
916            },
917            SpotToken {
918                name: "HYPE".to_string(),
919                sz_decimals: 2,
920                wei_decimals: 8,
921                index: 150,
922                token_id: "0x2".to_string(),
923                is_canonical: true,
924                evm_contract: None,
925                full_name: None,
926                deployer_trading_fee_share: None,
927            },
928        ];
929
930        let pairs = vec![
931            SpotPair {
932                name: "HYPE_OLD".to_string(),
933                tokens: [150, 0],
934                index: 3,
935                is_canonical: false,
936            },
937            SpotPair {
938                name: "HYPE".to_string(),
939                tokens: [150, 0],
940                index: 107,
941                is_canonical: true,
942            },
943        ];
944
945        let defs = parse_spot_instruments(&SpotMeta {
946            tokens,
947            universe: pairs,
948        })
949        .unwrap();
950
951        assert_eq!(defs.len(), 2);
952        assert!(defs[0].active, "canonical must sort first");
953        assert_eq!(defs[0].asset_index, 10000 + 107);
954        assert!(!defs[1].active);
955        assert_eq!(defs[1].asset_index, 10000 + 3);
956    }
957
958    #[rstest]
959    fn test_price_decimals_clamping() {
960        let meta = PerpMeta {
961            universe: vec![PerpAsset {
962                name: "HIGHPREC".to_string(),
963                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
964                max_leverage: Some(1),
965                ..Default::default()
966            }],
967            margin_tables: vec![],
968        };
969
970        let defs = parse_perp_instruments(&meta, 0).unwrap();
971        assert_eq!(defs[0].price_decimals, 0);
972        assert_eq!(defs[0].tick_size, dec!(1));
973    }
974
975    #[rstest]
976    fn test_parse_perp_instruments_hip3_dex() {
977        // HIP-3 dex at index 1: asset_index_base = 100_000 + 1 * 10_000 = 110_000
978        let meta = PerpMeta {
979            universe: vec![
980                PerpAsset {
981                    name: "xyz:TSLA".to_string(),
982                    sz_decimals: 3,
983                    max_leverage: Some(10),
984                    only_isolated: None,
985                    is_delisted: None,
986                    growth_mode: Some("enabled".to_string()),
987                    margin_mode: Some("strictIsolated".to_string()),
988                },
989                PerpAsset {
990                    name: "xyz:NVDA".to_string(),
991                    sz_decimals: 3,
992                    max_leverage: Some(20),
993                    only_isolated: None,
994                    is_delisted: None,
995                    growth_mode: None,
996                    margin_mode: None,
997                },
998            ],
999            margin_tables: vec![],
1000        };
1001
1002        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1003        assert_eq!(defs.len(), 2);
1004
1005        // HIP-3 asset: colon in symbol, offset asset index
1006        assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1007        assert!(defs[0].symbol.contains(':'));
1008        assert_eq!(defs[0].base, "xyz:TSLA");
1009        assert_eq!(defs[0].asset_index, 110_000);
1010        assert!(defs[0].active);
1011
1012        assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1013        assert_eq!(defs[1].asset_index, 110_001);
1014    }
1015
1016    #[rstest]
1017    #[case("BTC", "BTC")]
1018    #[case("kPEPE", "kPEPE")]
1019    #[case("xyz:TSLA", "xyz:TSLA")]
1020    #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1021    #[case("ABC?", "ABCx")]
1022    #[case("a*b?c", "axbxc")]
1023    fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1024        assert_eq!(sanitize_symbol(input), expected);
1025    }
1026
1027    #[rstest]
1028    fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1029        // Hypothetical spot token whose venue name contains `?`. Sanitization
1030        // must apply to the constructed `symbol` while leaving `raw_symbol`
1031        // and `base` carrying the venue-official name for wire I/O.
1032        let tokens = vec![
1033            SpotToken {
1034                name: "USDC".to_string(),
1035                sz_decimals: 6,
1036                wei_decimals: 6,
1037                index: 0,
1038                token_id: "0x1".to_string(),
1039                is_canonical: true,
1040                evm_contract: None,
1041                full_name: None,
1042                deployer_trading_fee_share: None,
1043            },
1044            SpotToken {
1045                name: "ABC?".to_string(),
1046                sz_decimals: 4,
1047                wei_decimals: 4,
1048                index: 1,
1049                token_id: "0x2".to_string(),
1050                is_canonical: true,
1051                evm_contract: None,
1052                full_name: None,
1053                deployer_trading_fee_share: None,
1054            },
1055        ];
1056
1057        let pairs = vec![SpotPair {
1058            name: "ABC?/USDC".to_string(),
1059            tokens: [1, 0],
1060            index: 50,
1061            is_canonical: true,
1062        }];
1063
1064        let meta = SpotMeta {
1065            tokens,
1066            universe: pairs,
1067        };
1068
1069        let defs = parse_spot_instruments(&meta).unwrap();
1070        assert_eq!(defs.len(), 1);
1071        assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1072        assert_eq!(defs[0].base, "ABC?");
1073        assert_eq!(defs[0].quote, "USDC");
1074    }
1075
1076    #[rstest]
1077    fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1078        let meta = PerpMeta {
1079            universe: vec![PerpAsset {
1080                name: "dex:STREAMABCD****".to_string(),
1081                sz_decimals: 3,
1082                max_leverage: Some(10),
1083                only_isolated: None,
1084                is_delisted: None,
1085                growth_mode: None,
1086                margin_mode: None,
1087            }],
1088            margin_tables: vec![],
1089        };
1090
1091        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1092        assert_eq!(defs.len(), 1);
1093        assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1094        assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1095        assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1096    }
1097}