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::{Params, UUID4, UnixNanos};
18use nautilus_model::{
19    data::TradeTick,
20    enums::{
21        AggressorSide, AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
22        PositionSideSpecified, TimeInForce, TriggerType,
23    },
24    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
25    instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
26    reports::{FillReport, OrderStatusReport, PositionStatusReport},
27    types::{Currency, Money, Price, Quantity},
28};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use ustr::Ustr;
33
34use super::models::{
35    AssetPosition, HyperliquidFill, HyperliquidRecentTrade, OutcomeMarket, OutcomeMeta,
36    OutcomeQuestion, PerpMeta, SpotBalance, SpotMeta,
37};
38use crate::{
39    common::{
40        consts::HYPERLIQUID_VENUE,
41        enums::{
42            HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
43            HyperliquidSide, HyperliquidTimeInForce,
44        },
45        parse::{
46            format_outcome_nautilus_symbol, is_conditional_order_data, make_fill_trade_id,
47            millis_to_nanos, parse_trigger_order_type,
48        },
49        types::HyperliquidAssetId,
50    },
51    websocket::messages::{WsBasicOrderData, WsOrderData},
52};
53
54/// Market type enumeration for normalized instrument definitions.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum HyperliquidMarketType {
57    /// Perpetual futures contract.
58    Perp,
59    /// Spot trading pair.
60    Spot,
61    /// HIP-4 binary outcome side token.
62    Outcome,
63}
64
65/// Outcome-specific metadata carried on [`HyperliquidInstrumentDef`] for HIP-4
66/// binary outcome side tokens.
67///
68/// The venue's `outcomeMeta` payload is partial today (no precision or
69/// expiry fields), so unknown values are left as defaults until real venue
70/// payloads are available.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct HyperliquidOutcomeMetadata {
73    /// HIP-4 outcome index (`outcome` field from `outcomeMeta`).
74    pub outcome_index: u32,
75    /// Side digit (`0` or `1`).
76    pub outcome_side: u8,
77    /// Outcome market name (for example, "BTC daily").
78    pub market_name: Ustr,
79    /// Side specification name. Set from the venue's `sideSpecs` entry when
80    /// present, otherwise falls back to the canonical HIP-4 labels (`"Yes"`
81    /// for side `0`, `"No"` for side `1`).
82    pub side_name: Option<Ustr>,
83    /// Venue-supplied description.
84    pub description: Option<Ustr>,
85    /// Activation timestamp; `0` when the venue payload does not expose it.
86    pub activation_ns: UnixNanos,
87    /// Expiration timestamp; `0` when the venue payload does not expose it.
88    pub expiration_ns: UnixNanos,
89    /// Structured metadata surfaced as `BinaryOption.info`; see the Hyperliquid
90    /// integration guide for the field layout.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub info: Option<Params>,
93}
94
95/// Normalized instrument definition produced by this parser.
96///
97/// This deliberately avoids any tight coupling to Nautilus' Cython types.
98/// The InstrumentProvider can later convert this into Nautilus `Instrument`s.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct HyperliquidInstrumentDef {
101    /// Human-readable symbol (e.g., "BTC-USD-PERP", "PURR-USDC-SPOT").
102    pub symbol: Ustr,
103    /// Raw symbol used in Hyperliquid WebSocket subscriptions/messages.
104    /// For perps: base currency (e.g., "BTC").
105    /// For spot: `@{pair_index}` format (e.g., "@107" for HYPE-USDC).
106    /// For outcomes: `#<encoding>` spot-coin form (e.g., "#10").
107    pub raw_symbol: Ustr,
108    /// Base currency/asset (e.g., "BTC", "PURR").
109    pub base: Ustr,
110    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
111    pub quote: Ustr,
112    /// Settlement currency for perps. `None` for spot and outcome instruments.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub settlement: Option<Ustr>,
115    /// Market type (perpetual, spot, or outcome).
116    pub market_type: HyperliquidMarketType,
117    /// Asset index used for order submission.
118    /// For perps: index in meta.universe (0, 1, 2, ...).
119    /// For spot: 10000 + index in spotMeta.universe.
120    /// For outcomes: `100_000_000 + 10 * outcome + side`.
121    pub asset_index: u32,
122    /// Number of decimal places for price precision.
123    pub price_decimals: u32,
124    /// Number of decimal places for size precision.
125    pub size_decimals: u32,
126    /// Price tick size as decimal.
127    pub tick_size: Decimal,
128    /// Size lot increment as decimal.
129    pub lot_size: Decimal,
130    /// Maximum leverage (for perps).
131    pub max_leverage: Option<u32>,
132    /// Whether requires isolated margin only.
133    pub only_isolated: bool,
134    /// Whether this is a HIP-3 builder-deployed perpetual.
135    pub is_hip3: bool,
136    /// Whether the instrument is active/tradeable.
137    pub active: bool,
138    /// Outcome-specific metadata when [`market_type`](Self::market_type) is
139    /// [`HyperliquidMarketType::Outcome`].
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub outcome: Option<HyperliquidOutcomeMetadata>,
142    /// Raw upstream data for debugging.
143    pub raw_data: String,
144}
145
146// Replace wildcard bytes (`*`, `?`) in a venue-supplied symbol component with
147// `x` so the value is safe to embed in a Nautilus `InstrumentId`. HIP-3
148// perpetual names from Hyperliquid (e.g. `dex:STREAMABCD****-USD-PERP`)
149// collide with msgbus pattern syntax; the venue-official name is preserved on
150// `raw_symbol` for HTTP/WS wire calls, and orders use the numeric
151// `asset_index` so they do not see the substitution.
152#[must_use]
153fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
154    if value.bytes().any(|b| b == b'*' || b == b'?') {
155        let mut out = String::with_capacity(value.len());
156        for ch in value.chars() {
157            out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
158        }
159        std::borrow::Cow::Owned(out)
160    } else {
161        std::borrow::Cow::Borrowed(value)
162    }
163}
164
165/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
166///
167/// Hyperliquid perps follow specific rules:
168/// - Quote is always USD (USDC settled)
169/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
170/// - Active = !is_delisted
171///
172/// `asset_index_base` controls the starting offset for asset IDs:
173/// - Standard perps (dex 0): base = 0
174/// - HIP-3 dexes: base = 100_000 + dex_index * 10_000
175///
176/// Delisted instruments are included but marked as inactive to support
177/// parsing historical data for instruments that may still have trading history.
178pub fn parse_perp_instruments(
179    meta: &PerpMeta,
180    asset_index_base: u32,
181) -> Result<Vec<HyperliquidInstrumentDef>, String> {
182    Ok(parse_perp_instruments_with_settlement(
183        meta,
184        asset_index_base,
185        DEFAULT_PERP_SETTLEMENT_CURRENCY,
186    ))
187}
188
189pub(crate) fn parse_perp_instruments_with_settlement(
190    meta: &PerpMeta,
191    asset_index_base: u32,
192    settlement_currency: &str,
193) -> Vec<HyperliquidInstrumentDef> {
194    const PERP_MAX_DECIMALS: i32 = 6;
195
196    let mut defs = Vec::new();
197
198    for (index, asset) in meta.universe.iter().enumerate() {
199        let is_delisted = asset.is_delisted.unwrap_or(false);
200
201        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
202        let tick_size = pow10_neg(price_decimals);
203        let lot_size = pow10_neg(asset.sz_decimals);
204
205        let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
206
207        let raw_symbol: Ustr = asset.name.as_str().into();
208
209        let def = HyperliquidInstrumentDef {
210            symbol: symbol.into(),
211            raw_symbol,
212            base: asset.name.clone().into(),
213            quote: "USD".into(),
214            settlement: Some(settlement_currency.into()),
215            market_type: HyperliquidMarketType::Perp,
216            asset_index: asset_index_base + index as u32,
217            price_decimals,
218            size_decimals: asset.sz_decimals,
219            tick_size,
220            lot_size,
221            max_leverage: asset.max_leverage,
222            only_isolated: asset.only_isolated.unwrap_or(false),
223            is_hip3: asset_index_base > 0,
224            active: !is_delisted,
225            outcome: None,
226            raw_data: serde_json::to_string(asset).unwrap_or_default(),
227        };
228
229        defs.push(def);
230    }
231
232    defs
233}
234
235const DEFAULT_PERP_COLLATERAL_TOKEN: u32 = 0;
236const DEFAULT_PERP_SETTLEMENT_CURRENCY: &str = "USDC";
237
238pub(crate) fn resolve_perp_settlement_currency(
239    meta: &PerpMeta,
240    spot_meta: Option<&SpotMeta>,
241) -> Result<Ustr, String> {
242    let Some(collateral_token) = meta.collateral_token else {
243        return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
244    };
245
246    if collateral_token == DEFAULT_PERP_COLLATERAL_TOKEN {
247        return Ok(DEFAULT_PERP_SETTLEMENT_CURRENCY.into());
248    }
249
250    let spot_meta = spot_meta.ok_or_else(|| {
251        format!("Spot metadata required to resolve perp collateral token {collateral_token}")
252    })?;
253    let token = spot_meta
254        .tokens
255        .iter()
256        .find(|token| token.index == collateral_token)
257        .ok_or_else(|| {
258            format!("Perp collateral token index {collateral_token} not found in spot metadata")
259        })?;
260
261    Ok(token.name.as_str().into())
262}
263
264/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
265///
266/// Hyperliquid spot follows these rules:
267/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
268/// - Size decimals from base token
269/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
270///   for instruments that may have been traded
271pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
272    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
273    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
274
275    let mut defs = Vec::new();
276
277    // Build index -> token lookup
278    let mut tokens_by_index = ahash::AHashMap::new();
279    for token in &meta.tokens {
280        tokens_by_index.insert(token.index, token);
281    }
282
283    for pair in &meta.universe {
284        // Load all pairs (including non-canonical) to support parsing fills/positions
285        // for instruments that may have been traded but are not currently canonical
286
287        let base_token = tokens_by_index
288            .get(&pair.tokens[0])
289            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
290        let quote_token = tokens_by_index
291            .get(&pair.tokens[1])
292            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
293
294        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
295        let tick_size = pow10_neg(price_decimals);
296        let lot_size = pow10_neg(base_token.sz_decimals);
297
298        let symbol = format!(
299            "{}-{}-SPOT",
300            sanitize_symbol(&base_token.name),
301            sanitize_symbol(&quote_token.name),
302        );
303
304        // Hyperliquid spot raw_symbol formats (per API docs):
305        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
306        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
307        let raw_symbol: Ustr = if base_token.name == "PURR" {
308            pair.name.as_str().into()
309        } else {
310            format!("@{}", pair.index).into()
311        };
312
313        let def = HyperliquidInstrumentDef {
314            symbol: symbol.into(),
315            raw_symbol,
316            base: base_token.name.clone().into(),
317            quote: quote_token.name.clone().into(),
318            settlement: None,
319            market_type: HyperliquidMarketType::Spot,
320            asset_index: SPOT_INDEX_OFFSET + pair.index,
321            price_decimals,
322            size_decimals: base_token.sz_decimals,
323            tick_size,
324            lot_size,
325            max_leverage: None,
326            only_isolated: false,
327            is_hip3: false,
328            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
329            outcome: None,
330            raw_data: serde_json::to_string(pair).unwrap_or_default(),
331        };
332
333        defs.push(def);
334    }
335
336    // Canonical pairs must be cached first so the base-token alias (e.g.
337    // "PURR" -> PURR-USDC-SPOT) resolves to the canonical instrument when
338    // non-canonical pairs share the same base. Secondary key keeps the
339    // order stable within each bucket.
340    defs.sort_by(|a, b| {
341        b.active
342            .cmp(&a.active)
343            .then(a.asset_index.cmp(&b.asset_index))
344    });
345
346    Ok(defs)
347}
348
349// Default precision for HIP-4 outcome side tokens until the venue exposes
350// per-market values via `outcomeMeta`. Outcomes settle in `[0, 1]` so 4
351// decimals of price granularity (tick `0.0001`) and 2 decimals of size
352// granularity (lot `0.01`) are conservative starting values; refine when
353// real venue payloads land.
354pub const OUTCOME_PRICE_DECIMALS: u32 = 4;
355pub const OUTCOME_SIZE_DECIMALS: u32 = 2;
356
357/// Parse outcome instrument definitions from Hyperliquid `outcomeMeta` response.
358///
359/// Each [`OutcomeMarket`] yields two definitions, one per side (`0` and `1`),
360/// modeled as binary outcome side tokens. The Nautilus internal symbol uses
361/// the form `{outcome_index}-{YES|NO}-OUTCOME` (symmetric with `-PERP` /
362/// `-SPOT`), and the wire `raw_symbol` uses the spot-coin form
363/// (`#<encoding>`) which is what `l2Book`, `trades`, and `bbo` subscriptions
364/// accept.
365///
366/// Expiry is read from the market's own description when it carries
367/// `class:priceBinary`; for outcomes that point at a parent question (`other`
368/// or `index:N`), the expiry is inherited from that question's description.
369///
370/// `side_name` is taken from the venue's `sideSpecs` entry when present,
371/// otherwise it falls back to the canonical HIP-4 labels (`"Yes"` / `"No"`).
372pub fn parse_outcome_instruments(
373    meta: &OutcomeMeta,
374) -> Result<Vec<HyperliquidInstrumentDef>, String> {
375    let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
376
377    for market in &meta.outcomes {
378        for side in 0u8..=1u8 {
379            defs.push(build_outcome_def(market, side, meta)?);
380        }
381    }
382
383    Ok(defs)
384}
385
386fn build_outcome_def(
387    market: &OutcomeMarket,
388    side: u8,
389    meta: &OutcomeMeta,
390) -> Result<HyperliquidInstrumentDef, String> {
391    let outcome_index = market.outcome;
392    let asset_id = HyperliquidAssetId::outcome(outcome_index, side);
393    let encoding = asset_id.outcome_encoding().ok_or_else(|| {
394        format!("Invalid outcome encoding for outcome={outcome_index} side={side}")
395    })?;
396
397    let token = format!("+{encoding}");
398    let coin = format!("#{encoding}");
399    let symbol = format_outcome_nautilus_symbol(outcome_index, side);
400
401    let side_name = market
402        .side_specs
403        .get(usize::from(side))
404        .map(|spec| Ustr::from(spec.name.as_str()))
405        .or_else(|| Some(Ustr::from(default_side_label(side))));
406
407    let description = if market.description.is_empty() {
408        None
409    } else {
410        Some(Ustr::from(market.description.as_str()))
411    };
412
413    let parent_question = meta.parent_question(outcome_index);
414    let expiration_ns = resolve_outcome_expiration_ns(market, meta);
415
416    let info = build_outcome_info(
417        market,
418        side,
419        encoding,
420        asset_id.to_raw(),
421        side_name.as_ref().map(Ustr::as_str),
422        parent_question,
423    );
424
425    let outcome_metadata = HyperliquidOutcomeMetadata {
426        outcome_index,
427        outcome_side: side,
428        market_name: Ustr::from(market.name.as_str()),
429        side_name,
430        description,
431        activation_ns: UnixNanos::default(),
432        expiration_ns,
433        info: Some(info),
434    };
435
436    Ok(HyperliquidInstrumentDef {
437        symbol: Ustr::from(symbol.as_str()),
438        raw_symbol: Ustr::from(coin.as_str()),
439        base: Ustr::from(token.as_str()),
440        quote: "USDH".into(),
441        settlement: None,
442        market_type: HyperliquidMarketType::Outcome,
443        asset_index: asset_id.to_raw(),
444        price_decimals: OUTCOME_PRICE_DECIMALS,
445        size_decimals: OUTCOME_SIZE_DECIMALS,
446        tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
447        lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
448        max_leverage: None,
449        only_isolated: false,
450        is_hip3: false,
451        active: true,
452        outcome: Some(outcome_metadata),
453        raw_data: serde_json::to_string(market).unwrap_or_default(),
454    })
455}
456
457// Side `0` is Yes, `1` is No; matches the HIP-4 encoding convention.
458fn default_side_label(side: u8) -> &'static str {
459    if side == 0 { "Yes" } else { "No" }
460}
461
462// Splits a `key:value|key:value|...` description into snake_case keyed entries
463// keyed on the venue's camelCase keys lowered to snake_case. Empty descriptions
464// produce an empty iterator.
465fn parse_description_fields(description: &str) -> impl Iterator<Item = (String, String)> + '_ {
466    description
467        .split('|')
468        .filter_map(|piece| piece.split_once(':'))
469        .map(|(key, value)| (camel_to_snake(key.trim()), value.trim().to_string()))
470}
471
472fn camel_to_snake(s: &str) -> String {
473    let mut out = String::with_capacity(s.len() + 4);
474    for (i, ch) in s.char_indices() {
475        if ch.is_ascii_uppercase() {
476            if i > 0 {
477                out.push('_');
478            }
479            out.push(ch.to_ascii_lowercase());
480        } else {
481            out.push(ch);
482        }
483    }
484    out
485}
486
487fn build_outcome_info(
488    market: &OutcomeMarket,
489    side: u8,
490    encoding: u32,
491    asset_id_raw: u32,
492    side_name: Option<&str>,
493    parent_question: Option<&OutcomeQuestion>,
494) -> Params {
495    let mut info = Params::new();
496
497    info.insert("outcome_index".into(), json!(market.outcome));
498    info.insert("outcome_side".into(), json!(side));
499    if let Some(name) = side_name {
500        info.insert("side_name".into(), Value::String(name.to_string()));
501    }
502    info.insert("encoding".into(), json!(encoding));
503    info.insert("asset_id".into(), json!(asset_id_raw));
504    info.insert("market_name".into(), Value::String(market.name.clone()));
505
506    // Direct binary outcomes (`class:priceBinary|...`) carry the full metadata
507    // on the market description. Named-outcome descriptions are sentinels
508    // (`index:N` / `other`) that just point at the parent question.
509    for (key, value) in parse_description_fields(&market.description) {
510        match key.as_str() {
511            "index" => {
512                if let Ok(named) = value.parse::<u32>() {
513                    info.insert("named_index".into(), json!(named));
514                }
515            }
516            "other" => {
517                info.insert("is_fallback".into(), json!(true));
518            }
519            _ => {
520                info.insert(key, Value::String(value));
521            }
522        }
523    }
524
525    // The market description for named outcomes is literally the keyless
526    // sentinel `other`; capture it explicitly so consumers don't need to
527    // inspect the raw description.
528    if market.description.trim() == "other" {
529        info.insert("is_fallback".into(), json!(true));
530    }
531
532    if let Some(question) = parent_question {
533        info.insert("question".into(), json!(question.question));
534        info.insert("question_name".into(), Value::String(question.name.clone()));
535        for (key, value) in parse_description_fields(&question.description) {
536            let prefixed = format!("question_{key}");
537            info.insert(prefixed, Value::String(value));
538        }
539    }
540
541    info
542}
543
544fn pow10_neg(decimals: u32) -> Decimal {
545    if decimals == 0 {
546        return Decimal::ONE;
547    }
548
549    // Build 1 / 10^decimals using integer arithmetic
550    Decimal::from_i128_with_scale(1, decimals)
551}
552
553// Direct binary outcomes carry `expiry:` in their own description. Named
554// outcomes (`index:N`) and the `other` fallback inherit expiry from the
555// parent question. Returns zero when no expiry can be located.
556fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
557    if let Some(ns) = parse_expiry_from_description(&market.description) {
558        return ns;
559    }
560
561    meta.parent_question(market.outcome)
562        .and_then(|q| parse_expiry_from_description(&q.description))
563        .unwrap_or_default()
564}
565
566fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
567    description
568        .split('|')
569        .filter_map(|piece| piece.split_once(':'))
570        .find_map(|(key, value)| (key == "expiry").then_some(value))
571        .and_then(parse_outcome_expiry_ns)
572}
573
574// Parses a Hyperliquid outcome expiry stamp `YYYYMMDD-HHMM` (UTC) to UnixNanos.
575fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
576    let (date_part, time_part) = s.split_once('-')?;
577    if date_part.len() != 8 || time_part.len() != 4 {
578        return None;
579    }
580
581    let year: i32 = date_part[0..4].parse().ok()?;
582    let month: u32 = date_part[4..6].parse().ok()?;
583    let day: u32 = date_part[6..8].parse().ok()?;
584    let hour: u32 = time_part[0..2].parse().ok()?;
585    let minute: u32 = time_part[2..4].parse().ok()?;
586
587    let datetime = chrono::NaiveDate::from_ymd_opt(year, month, day)?
588        .and_hms_opt(hour, minute, 0)?
589        .and_utc();
590    let nanos = datetime.timestamp_nanos_opt()?;
591    u64::try_from(nanos).ok().map(UnixNanos::from)
592}
593
594/// Settlement state for a single HIP-4 outcome side token.
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub struct OutcomeSettlement {
597    /// Outcome index from `outcomeMeta`.
598    pub outcome_index: u32,
599    /// Side token (`0` or `1`).
600    pub outcome_side: u8,
601    /// Final settlement value: `1` for the winning side, `0` for losing sides.
602    pub final_value: u8,
603}
604
605/// Derives per-side settlement values from an `outcomeMeta` snapshot.
606///
607/// Returns one [`OutcomeSettlement`] for every side of every outcome whose
608/// resolution can be inferred from the snapshot:
609///
610/// - For each question with non-empty `settled_named_outcomes`, every named
611///   outcome and the fallback are emitted: the winning named outcomes get
612///   `Yes -> 1, No -> 0`, every other named outcome and the fallback get
613///   `Yes -> 0, No -> 1`.
614/// - Standalone outcomes (not referenced by any question) are skipped because
615///   the venue does not expose their resolution in `outcomeMeta`. They will
616///   need a separate signal (status flag, fill, or position-state event).
617///
618/// Outcomes referenced by a question that has not yet settled are also
619/// skipped. This lets a caller poll `outcomeMeta` and emit settlement events
620/// when entries first appear in the result.
621#[must_use]
622pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
623    let mut settlements = Vec::new();
624
625    for question in &meta.questions {
626        if question.settled_named_outcomes.is_empty() {
627            continue;
628        }
629
630        let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
631            // Named outcome did not win; Yes side -> 0, No side -> 1.
632            [
633                OutcomeSettlement {
634                    outcome_index,
635                    outcome_side: 0,
636                    final_value: 0,
637                },
638                OutcomeSettlement {
639                    outcome_index,
640                    outcome_side: 1,
641                    final_value: 1,
642                },
643            ]
644        };
645
646        let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
647            // Named outcome won; Yes side -> 1, No side -> 0.
648            [
649                OutcomeSettlement {
650                    outcome_index,
651                    outcome_side: 0,
652                    final_value: 1,
653                },
654                OutcomeSettlement {
655                    outcome_index,
656                    outcome_side: 1,
657                    final_value: 0,
658                },
659            ]
660        };
661
662        for outcome_index in &question.named_outcomes {
663            if question.settled_named_outcomes.contains(outcome_index) {
664                settlements.extend(winning_sides(*outcome_index));
665            } else {
666                settlements.extend(losing_sides_won(*outcome_index));
667            }
668        }
669
670        // The fallback is the "no named outcome resolved" branch; it loses
671        // whenever any named outcome won.
672        if let Some(fallback) = question.fallback_outcome {
673            settlements.extend(losing_sides_won(fallback));
674        }
675    }
676
677    settlements
678}
679
680pub fn get_currency(code: &str) -> Currency {
681    Currency::try_from_str(code).unwrap_or_else(|| {
682        let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
683        if let Err(e) = Currency::register(currency, false) {
684            log::error!("Failed to register currency '{code}': {e}");
685        }
686        currency
687    })
688}
689
690/// Returns the HIP-4 outcome settlement currency, registering it on first call.
691///
692/// Outcome markets settle in USDH (token index 360 on the `USDH/USDC` spot pair
693/// `@230`), not USDC. The registration is explicit so the precision is
694/// deterministic rather than dependent on whichever caller first triggers
695/// `get_currency`'s auto-register path.
696pub fn get_usdh_currency() -> Currency {
697    Currency::try_from_str("USDH").unwrap_or_else(|| {
698        let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
699        if let Err(e) = Currency::register(currency, false) {
700            log::error!("Failed to register USDH currency: {e}");
701        }
702        currency
703    })
704}
705
706/// Resolves the commission currency for a fill given the venue's `feeToken` field.
707///
708/// HIP-4 outcome fills echo the side token (e.g. `+50`) as `feeToken` even when
709/// the fee is zero. The side token is not a Nautilus currency and emitting it as
710/// the commission currency would leak into `OrderFilled` events and persistence;
711/// for outcome side tokens the instrument's quote currency is always used, even
712/// when another adapter path (such as spot-balance parsing) has registered the
713/// side token in the global registry. Non-zero side-token fees error: the venue
714/// does not denominate fees in side tokens. Other unknown tokens fall back to
715/// the instrument's quote currency only when the fee is zero.
716///
717/// # Errors
718///
719/// Returns an error when an outcome side token carries a non-zero fee, or when
720/// `fee_token` cannot be resolved and `fee_amount` is non-zero.
721pub fn resolve_fee_currency(
722    fee_token: &str,
723    fee_amount: Decimal,
724    instrument: &dyn Instrument,
725) -> anyhow::Result<Currency> {
726    if is_outcome_side_token(fee_token) {
727        if !fee_amount.is_zero() {
728            anyhow::bail!(
729                "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
730                 venue does not denominate fees in side tokens",
731            );
732        }
733        return Ok(instrument.quote_currency());
734    }
735
736    if let Some(currency) = Currency::try_from_str(fee_token) {
737        return Ok(currency);
738    }
739
740    if fee_amount.is_zero() {
741        let fallback = instrument.quote_currency();
742        log::debug!(
743            "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
744            instrument.id(),
745        );
746        return Ok(fallback);
747    }
748
749    anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
750}
751
752fn is_outcome_side_token(symbol: &str) -> bool {
753    let Some(rest) = symbol.strip_prefix('+') else {
754        return false;
755    };
756    !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
757}
758
759// Hyperliquid documents a venue-wide minimum order notional: $10 for perps,
760// and 10 quote_token for spot.
761// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/error-responses
762const HYPERLIQUID_MIN_ORDER_NOTIONAL: Decimal = Decimal::TEN;
763
764/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
765///
766/// Returns `None` if the conversion fails (e.g., unsupported market type).
767#[must_use]
768pub fn create_instrument_from_def(
769    def: &HyperliquidInstrumentDef,
770    ts_init: UnixNanos,
771) -> Option<InstrumentAny> {
772    let symbol = Symbol::new(def.symbol);
773    let venue = *HYPERLIQUID_VENUE;
774    let instrument_id = InstrumentId::new(symbol, venue);
775
776    // Use the raw_symbol from the definition which is format-specific:
777    // - Perps: base currency (e.g., "BTC")
778    // - Spot PURR: slash format (e.g., "PURR/USDC")
779    // - Spot others: @{index} format (e.g., "@107")
780    let raw_symbol = Symbol::new(def.raw_symbol);
781    let price_increment = Price::from(def.tick_size.to_string());
782    let size_increment = Quantity::from(def.lot_size.to_string());
783
784    match def.market_type {
785        HyperliquidMarketType::Spot => {
786            let base_currency = get_currency(&def.base);
787            let quote_currency = get_currency(&def.quote);
788            let min_notional = Some(min_order_notional(quote_currency)?);
789
790            Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
791                instrument_id,
792                raw_symbol,
793                base_currency,
794                quote_currency,
795                def.price_decimals as u8,
796                def.size_decimals as u8,
797                price_increment,
798                size_increment,
799                None,
800                None,
801                None,
802                None,
803                None,
804                min_notional,
805                None,
806                None,
807                None,
808                None,
809                None,
810                None,
811                None,
812                None,
813                ts_init, // Identical to ts_init for now
814                ts_init,
815            )))
816        }
817        HyperliquidMarketType::Perp => {
818            let base_currency = get_currency(&def.base);
819            let quote_currency = get_currency(&def.quote);
820            let settlement_code = def
821                .settlement
822                .as_ref()
823                .map_or(DEFAULT_PERP_SETTLEMENT_CURRENCY, Ustr::as_str);
824            let settlement_currency = if settlement_code == "USDH" {
825                get_usdh_currency()
826            } else {
827                get_currency(settlement_code)
828            };
829            let min_notional = Some(min_order_notional(quote_currency)?);
830
831            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
832                instrument_id,
833                raw_symbol,
834                base_currency,
835                quote_currency,
836                settlement_currency,
837                false,
838                def.price_decimals as u8,
839                def.size_decimals as u8,
840                price_increment,
841                size_increment,
842                None, // multiplier
843                None,
844                None,
845                None,
846                None,
847                min_notional,
848                None,
849                None,
850                None,
851                None,
852                None,
853                None,
854                None,
855                None,
856                ts_init, // Identical to ts_init for now
857                ts_init,
858            )))
859        }
860        HyperliquidMarketType::Outcome => {
861            let outcome = def.outcome.as_ref()?;
862            let currency = get_usdh_currency();
863
864            Some(InstrumentAny::BinaryOption(BinaryOption::new(
865                instrument_id,
866                raw_symbol,
867                AssetClass::Alternative,
868                currency,
869                outcome.activation_ns,
870                outcome.expiration_ns,
871                def.price_decimals as u8,
872                def.size_decimals as u8,
873                price_increment,
874                size_increment,
875                outcome.side_name,
876                outcome.description,
877                None, // max_quantity
878                None, // min_quantity
879                None, // max_notional
880                None, // min_notional
881                None, // max_price
882                None, // min_price
883                None, // margin_init
884                None, // margin_maint
885                None, // maker_fee
886                None, // taker_fee
887                None, // tick_scheme
888                outcome.info.clone(),
889                ts_init,
890                ts_init,
891            )))
892        }
893    }
894}
895
896fn min_order_notional(currency: Currency) -> Option<Money> {
897    Money::from_decimal(HYPERLIQUID_MIN_ORDER_NOTIONAL, currency).ok()
898}
899
900/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
901/// discarding any definitions that fail to convert.
902#[must_use]
903pub fn instruments_from_defs(
904    defs: &[HyperliquidInstrumentDef],
905    ts_init: UnixNanos,
906) -> Vec<InstrumentAny> {
907    defs.iter()
908        .filter_map(|def| create_instrument_from_def(def, ts_init))
909        .collect()
910}
911
912/// Convert owned definitions into Nautilus instruments, consuming the input vector.
913#[must_use]
914pub fn instruments_from_defs_owned(
915    defs: Vec<HyperliquidInstrumentDef>,
916    ts_init: UnixNanos,
917) -> Vec<InstrumentAny> {
918    defs.into_iter()
919        .filter_map(|def| create_instrument_from_def(&def, ts_init))
920        .collect()
921}
922
923fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
924    match side {
925        HyperliquidSide::Buy => OrderSide::Buy,
926        HyperliquidSide::Sell => OrderSide::Sell,
927    }
928}
929
930/// Parse WebSocket order data to OrderStatusReport.
931///
932/// # Errors
933///
934/// Returns an error if required fields are missing or invalid.
935pub fn parse_order_status_report_from_ws(
936    order_data: &WsOrderData,
937    instrument: &dyn Instrument,
938    account_id: AccountId,
939    ts_init: UnixNanos,
940) -> anyhow::Result<OrderStatusReport> {
941    parse_order_status_report_from_basic(
942        &order_data.order,
943        &order_data.status,
944        instrument,
945        account_id,
946        ts_init,
947    )
948}
949
950/// Parse basic order data to OrderStatusReport.
951///
952/// # Errors
953///
954/// Returns an error if required fields are missing or invalid.
955pub fn parse_order_status_report_from_basic(
956    order: &WsBasicOrderData,
957    status: &HyperliquidOrderStatusEnum,
958    instrument: &dyn Instrument,
959    account_id: AccountId,
960    ts_init: UnixNanos,
961) -> anyhow::Result<OrderStatusReport> {
962    let instrument_id = instrument.id();
963    let venue_order_id = VenueOrderId::new(order.oid.to_string());
964    let order_side = OrderSide::from(order.side);
965
966    let is_conditional = is_conditional_order_data(order.trigger_px, order.tpsl.as_ref());
967    let order_type = if is_conditional {
968        match (order.is_market, order.tpsl.as_ref()) {
969            (Some(is_market), Some(tpsl)) => parse_trigger_order_type(is_market, tpsl),
970            (None, Some(tpsl)) => parse_trigger_order_type(false, tpsl),
971            _ => OrderType::Limit,
972        }
973    } else {
974        OrderType::Limit
975    };
976
977    let time_in_force = match order.tif {
978        Some(HyperliquidTimeInForce::Ioc) => TimeInForce::Ioc,
979        _ => TimeInForce::Gtc,
980    };
981    let order_status = OrderStatus::from(*status);
982
983    let price_precision = instrument.price_precision();
984    let size_precision = instrument.size_precision();
985
986    let orig_sz = order.orig_sz;
987    let current_sz = order.sz;
988
989    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
990        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
991    let filled_sz = orig_sz.abs() - current_sz.abs();
992    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
993        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
994
995    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
996    let ts_last = ts_accepted;
997    let report_id = UUID4::new();
998
999    let mut report = OrderStatusReport::new(
1000        account_id,
1001        instrument_id,
1002        None, // client_order_id - will be set if present
1003        venue_order_id,
1004        order_side,
1005        order_type,
1006        time_in_force,
1007        order_status,
1008        quantity,
1009        filled_qty,
1010        ts_accepted,
1011        ts_last,
1012        ts_init,
1013        Some(report_id),
1014    );
1015
1016    // Add client order ID if present
1017    if let Some(cloid) = &order.cloid {
1018        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
1019    }
1020
1021    if matches!(order.tif, Some(HyperliquidTimeInForce::Alo)) {
1022        report = report.with_post_only(true);
1023    }
1024
1025    if let Some(reduce_only) = order.reduce_only {
1026        report = report.with_reduce_only(reduce_only);
1027    }
1028
1029    // Only set price for non-filled orders. For filled orders, the limit price is not
1030    // the execution price, and setting it would cause bogus inferred fills to be created
1031    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
1032    if !matches!(
1033        order_status,
1034        OrderStatus::Filled | OrderStatus::PartiallyFilled
1035    ) {
1036        let price = Price::from_decimal_dp(order.limit_px, price_precision)
1037            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
1038        report = report.with_price(price);
1039    }
1040
1041    if is_conditional && let Some(trigger_px) = order.trigger_px {
1042        let trigger_price = Price::from_decimal_dp(trigger_px, price_precision)
1043            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
1044        report = report
1045            .with_trigger_price(trigger_price)
1046            .with_trigger_type(TriggerType::Default);
1047    }
1048
1049    Ok(report)
1050}
1051
1052/// Parses a `recentTrades` info entry into a [`TradeTick`].
1053///
1054/// Mirrors the field mapping of the WebSocket trade parser
1055/// [`parse_ws_trade_tick`](crate::websocket::parse::parse_ws_trade_tick): both the
1056/// `trades` channel and the `recentTrades` endpoint carry the same
1057/// `px`/`sz`/`side`/`time`/`tid` fields. For this historical snapshot `ts_init` is
1058/// set to the trade's `ts_event` (venue time), matching the other request
1059/// converters so the data engine's window trimming keeps bounded requests.
1060///
1061/// # Errors
1062///
1063/// Returns an error if the price, size, trade identifier, or timestamp is invalid.
1064pub fn parse_recent_trade(
1065    trade: &HyperliquidRecentTrade,
1066    instrument: &InstrumentAny,
1067) -> anyhow::Result<TradeTick> {
1068    let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1069        .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1070
1071    let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1072        .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1073
1074    let aggressor = AggressorSide::from(trade.side);
1075    let trade_id = TradeId::new_checked(trade.tid.to_string())
1076        .context("invalid trade identifier in Hyperliquid recent trade")?;
1077    let ts_event = millis_to_nanos(trade.time)?;
1078
1079    TradeTick::new_checked(
1080        instrument.id(),
1081        price,
1082        size,
1083        aggressor,
1084        trade_id,
1085        ts_event,
1086        ts_event,
1087    )
1088    .context("failed to construct TradeTick from Hyperliquid recent trade")
1089}
1090
1091/// Parse Hyperliquid fill to FillReport.
1092///
1093/// # Errors
1094///
1095/// Returns an error if required fields are missing or invalid.
1096pub fn parse_fill_report(
1097    fill: &HyperliquidFill,
1098    instrument: &dyn Instrument,
1099    account_id: AccountId,
1100    ts_init: UnixNanos,
1101) -> anyhow::Result<FillReport> {
1102    let instrument_id = instrument.id();
1103    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
1104
1105    if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
1106        log::warn!(
1107            "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
1108            fill.oid,
1109            fill.px,
1110            fill.sz,
1111        );
1112    }
1113
1114    let trade_id = make_fill_trade_id(
1115        &fill.hash,
1116        fill.oid,
1117        fill.px,
1118        fill.sz,
1119        fill.time,
1120        fill.start_position,
1121    );
1122    let order_side = parse_fill_side(&fill.side);
1123
1124    let price_precision = instrument.price_precision();
1125    let size_precision = instrument.size_precision();
1126
1127    let last_px = Price::from_decimal_dp(fill.px, price_precision)
1128        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
1129    let last_qty = Quantity::from_decimal_dp(fill.sz.abs(), size_precision)
1130        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
1131
1132    let fee_amount = fill.fee;
1133
1134    let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
1135    let commission = Money::from_decimal(fee_amount, fee_currency)
1136        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
1137
1138    // Determine liquidity side based on 'crossed' flag
1139    let liquidity_side = if fill.crossed {
1140        LiquiditySide::Taker
1141    } else {
1142        LiquiditySide::Maker
1143    };
1144
1145    let ts_event = UnixNanos::from(fill.time * 1_000_000);
1146    let report_id = UUID4::new();
1147
1148    let report = FillReport::new(
1149        account_id,
1150        instrument_id,
1151        venue_order_id,
1152        trade_id,
1153        order_side,
1154        last_qty,
1155        last_px,
1156        commission,
1157        liquidity_side,
1158        None, // client_order_id - to be linked by execution engine
1159        None, // venue_position_id
1160        ts_event,
1161        ts_init,
1162        Some(report_id),
1163    );
1164
1165    Ok(report)
1166}
1167
1168/// Parse position data from clearinghouse state to PositionStatusReport.
1169///
1170/// # Errors
1171///
1172/// Returns an error if required fields are missing or invalid.
1173pub fn parse_position_status_report(
1174    position_data: &serde_json::Value,
1175    instrument: &dyn Instrument,
1176    account_id: AccountId,
1177    ts_init: UnixNanos,
1178) -> anyhow::Result<PositionStatusReport> {
1179    // Deserialize the position data
1180    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
1181        .context("failed to deserialize AssetPosition")?;
1182
1183    let position = &asset_position.position;
1184    let instrument_id = instrument.id();
1185
1186    // Determine position side based on size (szi)
1187    let (position_side, quantity_value) = if position.szi.is_zero() {
1188        (PositionSideSpecified::Flat, Decimal::ZERO)
1189    } else if position.szi.is_sign_positive() {
1190        (PositionSideSpecified::Long, position.szi)
1191    } else {
1192        (PositionSideSpecified::Short, position.szi.abs())
1193    };
1194
1195    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1196        .context("failed to create quantity from decimal")?;
1197    let report_id = UUID4::new();
1198    let ts_last = ts_init;
1199    let avg_px_open = position.entry_px;
1200
1201    // Hyperliquid uses netting (one position per instrument), not hedging
1202    Ok(PositionStatusReport::new(
1203        account_id,
1204        instrument_id,
1205        position_side,
1206        quantity,
1207        ts_last,
1208        ts_init,
1209        Some(report_id),
1210        None, // No venue_position_id for netting positions
1211        avg_px_open,
1212    ))
1213}
1214
1215/// Parse a spot token balance into a [`PositionStatusReport`] against the spot instrument.
1216///
1217/// Spot holdings are always Long (Hyperliquid spot has no short exposure). The average
1218/// entry price is derived from `entry_ntl / total` when both are non-zero; otherwise it
1219/// is omitted.
1220///
1221/// # Errors
1222///
1223/// Returns an error if the quantity cannot be constructed at the instrument's precision.
1224pub fn parse_spot_position_status_report(
1225    balance: &SpotBalance,
1226    instrument: &dyn Instrument,
1227    account_id: AccountId,
1228    ts_init: UnixNanos,
1229) -> anyhow::Result<PositionStatusReport> {
1230    let (position_side, quantity_value) = if balance.total.is_zero() {
1231        (PositionSideSpecified::Flat, Decimal::ZERO)
1232    } else {
1233        (PositionSideSpecified::Long, balance.total)
1234    };
1235
1236    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1237        .context("failed to create spot quantity from decimal")?;
1238
1239    Ok(PositionStatusReport::new(
1240        account_id,
1241        instrument.id(),
1242        position_side,
1243        quantity,
1244        ts_init,
1245        ts_init,
1246        Some(UUID4::new()),
1247        None,
1248        balance.avg_entry_px(),
1249    ))
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use rstest::rstest;
1255    use rust_decimal_macros::dec;
1256
1257    use super::{
1258        super::models::{
1259            HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1260            PerpAsset, SpotPair, SpotToken,
1261        },
1262        *,
1263    };
1264
1265    #[rstest]
1266    fn test_parse_fill_side() {
1267        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
1268        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
1269    }
1270
1271    #[rstest]
1272    fn test_pow10_neg() {
1273        assert_eq!(pow10_neg(0), dec!(1));
1274        assert_eq!(pow10_neg(1), dec!(0.1));
1275        assert_eq!(pow10_neg(5), dec!(0.00001));
1276    }
1277
1278    #[rstest]
1279    fn test_parse_perp_instruments() {
1280        let meta = PerpMeta {
1281            universe: vec![
1282                PerpAsset {
1283                    name: "BTC".to_string(),
1284                    sz_decimals: 5,
1285                    max_leverage: Some(50),
1286                    ..Default::default()
1287                },
1288                PerpAsset {
1289                    name: "DELIST".to_string(),
1290                    sz_decimals: 3,
1291                    max_leverage: Some(10),
1292                    only_isolated: Some(true),
1293                    is_delisted: Some(true),
1294                    ..Default::default()
1295                },
1296            ],
1297            margin_tables: vec![],
1298            collateral_token: None,
1299        };
1300
1301        let defs = parse_perp_instruments(&meta, 0).unwrap();
1302
1303        // Should have both BTC and DELIST (delisted instruments are included for historical data)
1304        assert_eq!(defs.len(), 2);
1305
1306        let btc = &defs[0];
1307        assert_eq!(btc.symbol, "BTC-USD-PERP");
1308        assert_eq!(btc.base, "BTC");
1309        assert_eq!(btc.quote, "USD");
1310        assert_eq!(btc.settlement.as_ref().unwrap().as_str(), "USDC");
1311        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1312        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
1313        assert_eq!(btc.size_decimals, 5);
1314        assert_eq!(btc.tick_size, dec!(0.1));
1315        assert_eq!(btc.lot_size, dec!(0.00001));
1316        assert_eq!(btc.max_leverage, Some(50));
1317        assert!(!btc.only_isolated);
1318        assert!(btc.active);
1319
1320        let delist = &defs[1];
1321        assert_eq!(delist.symbol, "DELIST-USD-PERP");
1322        assert_eq!(delist.base, "DELIST");
1323        assert!(!delist.active); // Delisted instruments are marked as inactive
1324    }
1325
1326    use crate::common::testing::load_test_data;
1327
1328    #[rstest]
1329    fn test_parse_perp_instruments_from_real_data() {
1330        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1331
1332        let defs = parse_perp_instruments(&meta, 0).unwrap();
1333
1334        // Should have 3 instruments (BTC, ETH, ATOM)
1335        assert_eq!(defs.len(), 3);
1336
1337        // Validate BTC
1338        let btc = &defs[0];
1339        assert_eq!(btc.symbol, "BTC-USD-PERP");
1340        assert_eq!(btc.base, "BTC");
1341        assert_eq!(btc.quote, "USD");
1342        assert_eq!(btc.settlement.as_ref().unwrap().as_str(), "USDC");
1343        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1344        assert_eq!(btc.size_decimals, 5);
1345        assert_eq!(btc.max_leverage, Some(40));
1346        assert!(btc.active);
1347
1348        // Validate ETH
1349        let eth = &defs[1];
1350        assert_eq!(eth.symbol, "ETH-USD-PERP");
1351        assert_eq!(eth.base, "ETH");
1352        assert_eq!(eth.size_decimals, 4);
1353        assert_eq!(eth.max_leverage, Some(25));
1354
1355        // Validate ATOM
1356        let atom = &defs[2];
1357        assert_eq!(atom.symbol, "ATOM-USD-PERP");
1358        assert_eq!(atom.base, "ATOM");
1359        assert_eq!(atom.size_decimals, 2);
1360        assert_eq!(atom.max_leverage, Some(5));
1361    }
1362
1363    #[rstest]
1364    fn test_parse_recent_trade() {
1365        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1366        let defs = parse_perp_instruments(&meta, 0).unwrap();
1367        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1368
1369        let trade = HyperliquidRecentTrade {
1370            coin: Ustr::from("BTC"),
1371            side: HyperliquidSide::Sell,
1372            px: dec!(50000.0),
1373            sz: dec!(0.5),
1374            time: 1_769_916_000_000,
1375            tid: 987_654_321,
1376        };
1377
1378        let tick = parse_recent_trade(&trade, &instrument).unwrap();
1379
1380        assert_eq!(tick.instrument_id, instrument.id());
1381        assert_eq!(tick.price.as_decimal(), dec!(50000));
1382        assert_eq!(tick.size.as_decimal(), dec!(0.5));
1383        assert_eq!(tick.aggressor_side, AggressorSide::Seller);
1384        assert_eq!(tick.trade_id.to_string(), "987654321");
1385        assert_eq!(
1386            tick.ts_event,
1387            UnixNanos::from(1_769_916_000_000 * 1_000_000)
1388        );
1389        // Historical trades carry ts_init == ts_event so the engine's window
1390        // trimming (by ts_init) keeps bounded requests.
1391        assert_eq!(tick.ts_init, tick.ts_event);
1392    }
1393
1394    #[rstest]
1395    fn test_recent_trade_rejects_invalid_price() {
1396        // Price is now a Decimal field, so an invalid value is rejected at
1397        // deserialization rather than by parse_recent_trade.
1398        let json = r#"{"coin":"BTC","side":"B","px":"not-a-number","sz":"0.5","time":1769916000000,"tid":1}"#;
1399        assert!(serde_json::from_str::<HyperliquidRecentTrade>(json).is_err());
1400    }
1401
1402    #[rstest]
1403    fn test_create_instrument_from_def_perp_sets_min_notional() {
1404        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1405        let defs = parse_perp_instruments(&meta, 0).unwrap();
1406
1407        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1408
1409        match instrument {
1410            InstrumentAny::CryptoPerpetual(perp) => {
1411                let min_notional = perp.min_notional.unwrap();
1412                assert_eq!(min_notional.currency, Currency::USD());
1413                assert_eq!(min_notional.as_decimal(), dec!(10));
1414                assert_eq!(perp.settlement_currency.code.as_str(), "USDC");
1415            }
1416            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1417        }
1418    }
1419
1420    #[rstest]
1421    fn test_parse_perp_instruments_with_non_usdc_collateral() {
1422        let all_metas: Vec<PerpMeta> =
1423            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1424        let spot_meta: SpotMeta = load_test_data("http_spot_meta_non_usdc_collateral.json");
1425
1426        assert_eq!(all_metas[1].collateral_token, Some(360));
1427        assert_eq!(all_metas[2].collateral_token, Some(235));
1428
1429        let settlement_currency =
1430            resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap();
1431        let defs = parse_perp_instruments_with_settlement(
1432            &all_metas[1],
1433            110_000,
1434            settlement_currency.as_str(),
1435        );
1436
1437        assert_eq!(settlement_currency.as_str(), "USDH");
1438        assert_eq!(defs.len(), 1);
1439        assert_eq!(defs[0].symbol.as_str(), "km:US500-USD-PERP");
1440        assert_eq!(defs[0].quote.as_str(), "USD");
1441        assert_eq!(defs[0].settlement.as_ref().unwrap().as_str(), "USDH");
1442
1443        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1444        match instrument {
1445            InstrumentAny::CryptoPerpetual(perp) => {
1446                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1447                assert_eq!(perp.settlement_currency.code.as_str(), "USDH");
1448                assert_eq!(perp.settlement_currency.name.as_str(), "Hyperliquid USD");
1449            }
1450            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1451        }
1452
1453        let settlement_currency =
1454            resolve_perp_settlement_currency(&all_metas[2], Some(&spot_meta)).unwrap();
1455        let defs = parse_perp_instruments_with_settlement(
1456            &all_metas[2],
1457            140_000,
1458            settlement_currency.as_str(),
1459        );
1460
1461        assert_eq!(settlement_currency.as_str(), "USDE");
1462        assert_eq!(defs.len(), 1);
1463        assert_eq!(defs[0].symbol.as_str(), "hyna:BTC-USD-PERP");
1464        assert_eq!(defs[0].quote.as_str(), "USD");
1465        assert_eq!(defs[0].settlement.as_ref().unwrap().as_str(), "USDE");
1466
1467        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1468        match instrument {
1469            InstrumentAny::CryptoPerpetual(perp) => {
1470                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1471                assert_eq!(perp.settlement_currency.code.as_str(), "USDE");
1472            }
1473            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1474        }
1475    }
1476
1477    #[rstest]
1478    fn test_create_instrument_from_def_perp_defaults_missing_settlement_to_usdc() {
1479        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1480        let mut defs = parse_perp_instruments(&meta, 0).unwrap();
1481        defs[0].settlement = None;
1482
1483        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1484
1485        match instrument {
1486            InstrumentAny::CryptoPerpetual(perp) => {
1487                assert_eq!(perp.quote_currency.code.as_str(), "USD");
1488                assert_eq!(perp.settlement_currency.code.as_str(), "USDC");
1489            }
1490            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1491        }
1492    }
1493
1494    #[rstest]
1495    fn test_resolve_perp_settlement_currency_defaults_to_usdc() {
1496        let legacy_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1497        let all_metas: Vec<PerpMeta> =
1498            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1499
1500        let legacy_settlement = resolve_perp_settlement_currency(&legacy_meta, None).unwrap();
1501        let token_zero_settlement = resolve_perp_settlement_currency(&all_metas[0], None).unwrap();
1502
1503        assert_eq!(legacy_settlement.as_str(), "USDC");
1504        assert_eq!(token_zero_settlement.as_str(), "USDC");
1505    }
1506
1507    #[rstest]
1508    fn test_resolve_perp_settlement_currency_requires_spot_meta_for_non_usdc() {
1509        let all_metas: Vec<PerpMeta> =
1510            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1511
1512        let err = resolve_perp_settlement_currency(&all_metas[1], None).unwrap_err();
1513
1514        assert_eq!(
1515            err,
1516            "Spot metadata required to resolve perp collateral token 360",
1517        );
1518    }
1519
1520    #[rstest]
1521    fn test_resolve_perp_settlement_currency_errors_on_missing_token_index() {
1522        let all_metas: Vec<PerpMeta> =
1523            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1524        let spot_meta = SpotMeta {
1525            tokens: Vec::new(),
1526            universe: Vec::new(),
1527        };
1528
1529        let err = resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap_err();
1530
1531        assert_eq!(
1532            err,
1533            "Perp collateral token index 360 not found in spot metadata",
1534        );
1535    }
1536
1537    #[rstest]
1538    fn test_deserialize_l2_book_from_real_data() {
1539        let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1540
1541        // Validate basic structure
1542        assert_eq!(book.coin, "BTC");
1543        assert_eq!(book.levels.len(), 2); // [bids, asks]
1544        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
1545        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
1546
1547        // Verify bids and asks are properly ordered
1548        let bids = &book.levels[0];
1549        let asks = &book.levels[1];
1550
1551        // Bids should be descending (highest first)
1552        for i in 1..bids.len() {
1553            let prev_price = bids[i - 1].px;
1554            let curr_price = bids[i].px;
1555            assert!(prev_price >= curr_price, "Bids should be descending");
1556        }
1557
1558        // Asks should be ascending (lowest first)
1559        for i in 1..asks.len() {
1560            let prev_price = asks[i - 1].px;
1561            let curr_price = asks[i].px;
1562            assert!(prev_price <= curr_price, "Asks should be ascending");
1563        }
1564    }
1565
1566    #[rstest]
1567    fn test_parse_spot_instruments() {
1568        let tokens = vec![
1569            SpotToken {
1570                name: "USDC".to_string(),
1571                sz_decimals: 6,
1572                wei_decimals: 6,
1573                index: 0,
1574                token_id: "0x1".to_string(),
1575                is_canonical: true,
1576                evm_contract: None,
1577                full_name: None,
1578                deployer_trading_fee_share: None,
1579            },
1580            SpotToken {
1581                name: "PURR".to_string(),
1582                sz_decimals: 0,
1583                wei_decimals: 5,
1584                index: 1,
1585                token_id: "0x2".to_string(),
1586                is_canonical: true,
1587                evm_contract: None,
1588                full_name: None,
1589                deployer_trading_fee_share: None,
1590            },
1591        ];
1592
1593        let pairs = vec![
1594            SpotPair {
1595                name: "PURR/USDC".to_string(),
1596                tokens: [1, 0], // PURR base, USDC quote
1597                index: 0,
1598                is_canonical: true,
1599            },
1600            SpotPair {
1601                name: "ALIAS".to_string(),
1602                tokens: [1, 0],
1603                index: 1,
1604                is_canonical: false, // Should be included but marked as inactive
1605            },
1606        ];
1607
1608        let meta = SpotMeta {
1609            tokens,
1610            universe: pairs,
1611        };
1612
1613        let defs = parse_spot_instruments(&meta).unwrap();
1614
1615        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
1616        assert_eq!(defs.len(), 2);
1617
1618        let purr_usdc = &defs[0];
1619        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1620        assert_eq!(purr_usdc.base, "PURR");
1621        assert_eq!(purr_usdc.quote, "USDC");
1622        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1623        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
1624        assert_eq!(purr_usdc.size_decimals, 0);
1625        assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1626        assert_eq!(purr_usdc.lot_size, dec!(1));
1627        assert_eq!(purr_usdc.max_leverage, None);
1628        assert!(!purr_usdc.only_isolated);
1629        assert!(purr_usdc.active);
1630
1631        let alias = &defs[1];
1632        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1633        assert_eq!(alias.base, "PURR");
1634        assert!(!alias.active); // Non-canonical pairs are marked as inactive
1635
1636        let instrument = create_instrument_from_def(purr_usdc, UnixNanos::default()).unwrap();
1637
1638        match instrument {
1639            InstrumentAny::CurrencyPair(pair) => {
1640                let min_notional = pair.min_notional.unwrap();
1641                assert_eq!(min_notional.currency, Currency::USDC());
1642                assert_eq!(min_notional.as_decimal(), dec!(10));
1643            }
1644            other => panic!("Expected CurrencyPair, was {other:?}"),
1645        }
1646    }
1647
1648    #[rstest]
1649    fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1650        // Non-canonical pair uses a lower pair index than the canonical one;
1651        // the sort must still put canonical first so the base-token alias in
1652        // cache_instrument resolves to the canonical instrument.
1653        let tokens = vec![
1654            SpotToken {
1655                name: "USDC".to_string(),
1656                sz_decimals: 6,
1657                wei_decimals: 6,
1658                index: 0,
1659                token_id: "0x1".to_string(),
1660                is_canonical: true,
1661                evm_contract: None,
1662                full_name: None,
1663                deployer_trading_fee_share: None,
1664            },
1665            SpotToken {
1666                name: "HYPE".to_string(),
1667                sz_decimals: 2,
1668                wei_decimals: 8,
1669                index: 150,
1670                token_id: "0x2".to_string(),
1671                is_canonical: true,
1672                evm_contract: None,
1673                full_name: None,
1674                deployer_trading_fee_share: None,
1675            },
1676        ];
1677
1678        let pairs = vec![
1679            SpotPair {
1680                name: "HYPE_OLD".to_string(),
1681                tokens: [150, 0],
1682                index: 3,
1683                is_canonical: false,
1684            },
1685            SpotPair {
1686                name: "HYPE".to_string(),
1687                tokens: [150, 0],
1688                index: 107,
1689                is_canonical: true,
1690            },
1691        ];
1692
1693        let defs = parse_spot_instruments(&SpotMeta {
1694            tokens,
1695            universe: pairs,
1696        })
1697        .unwrap();
1698
1699        assert_eq!(defs.len(), 2);
1700        assert!(defs[0].active, "canonical must sort first");
1701        assert_eq!(defs[0].asset_index, 10000 + 107);
1702        assert!(!defs[1].active);
1703        assert_eq!(defs[1].asset_index, 10000 + 3);
1704    }
1705
1706    #[rstest]
1707    fn test_price_decimals_clamping() {
1708        let meta = PerpMeta {
1709            universe: vec![PerpAsset {
1710                name: "HIGHPREC".to_string(),
1711                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
1712                max_leverage: Some(1),
1713                ..Default::default()
1714            }],
1715            margin_tables: vec![],
1716            collateral_token: None,
1717        };
1718
1719        let defs = parse_perp_instruments(&meta, 0).unwrap();
1720        assert_eq!(defs[0].price_decimals, 0);
1721        assert_eq!(defs[0].tick_size, dec!(1));
1722    }
1723
1724    #[rstest]
1725    fn test_parse_perp_instruments_hip3_dex() {
1726        // HIP-3 dex at index 1: asset_index_base = 100_000 + 1 * 10_000 = 110_000
1727        let meta = PerpMeta {
1728            universe: vec![
1729                PerpAsset {
1730                    name: "xyz:TSLA".to_string(),
1731                    sz_decimals: 3,
1732                    max_leverage: Some(10),
1733                    only_isolated: None,
1734                    is_delisted: None,
1735                    growth_mode: Some("enabled".to_string()),
1736                    margin_mode: Some("strictIsolated".to_string()),
1737                },
1738                PerpAsset {
1739                    name: "xyz:NVDA".to_string(),
1740                    sz_decimals: 3,
1741                    max_leverage: Some(20),
1742                    only_isolated: None,
1743                    is_delisted: None,
1744                    growth_mode: None,
1745                    margin_mode: None,
1746                },
1747            ],
1748            margin_tables: vec![],
1749            collateral_token: None,
1750        };
1751
1752        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1753        assert_eq!(defs.len(), 2);
1754
1755        // HIP-3 asset: colon in symbol, offset asset index
1756        assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1757        assert!(defs[0].symbol.contains(':'));
1758        assert_eq!(defs[0].base, "xyz:TSLA");
1759        assert_eq!(defs[0].asset_index, 110_000);
1760        assert!(defs[0].active);
1761
1762        assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1763        assert_eq!(defs[1].asset_index, 110_001);
1764    }
1765
1766    #[rstest]
1767    #[case("BTC", "BTC")]
1768    #[case("kPEPE", "kPEPE")]
1769    #[case("xyz:TSLA", "xyz:TSLA")]
1770    #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1771    #[case("ABC?", "ABCx")]
1772    #[case("a*b?c", "axbxc")]
1773    fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1774        assert_eq!(sanitize_symbol(input), expected);
1775    }
1776
1777    #[rstest]
1778    fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1779        // Hypothetical spot token whose venue name contains `?`. Sanitization
1780        // must apply to the constructed `symbol` while leaving `raw_symbol`
1781        // and `base` carrying the venue-official name for wire I/O.
1782        let tokens = vec![
1783            SpotToken {
1784                name: "USDC".to_string(),
1785                sz_decimals: 6,
1786                wei_decimals: 6,
1787                index: 0,
1788                token_id: "0x1".to_string(),
1789                is_canonical: true,
1790                evm_contract: None,
1791                full_name: None,
1792                deployer_trading_fee_share: None,
1793            },
1794            SpotToken {
1795                name: "ABC?".to_string(),
1796                sz_decimals: 4,
1797                wei_decimals: 4,
1798                index: 1,
1799                token_id: "0x2".to_string(),
1800                is_canonical: true,
1801                evm_contract: None,
1802                full_name: None,
1803                deployer_trading_fee_share: None,
1804            },
1805        ];
1806
1807        let pairs = vec![SpotPair {
1808            name: "ABC?/USDC".to_string(),
1809            tokens: [1, 0],
1810            index: 50,
1811            is_canonical: true,
1812        }];
1813
1814        let meta = SpotMeta {
1815            tokens,
1816            universe: pairs,
1817        };
1818
1819        let defs = parse_spot_instruments(&meta).unwrap();
1820        assert_eq!(defs.len(), 1);
1821        assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1822        assert_eq!(defs[0].base, "ABC?");
1823        assert_eq!(defs[0].quote, "USDC");
1824    }
1825
1826    #[rstest]
1827    fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1828        let meta = PerpMeta {
1829            universe: vec![PerpAsset {
1830                name: "dex:STREAMABCD****".to_string(),
1831                sz_decimals: 3,
1832                max_leverage: Some(10),
1833                only_isolated: None,
1834                is_delisted: None,
1835                growth_mode: None,
1836                margin_mode: None,
1837            }],
1838            margin_tables: vec![],
1839            collateral_token: None,
1840        };
1841
1842        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1843        assert_eq!(defs.len(), 1);
1844        assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1845        assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1846        assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1847    }
1848
1849    #[rstest]
1850    fn test_parse_outcome_instruments_emits_both_sides() {
1851        let meta = OutcomeMeta {
1852            outcomes: vec![OutcomeMarket {
1853                outcome: 1,
1854                name: "BTC daily".to_string(),
1855                description: "BTC settles above strike at 06:00 UTC".to_string(),
1856                side_specs: vec![
1857                    OutcomeSideSpec {
1858                        name: "Yes".to_string(),
1859                    },
1860                    OutcomeSideSpec {
1861                        name: "No".to_string(),
1862                    },
1863                ],
1864            }],
1865            questions: vec![],
1866        };
1867
1868        let defs = parse_outcome_instruments(&meta).unwrap();
1869        assert_eq!(defs.len(), 2);
1870
1871        let yes = &defs[0];
1872        assert_eq!(yes.symbol.as_str(), "1-YES-OUTCOME");
1873        assert_eq!(yes.raw_symbol.as_str(), "#10");
1874        assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
1875        assert_eq!(yes.asset_index, 100_000_010);
1876        assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
1877        assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
1878        assert_eq!(yes.tick_size, dec!(0.0001));
1879        assert_eq!(yes.lot_size, dec!(0.01));
1880        assert_eq!(yes.quote.as_str(), "USDH");
1881        assert!(yes.active);
1882
1883        let yes_meta = yes.outcome.as_ref().unwrap();
1884        assert_eq!(yes_meta.outcome_index, 1);
1885        assert_eq!(yes_meta.outcome_side, 0);
1886        assert_eq!(yes_meta.market_name.as_str(), "BTC daily");
1887        assert_eq!(yes_meta.side_name.unwrap().as_str(), "Yes");
1888        assert_eq!(
1889            yes_meta.description.unwrap().as_str(),
1890            "BTC settles above strike at 06:00 UTC"
1891        );
1892
1893        let no = &defs[1];
1894        assert_eq!(no.symbol.as_str(), "1-NO-OUTCOME");
1895        assert_eq!(no.raw_symbol.as_str(), "#11");
1896        assert_eq!(no.asset_index, 100_000_011);
1897        let no_meta = no.outcome.as_ref().unwrap();
1898        assert_eq!(no_meta.outcome_side, 1);
1899        assert_eq!(no_meta.side_name.unwrap().as_str(), "No");
1900    }
1901
1902    #[rstest]
1903    fn test_parse_outcome_instruments_handles_missing_side_specs() {
1904        let meta = OutcomeMeta {
1905            outcomes: vec![OutcomeMarket {
1906                outcome: 5,
1907                name: "Recurring".to_string(),
1908                description: String::new(),
1909                side_specs: vec![],
1910            }],
1911            questions: vec![],
1912        };
1913
1914        let defs = parse_outcome_instruments(&meta).unwrap();
1915        assert_eq!(defs.len(), 2);
1916
1917        // Even when the venue omits `sideSpecs`, the parser falls back to the
1918        // canonical HIP-4 labels ("Yes" / "No") so downstream `BinaryOption`
1919        // instruments always carry a meaningful side label.
1920        assert_eq!(
1921            defs[0]
1922                .outcome
1923                .as_ref()
1924                .unwrap()
1925                .side_name
1926                .unwrap()
1927                .as_str(),
1928            "Yes"
1929        );
1930        assert_eq!(
1931            defs[1]
1932                .outcome
1933                .as_ref()
1934                .unwrap()
1935                .side_name
1936                .unwrap()
1937                .as_str(),
1938            "No"
1939        );
1940
1941        for def in &defs {
1942            assert!(def.outcome.as_ref().unwrap().description.is_none());
1943        }
1944
1945        assert_eq!(defs[0].asset_index, 100_000_050);
1946        assert_eq!(defs[1].asset_index, 100_000_051);
1947    }
1948
1949    #[rstest]
1950    fn test_get_usdh_currency_registers_with_explicit_precision() {
1951        let currency = get_usdh_currency();
1952        assert_eq!(currency.code.as_str(), "USDH");
1953        assert_eq!(currency.precision, 8);
1954        assert_eq!(currency.currency_type, CurrencyType::Crypto);
1955
1956        // Repeated calls return the same registered currency
1957        let again = get_usdh_currency();
1958        assert_eq!(again, currency);
1959        assert!(Currency::try_from_str("USDH").is_some());
1960    }
1961
1962    #[rstest]
1963    fn test_create_instrument_from_def_outcome_emits_binary_option() {
1964        let meta = OutcomeMeta {
1965            outcomes: vec![OutcomeMarket {
1966                outcome: 2,
1967                name: "Recurring BTC".to_string(),
1968                description: "Daily settlement".to_string(),
1969                side_specs: vec![
1970                    OutcomeSideSpec {
1971                        name: "Yes".to_string(),
1972                    },
1973                    OutcomeSideSpec {
1974                        name: "No".to_string(),
1975                    },
1976                ],
1977            }],
1978            questions: vec![],
1979        };
1980
1981        let defs = parse_outcome_instruments(&meta).unwrap();
1982        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1983
1984        match instrument {
1985            InstrumentAny::BinaryOption(bo) => {
1986                assert_eq!(bo.id.symbol.as_str(), "2-YES-OUTCOME");
1987                assert_eq!(bo.raw_symbol.as_str(), "#20");
1988                assert_eq!(bo.asset_class, AssetClass::Alternative);
1989                assert_eq!(bo.currency.code.as_str(), "USDH");
1990                assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
1991                assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
1992                assert_eq!(bo.outcome.unwrap().as_str(), "Yes");
1993                assert_eq!(bo.description.unwrap().as_str(), "Daily settlement");
1994
1995                let info = bo.info.expect("info should be populated for outcomes");
1996                assert_eq!(info.get_u64("outcome_index"), Some(2));
1997                assert_eq!(info.get_u64("outcome_side"), Some(0));
1998                assert_eq!(info.get_u64("encoding"), Some(20));
1999                assert_eq!(info.get_u64("asset_id"), Some(100_000_020));
2000                assert_eq!(info.get_str("side_name"), Some("Yes"));
2001                assert_eq!(info.get_str("market_name"), Some("Recurring BTC"));
2002            }
2003            other => panic!("Expected BinaryOption, was {other:?}"),
2004        }
2005    }
2006
2007    #[rstest]
2008    fn test_create_instrument_from_def_outcome_info_carries_parsed_description() {
2009        let meta = OutcomeMeta {
2010            outcomes: vec![OutcomeMarket {
2011                outcome: 5,
2012                name: "Recurring BTC".to_string(),
2013                description:
2014                    "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2015                        .to_string(),
2016                side_specs: vec![
2017                    OutcomeSideSpec {
2018                        name: "Yes".to_string(),
2019                    },
2020                    OutcomeSideSpec {
2021                        name: "No".to_string(),
2022                    },
2023                ],
2024            }],
2025            questions: vec![],
2026        };
2027
2028        let defs = parse_outcome_instruments(&meta).unwrap();
2029        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2030
2031        match yes {
2032            InstrumentAny::BinaryOption(bo) => {
2033                let info = bo.info.expect("info should be populated for outcomes");
2034                assert_eq!(info.get_str("class"), Some("priceBinary"));
2035                assert_eq!(info.get_str("underlying"), Some("BTC"));
2036                assert_eq!(info.get_str("expiry"), Some("20260508-0600"));
2037                assert_eq!(info.get_str("target_price"), Some("81041"));
2038                assert_eq!(info.get_str("period"), Some("1d"));
2039                assert!(info.get("question").is_none());
2040            }
2041            other => panic!("Expected BinaryOption, was {other:?}"),
2042        }
2043    }
2044
2045    #[rstest]
2046    fn test_create_instrument_from_def_outcome_info_merges_parent_question() {
2047        let meta = OutcomeMeta {
2048            outcomes: vec![
2049                OutcomeMarket {
2050                    outcome: 6,
2051                    name: "Recurring Fallback".to_string(),
2052                    description: "other".to_string(),
2053                    side_specs: vec![],
2054                },
2055                OutcomeMarket {
2056                    outcome: 7,
2057                    name: "Recurring Named Outcome".to_string(),
2058                    description: "index:0".to_string(),
2059                    side_specs: vec![],
2060                },
2061            ],
2062            questions: vec![OutcomeQuestion {
2063                question: 0,
2064                name: "Recurring".to_string(),
2065                description:
2066                    "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2067                        .to_string(),
2068                fallback_outcome: Some(6),
2069                named_outcomes: vec![7, 8, 9],
2070                settled_named_outcomes: vec![],
2071            }],
2072        };
2073
2074        let defs = parse_outcome_instruments(&meta).unwrap();
2075
2076        // Named outcome 7, Yes side (defs[2]).
2077        let named = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
2078        match named {
2079            InstrumentAny::BinaryOption(bo) => {
2080                assert_eq!(bo.id.symbol.as_str(), "7-YES-OUTCOME");
2081                let info = bo.info.expect("info should be populated for outcomes");
2082                assert_eq!(info.get_u64("named_index"), Some(0));
2083                assert_eq!(info.get_u64("question"), Some(0));
2084                assert_eq!(info.get_str("question_name"), Some("Recurring"));
2085                assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2086                assert_eq!(info.get_str("question_underlying"), Some("BTC"));
2087                assert_eq!(
2088                    info.get_str("question_price_thresholds"),
2089                    Some("79303,82540"),
2090                );
2091                assert_eq!(info.get_str("question_expiry"), Some("20260508-0600"));
2092            }
2093            other => panic!("Expected BinaryOption, was {other:?}"),
2094        }
2095
2096        // Fallback outcome 6, Yes side (defs[0]).
2097        let fallback = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2098        match fallback {
2099            InstrumentAny::BinaryOption(bo) => {
2100                assert_eq!(bo.id.symbol.as_str(), "6-YES-OUTCOME");
2101                let info = bo.info.expect("info should be populated for outcomes");
2102                assert_eq!(info.get_bool("is_fallback"), Some(true));
2103                assert_eq!(info.get_u64("question"), Some(0));
2104                assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2105            }
2106            other => panic!("Expected BinaryOption, was {other:?}"),
2107        }
2108    }
2109
2110    #[rstest]
2111    fn test_parse_fill_report_outcome_round_trip() {
2112        let meta = OutcomeMeta {
2113            outcomes: vec![OutcomeMarket {
2114                outcome: 42,
2115                name: "BTC daily".to_string(),
2116                description: "BTC settles above strike at 06:00 UTC".to_string(),
2117                side_specs: vec![
2118                    OutcomeSideSpec {
2119                        name: "Yes".to_string(),
2120                    },
2121                    OutcomeSideSpec {
2122                        name: "No".to_string(),
2123                    },
2124                ],
2125            }],
2126            questions: vec![],
2127        };
2128
2129        let defs = parse_outcome_instruments(&meta).unwrap();
2130        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2131        assert_eq!(yes.id().symbol.as_str(), "42-YES-OUTCOME");
2132
2133        let fill = HyperliquidFill {
2134            coin: Ustr::from("#420"),
2135            px: dec!(0.5500),
2136            sz: dec!(1000.00),
2137            side: HyperliquidSide::Buy,
2138            time: 1_704_470_400_000,
2139            start_position: dec!(0.00),
2140            dir: HyperliquidFillDirection::OpenLong,
2141            closed_pnl: dec!(0.0),
2142            hash: "0xfeed".to_string(),
2143            oid: 99_001,
2144            crossed: true,
2145            fee: dec!(0.0),
2146            fee_token: Ustr::from("+420"),
2147        };
2148
2149        let account_id = AccountId::from("HYPERLIQUID-001");
2150        let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
2151
2152        // Zero-fee outcome fills resolve commission to the instrument's quote
2153        // currency (USDH) rather than the side token, so downstream OrderFilled
2154        // events and persistence carry a registered currency.
2155        assert_eq!(report.commission.currency.code.as_str(), "USDH");
2156        assert!(report.commission.as_decimal().is_zero());
2157        assert_eq!(report.order_side, OrderSide::Buy);
2158        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2159        assert_eq!(report.last_qty.as_decimal(), dec!(1000));
2160        assert_eq!(report.last_px.as_decimal(), dec!(0.55));
2161    }
2162
2163    #[rstest]
2164    fn test_deserialize_user_fills_with_dust_conversion() {
2165        // #4325 regression: a userFills batch must decode whole, not fail on one
2166        // unmodeled direction. Fixture is real mainnet wire data.
2167        let fills: Vec<HyperliquidFill> = load_test_data("http_user_fills_dust_conversion.json");
2168
2169        let dirs: Vec<HyperliquidFillDirection> = fills.iter().map(|f| f.dir).collect();
2170
2171        assert_eq!(
2172            dirs,
2173            vec![
2174                HyperliquidFillDirection::OpenLong,
2175                HyperliquidFillDirection::CloseShort,
2176                HyperliquidFillDirection::Buy,
2177                HyperliquidFillDirection::SpotDustConversion,
2178                HyperliquidFillDirection::NetChildVaults,
2179            ],
2180        );
2181    }
2182
2183    #[rstest]
2184    fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
2185        let meta = OutcomeMeta {
2186            outcomes: vec![OutcomeMarket {
2187                outcome: 88,
2188                name: "Edge".to_string(),
2189                description: String::new(),
2190                side_specs: vec![],
2191            }],
2192            questions: vec![],
2193        };
2194        let defs = parse_outcome_instruments(&meta).unwrap();
2195        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2196
2197        // Simulate another adapter path (e.g. spot balance parsing) having already
2198        // registered the side token in the global currency registry.
2199        let _ = get_currency("+880");
2200        assert!(Currency::try_from_str("+880").is_some());
2201
2202        let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
2203            .expect("zero-fee outcome side token must resolve to quote currency");
2204        assert_eq!(currency.code.as_str(), "USDH");
2205
2206        let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
2207        let err_msg = err.to_string();
2208        assert!(err_msg.contains("Outcome side token '+880'"));
2209        assert!(err_msg.contains("non-zero fee"));
2210    }
2211
2212    #[rstest]
2213    #[case("+50", true)]
2214    #[case("+0", true)]
2215    #[case("+880", true)]
2216    #[case("", false)]
2217    #[case("+", false)]
2218    #[case("+abc", false)]
2219    #[case("+50a", false)]
2220    #[case("#50", false)]
2221    #[case("USDC", false)]
2222    #[case("-50", false)]
2223    fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
2224        assert_eq!(is_outcome_side_token(input), expected);
2225    }
2226
2227    #[rstest]
2228    fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
2229        let meta = OutcomeMeta {
2230            outcomes: vec![OutcomeMarket {
2231                outcome: 77,
2232                name: "Edge".to_string(),
2233                description: String::new(),
2234                side_specs: vec![],
2235            }],
2236            questions: vec![],
2237        };
2238
2239        let defs = parse_outcome_instruments(&meta).unwrap();
2240        let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
2241
2242        // Use a token that the venue would not normally emit; the helper must still
2243        // return the instrument's quote currency on a zero-fee fill.
2244        let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
2245            .expect("zero-fee fallback should succeed");
2246        assert_eq!(currency.code.as_str(), "USDH");
2247
2248        let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
2249        assert!(err.to_string().contains("non-zero fee"));
2250    }
2251
2252    #[rstest]
2253    fn test_parse_outcome_expiry_ns_round_trip() {
2254        // 2026-05-08 06:00:00 UTC == 1778652000 seconds since epoch
2255        let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
2256        assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
2257    }
2258
2259    #[rstest]
2260    #[case("")]
2261    #[case("20260508")]
2262    #[case("20260508-")]
2263    #[case("20260508-0600 ")]
2264    #[case("2026-05-08-06-00")]
2265    #[case("20261308-0600")]
2266    fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
2267        assert!(parse_outcome_expiry_ns(input).is_none());
2268    }
2269
2270    #[rstest]
2271    fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
2272        let meta = OutcomeMeta {
2273            outcomes: vec![OutcomeMarket {
2274                outcome: 5,
2275                name: "Recurring".to_string(),
2276                description:
2277                    "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2278                        .to_string(),
2279                side_specs: vec![
2280                    OutcomeSideSpec {
2281                        name: "Yes".to_string(),
2282                    },
2283                    OutcomeSideSpec {
2284                        name: "No".to_string(),
2285                    },
2286                ],
2287            }],
2288            questions: vec![],
2289        };
2290
2291        let defs = parse_outcome_instruments(&meta).unwrap();
2292        let yes_meta = defs[0].outcome.as_ref().unwrap();
2293        assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
2294    }
2295
2296    #[rstest]
2297    fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
2298        // outcome=7 has `index:0` description and is referenced by question 0's
2299        // `named_outcomes`. outcome=6 has `other` description and is the
2300        // `fallback_outcome`. Both should pick up the question's expiry.
2301        let meta = OutcomeMeta {
2302            outcomes: vec![
2303                OutcomeMarket {
2304                    outcome: 6,
2305                    name: "Recurring Fallback".to_string(),
2306                    description: "other".to_string(),
2307                    side_specs: vec![],
2308                },
2309                OutcomeMarket {
2310                    outcome: 7,
2311                    name: "Recurring Named Outcome".to_string(),
2312                    description: "index:0".to_string(),
2313                    side_specs: vec![],
2314                },
2315            ],
2316            questions: vec![OutcomeQuestion {
2317                question: 0,
2318                name: "Recurring".to_string(),
2319                description:
2320                    "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2321                        .to_string(),
2322                fallback_outcome: Some(6),
2323                named_outcomes: vec![7, 8, 9],
2324                settled_named_outcomes: vec![],
2325            }],
2326        };
2327
2328        let defs = parse_outcome_instruments(&meta).unwrap();
2329        let expected_ns: u64 = 1_778_220_000_000_000_000;
2330
2331        for def in &defs {
2332            let outcome = def.outcome.as_ref().unwrap();
2333            assert_eq!(
2334                outcome.expiration_ns.as_u64(),
2335                expected_ns,
2336                "outcome {} side {} should inherit expiry",
2337                outcome.outcome_index,
2338                outcome.outcome_side,
2339            );
2340        }
2341    }
2342
2343    #[rstest]
2344    fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
2345        let meta = OutcomeMeta {
2346            outcomes: vec![],
2347            questions: vec![],
2348        };
2349        assert!(derive_outcome_settlements(&meta).is_empty());
2350    }
2351
2352    #[rstest]
2353    fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
2354        let meta = OutcomeMeta {
2355            outcomes: vec![],
2356            questions: vec![OutcomeQuestion {
2357                question: 0,
2358                name: "Recurring".to_string(),
2359                description: "class:priceBucket|expiry:20260508-0600".to_string(),
2360                fallback_outcome: Some(6),
2361                named_outcomes: vec![7, 8, 9],
2362                settled_named_outcomes: vec![],
2363            }],
2364        };
2365
2366        assert!(derive_outcome_settlements(&meta).is_empty());
2367    }
2368
2369    #[rstest]
2370    fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
2371        let meta = OutcomeMeta {
2372            outcomes: vec![],
2373            questions: vec![OutcomeQuestion {
2374                question: 0,
2375                name: "Recurring".to_string(),
2376                description: "class:priceBucket|expiry:20260508-0600".to_string(),
2377                fallback_outcome: Some(6),
2378                named_outcomes: vec![7, 8, 9],
2379                settled_named_outcomes: vec![8],
2380            }],
2381        };
2382
2383        let settlements = derive_outcome_settlements(&meta);
2384        let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
2385            .into_iter()
2386            .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
2387            .collect();
2388
2389        // Winning named outcome 8: Yes -> 1, No -> 0
2390        assert_eq!(lookup[&(8, 0)], 1);
2391        assert_eq!(lookup[&(8, 1)], 0);
2392
2393        // Losing named outcomes 7, 9 and fallback 6: Yes -> 0, No -> 1
2394        for losing in [7, 9, 6] {
2395            assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
2396            assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
2397        }
2398
2399        assert_eq!(lookup.len(), 8);
2400    }
2401
2402    #[rstest]
2403    fn test_parse_outcome_meta_question_settlement_round_trip() {
2404        let json = r#"{
2405            "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
2406            "questions": [{
2407                "question": 0,
2408                "name": "Recurring",
2409                "description": "class:priceBucket|expiry:20260508-0600",
2410                "fallbackOutcome": 6,
2411                "namedOutcomes": [7, 8, 9],
2412                "settledNamedOutcomes": [8]
2413            }]
2414        }"#;
2415
2416        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
2417        assert_eq!(meta.questions.len(), 1);
2418        let q = &meta.questions[0];
2419        assert_eq!(q.fallback_outcome, Some(6));
2420        assert_eq!(q.named_outcomes, vec![7, 8, 9]);
2421        assert_eq!(q.settled_named_outcomes, vec![8]);
2422
2423        assert!(meta.parent_question(7).is_some());
2424        assert!(meta.parent_question(6).is_some());
2425        assert!(meta.parent_question(99).is_none());
2426    }
2427}