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