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