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