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::{ASSET_INDEX_INFO_KEY, 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/// Returns `info` with the venue asset index added under [`ASSET_INDEX_INFO_KEY`].
763///
764/// Hyperliquid signs orders with the numeric asset index rather than the symbol,
765/// and `InstrumentAny` is the only instrument shape published on the message bus.
766/// Carrying the index here lets the execution client register a market discovered
767/// after its own bootstrap without refetching venue metadata.
768fn info_with_asset_index(info: Option<Params>, asset_index: u32) -> Params {
769    let mut info = info.unwrap_or_default();
770    info.insert(ASSET_INDEX_INFO_KEY.to_string(), json!(asset_index));
771    info
772}
773
774/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
775///
776/// Returns `None` if the conversion fails (e.g., unsupported market type).
777///
778/// # Panics
779///
780/// Panics if the constructed instrument fails validation.
781#[must_use]
782pub fn create_instrument_from_def(
783    def: &HyperliquidInstrumentDef,
784    ts_init: UnixNanos,
785) -> Option<InstrumentAny> {
786    let symbol = Symbol::new(def.symbol);
787    let venue = *HYPERLIQUID_VENUE;
788    let instrument_id = InstrumentId::new(symbol, venue);
789
790    // Use the raw_symbol from the definition which is format-specific:
791    // - Perps: base currency (e.g., "BTC")
792    // - Spot PURR: slash format (e.g., "PURR/USDC")
793    // - Spot others: @{index} format (e.g., "@107")
794    let raw_symbol = Symbol::new(def.raw_symbol);
795    let price_increment = Price::from(def.tick_size.to_string());
796    let size_increment = Quantity::from(def.lot_size.to_string());
797
798    match def.market_type {
799        HyperliquidMarketType::Spot => {
800            let base_currency = get_currency(&def.base);
801            let quote_currency = get_currency(&def.quote);
802            let min_notional = Some(min_order_notional(quote_currency)?);
803            let info = serde_json::from_str::<Params>(&def.raw_data).ok();
804            let info = info_with_asset_index(info, def.asset_index);
805
806            Some(InstrumentAny::CurrencyPair(
807                CurrencyPair::builder()
808                    .instrument_id(instrument_id)
809                    .raw_symbol(raw_symbol)
810                    .base_currency(base_currency)
811                    .quote_currency(quote_currency)
812                    .price_precision(def.price_decimals as u8)
813                    .size_precision(def.size_decimals as u8)
814                    .price_increment(price_increment)
815                    .size_increment(size_increment)
816                    .maybe_min_notional(min_notional)
817                    .info(info)
818                    // Identical to ts_init for now
819                    .ts_event(ts_init)
820                    .ts_init(ts_init)
821                    .build()
822                    .unwrap(),
823            ))
824        }
825        HyperliquidMarketType::Perp => {
826            let base_currency = get_currency(&def.base);
827            let quote_currency = get_currency(&def.quote);
828            let settlement_code = def
829                .settlement
830                .as_ref()
831                .map_or(DEFAULT_PERP_SETTLEMENT_CURRENCY, Ustr::as_str);
832            let settlement_currency = if settlement_code == "USDH" {
833                get_usdh_currency()
834            } else {
835                get_currency(settlement_code)
836            };
837            let min_notional = Some(min_order_notional(quote_currency)?);
838
839            Some(InstrumentAny::CryptoPerpetual(
840                CryptoPerpetual::builder()
841                    .instrument_id(instrument_id)
842                    .raw_symbol(raw_symbol)
843                    .base_currency(base_currency)
844                    .quote_currency(quote_currency)
845                    .settlement_currency(settlement_currency)
846                    .is_inverse(false)
847                    .price_precision(def.price_decimals as u8)
848                    .size_precision(def.size_decimals as u8)
849                    .price_increment(price_increment)
850                    .size_increment(size_increment)
851                    .maybe_min_notional(min_notional)
852                    .info(info_with_asset_index(None, def.asset_index))
853                    // Identical to ts_init for now
854                    .ts_event(ts_init)
855                    .ts_init(ts_init)
856                    .build()
857                    .unwrap(),
858            ))
859        }
860        HyperliquidMarketType::Outcome => {
861            let outcome = def.outcome.as_ref()?;
862            let currency = get_usdh_currency();
863
864            Some(InstrumentAny::BinaryOption(
865                BinaryOption::builder()
866                    .instrument_id(instrument_id)
867                    .raw_symbol(raw_symbol)
868                    .asset_class(AssetClass::Alternative)
869                    .currency(currency)
870                    .activation_ns(outcome.activation_ns)
871                    .expiration_ns(outcome.expiration_ns)
872                    .price_precision(def.price_decimals as u8)
873                    .size_precision(def.size_decimals as u8)
874                    .price_increment(price_increment)
875                    .size_increment(size_increment)
876                    .maybe_outcome(outcome.side_name)
877                    .maybe_description(outcome.description)
878                    .info(info_with_asset_index(outcome.info.clone(), def.asset_index))
879                    .ts_event(ts_init)
880                    .ts_init(ts_init)
881                    .build()
882                    .unwrap(),
883            ))
884        }
885    }
886}
887
888fn min_order_notional(currency: Currency) -> Option<Money> {
889    Money::from_decimal(HYPERLIQUID_MIN_ORDER_NOTIONAL, currency).ok()
890}
891
892/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
893/// discarding any definitions that fail to convert.
894#[must_use]
895pub fn instruments_from_defs(
896    defs: &[HyperliquidInstrumentDef],
897    ts_init: UnixNanos,
898) -> Vec<InstrumentAny> {
899    defs.iter()
900        .filter_map(|def| create_instrument_from_def(def, ts_init))
901        .collect()
902}
903
904/// Convert owned definitions into Nautilus instruments, consuming the input vector.
905#[must_use]
906pub fn instruments_from_defs_owned(
907    defs: Vec<HyperliquidInstrumentDef>,
908    ts_init: UnixNanos,
909) -> Vec<InstrumentAny> {
910    defs.into_iter()
911        .filter_map(|def| create_instrument_from_def(&def, ts_init))
912        .collect()
913}
914
915fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
916    match side {
917        HyperliquidSide::Buy => OrderSide::Buy,
918        HyperliquidSide::Sell => OrderSide::Sell,
919    }
920}
921
922/// Parse WebSocket order data to OrderStatusReport.
923///
924/// # Errors
925///
926/// Returns an error if required fields are missing or invalid.
927pub fn parse_order_status_report_from_ws(
928    order_data: &WsOrderData,
929    instrument: &dyn Instrument,
930    account_id: AccountId,
931    ts_init: UnixNanos,
932) -> anyhow::Result<OrderStatusReport> {
933    parse_order_status_report_from_basic(
934        &order_data.order,
935        &order_data.status,
936        instrument,
937        account_id,
938        ts_init,
939    )
940}
941
942/// Parse basic order data to OrderStatusReport.
943///
944/// # Errors
945///
946/// Returns an error if required fields are missing or invalid.
947pub fn parse_order_status_report_from_basic(
948    order: &WsBasicOrderData,
949    status: &HyperliquidOrderStatusEnum,
950    instrument: &dyn Instrument,
951    account_id: AccountId,
952    ts_init: UnixNanos,
953) -> anyhow::Result<OrderStatusReport> {
954    let instrument_id = instrument.id();
955    let venue_order_id = VenueOrderId::new(order.oid.to_string());
956    let order_side = OrderSide::from(order.side);
957
958    let is_conditional = is_conditional_order_data(order.trigger_px, order.tpsl.as_ref());
959    let order_type = if is_conditional {
960        match (order.is_market, order.tpsl.as_ref()) {
961            (Some(is_market), Some(tpsl)) => parse_trigger_order_type(is_market, tpsl),
962            (None, Some(tpsl)) => parse_trigger_order_type(false, tpsl),
963            _ => OrderType::Limit,
964        }
965    } else {
966        OrderType::Limit
967    };
968
969    let time_in_force = order
970        .tif
971        .map_or(TimeInForce::Gtc, hyperliquid_time_in_force_to_nautilus);
972    let order_status = OrderStatus::from(*status);
973
974    let price_precision = instrument.price_precision();
975    let size_precision = instrument.size_precision();
976
977    let orig_sz = order.orig_sz;
978    let current_sz = order.sz;
979
980    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
981        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
982    let filled_sz = orig_sz.abs() - current_sz.abs();
983    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
984        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
985
986    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
987    let ts_last = ts_accepted;
988    let report_id = UUID4::new();
989
990    let mut report = OrderStatusReport::new(
991        account_id,
992        instrument_id,
993        None, // client_order_id - will be set if present
994        venue_order_id,
995        order_side.into(),
996        order_type,
997        time_in_force,
998        order_status,
999        quantity,
1000        filled_qty,
1001        ts_accepted,
1002        ts_last,
1003        ts_init,
1004        Some(report_id),
1005    );
1006
1007    // Add client order ID if present
1008    if let Some(cloid) = &order.cloid {
1009        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
1010    }
1011
1012    if matches!(order.tif, Some(HyperliquidTimeInForce::Alo)) {
1013        report = report.with_post_only(true);
1014    }
1015
1016    if let Some(reduce_only) = order.reduce_only {
1017        report = report.with_reduce_only(reduce_only);
1018    }
1019
1020    if let Some(reason) = status.rejection_reason() {
1021        report = report.with_cancel_reason(reason.to_string());
1022    }
1023
1024    // Only set price for non-filled orders. For filled orders, the limit price is not
1025    // the execution price, and setting it would cause bogus inferred fills to be created
1026    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
1027    if !matches!(
1028        order_status,
1029        OrderStatus::Filled | OrderStatus::PartiallyFilled
1030    ) {
1031        let price = Price::from_decimal_dp(order.limit_px, price_precision)
1032            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
1033        report = report.with_price(price);
1034    }
1035
1036    if is_conditional && let Some(trigger_px) = order.trigger_px {
1037        let trigger_price = Price::from_decimal_dp(trigger_px, price_precision)
1038            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
1039        report = report
1040            .with_trigger_price(trigger_price)
1041            .with_trigger_type(TriggerType::Default);
1042    }
1043
1044    Ok(report)
1045}
1046
1047/// Parses a `recentTrades` info entry into a [`TradeTick`].
1048///
1049/// Mirrors the field mapping of the WebSocket trade parser
1050/// [`parse_ws_trade_tick`](crate::websocket::parse::parse_ws_trade_tick): both the
1051/// `trades` channel and the `recentTrades` endpoint carry the same
1052/// `px`/`sz`/`side`/`time`/`tid` fields. For this historical snapshot `ts_init` is
1053/// set to the trade's `ts_event` (venue time), matching the other request
1054/// converters so the data engine's window trimming keeps bounded requests.
1055///
1056/// # Errors
1057///
1058/// Returns an error if the price, size, trade identifier, or timestamp is invalid.
1059pub fn parse_recent_trade(
1060    trade: &HyperliquidRecentTrade,
1061    instrument: &InstrumentAny,
1062) -> anyhow::Result<TradeTick> {
1063    let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1064        .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1065
1066    let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1067        .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1068
1069    let aggressor = AggressorSide::from(trade.side);
1070    let trade_id = TradeId::new_checked(trade.tid.to_string())
1071        .context("invalid trade identifier in Hyperliquid recent trade")?;
1072    let ts_event = millis_to_nanos(trade.time)?;
1073
1074    TradeTick::new_checked(
1075        instrument.id(),
1076        price,
1077        size,
1078        aggressor,
1079        trade_id,
1080        ts_event,
1081        ts_event,
1082    )
1083    .context("failed to construct TradeTick from Hyperliquid recent trade")
1084}
1085
1086/// Parses a `recentTrades` info entry into a complete public Hyperliquid trade.
1087pub fn parse_recent_public_trade(
1088    trade: &HyperliquidRecentTrade,
1089    instrument: &InstrumentAny,
1090) -> anyhow::Result<HyperliquidPublicTrade> {
1091    let price = Price::from_decimal_dp(trade.px, instrument.price_precision())
1092        .with_context(|| format!("Failed to create price from '{}'", trade.px))?;
1093    let size = Quantity::from_decimal_dp(trade.sz.abs(), instrument.size_precision())
1094        .with_context(|| format!("Failed to create size from '{}'", trade.sz))?;
1095    let ts_event = millis_to_nanos(trade.time)?;
1096
1097    Ok(HyperliquidPublicTrade::new(
1098        instrument.id(),
1099        price,
1100        size,
1101        AggressorSide::from(trade.side),
1102        trade.tid.to_string(),
1103        trade.users[0].clone(),
1104        trade.users[1].clone(),
1105        trade.hash.clone(),
1106        ts_event,
1107        ts_event,
1108    ))
1109}
1110
1111/// Constrains a recent public-trade snapshot to a requested time window.
1112///
1113/// The `recentTrades` endpoint only provides bounded recent coverage, so a
1114/// request whose end precedes the snapshot floor cannot be fulfilled.
1115pub fn filter_recent_public_trades(
1116    trades: Vec<HyperliquidPublicTrade>,
1117    start: Option<UnixNanos>,
1118    end: Option<UnixNanos>,
1119    limit: Option<usize>,
1120    instrument_id: InstrumentId,
1121) -> Vec<HyperliquidPublicTrade> {
1122    let Some(floor) = trades.first().map(|trade| trade.ts_event) else {
1123        return Vec::new();
1124    };
1125
1126    if let Some(end) = end
1127        && end < floor
1128    {
1129        log::warn!(
1130            "Recent public trades for {instrument_id} are entirely older than the requested window; \
1131             snapshot only covers back to {}",
1132            unix_nanos_to_iso8601(floor),
1133        );
1134        return Vec::new();
1135    }
1136
1137    if let Some(start) = start
1138        && start < floor
1139    {
1140        log::warn!(
1141            "Recent public trades for {instrument_id} only cover back to {}; \
1142             the requested start is earlier and cannot be served",
1143            unix_nanos_to_iso8601(floor),
1144        );
1145    }
1146
1147    let mut filtered: Vec<HyperliquidPublicTrade> = trades
1148        .into_iter()
1149        .filter(|trade| start.is_none_or(|value| trade.ts_event >= value))
1150        .filter(|trade| end.is_none_or(|value| trade.ts_event <= value))
1151        .collect();
1152
1153    if let Some(limit) = limit
1154        && filtered.len() > limit
1155    {
1156        // Preserve ascending event-time order while retaining the newest data.
1157        filtered.drain(0..filtered.len() - limit);
1158    }
1159
1160    filtered
1161}
1162
1163/// Parse Hyperliquid fill to FillReport.
1164///
1165/// # Errors
1166///
1167/// Returns an error if required fields are missing or invalid.
1168pub fn parse_fill_report(
1169    fill: &HyperliquidFill,
1170    instrument: &dyn Instrument,
1171    account_id: AccountId,
1172    ts_init: UnixNanos,
1173) -> anyhow::Result<FillReport> {
1174    let instrument_id = instrument.id();
1175    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
1176
1177    if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
1178        log::warn!(
1179            "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
1180            fill.oid,
1181            fill.px,
1182            fill.sz,
1183        );
1184    }
1185
1186    let trade_id = make_fill_trade_id(
1187        &fill.hash,
1188        fill.oid,
1189        fill.px,
1190        fill.sz,
1191        fill.time,
1192        fill.start_position,
1193    );
1194    let order_side = parse_fill_side(&fill.side);
1195
1196    let price_precision = instrument.price_precision();
1197    let size_precision = instrument.size_precision();
1198
1199    let last_px = Price::from_decimal_dp(fill.px, price_precision)
1200        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
1201    let last_qty = Quantity::from_decimal_dp(fill.sz.abs(), size_precision)
1202        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
1203
1204    let fee_amount = fill.fee;
1205
1206    let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
1207    let commission = Money::from_decimal(fee_amount, fee_currency)
1208        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
1209
1210    // Determine liquidity side based on 'crossed' flag
1211    let liquidity_side = if fill.crossed {
1212        LiquiditySide::Taker
1213    } else {
1214        LiquiditySide::Maker
1215    };
1216
1217    let ts_event = UnixNanos::from(fill.time * 1_000_000);
1218    let report_id = UUID4::new();
1219
1220    let report = FillReport::new(
1221        account_id,
1222        instrument_id,
1223        venue_order_id,
1224        trade_id,
1225        order_side,
1226        last_qty,
1227        last_px,
1228        commission,
1229        liquidity_side,
1230        None, // client_order_id - to be linked by execution engine
1231        None, // venue_position_id
1232        ts_event,
1233        ts_init,
1234        Some(report_id),
1235    );
1236
1237    Ok(report)
1238}
1239
1240/// Parse position data from clearinghouse state to PositionStatusReport.
1241///
1242/// # Errors
1243///
1244/// Returns an error if required fields are missing or invalid.
1245pub fn parse_position_status_report(
1246    position_data: &serde_json::Value,
1247    instrument: &dyn Instrument,
1248    account_id: AccountId,
1249    ts_init: UnixNanos,
1250) -> anyhow::Result<PositionStatusReport> {
1251    // Deserialize the position data
1252    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
1253        .context("failed to deserialize AssetPosition")?;
1254
1255    let position = &asset_position.position;
1256    let instrument_id = instrument.id();
1257
1258    // Determine position side based on size (szi)
1259    let (position_side, quantity_value) = if position.szi.is_zero() {
1260        (PositionSide::Flat, Decimal::ZERO)
1261    } else if position.szi.is_sign_positive() {
1262        (PositionSide::Long, position.szi)
1263    } else {
1264        (PositionSide::Short, position.szi.abs())
1265    };
1266
1267    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1268        .context("failed to create quantity from decimal")?;
1269    let report_id = UUID4::new();
1270    let ts_last = ts_init;
1271    let avg_px_open = position.entry_px;
1272
1273    // Hyperliquid uses netting (one position per instrument), not hedging
1274    Ok(PositionStatusReport::new(
1275        account_id,
1276        instrument_id,
1277        position_side,
1278        quantity,
1279        ts_last,
1280        ts_init,
1281        Some(report_id),
1282        None, // No venue_position_id for netting positions
1283        avg_px_open,
1284    ))
1285}
1286
1287/// Parse a spot token balance into a [`PositionStatusReport`] against the spot instrument.
1288///
1289/// Spot holdings are always Long (Hyperliquid spot has no short exposure). The average
1290/// entry price is derived from `entry_ntl / total` when both are non-zero; otherwise it
1291/// is omitted.
1292///
1293/// # Errors
1294///
1295/// Returns an error if the quantity cannot be constructed at the instrument's precision.
1296pub fn parse_spot_position_status_report(
1297    balance: &SpotBalance,
1298    instrument: &dyn Instrument,
1299    account_id: AccountId,
1300    ts_init: UnixNanos,
1301) -> anyhow::Result<PositionStatusReport> {
1302    let (position_side, quantity_value) = if balance.total.is_zero() {
1303        (PositionSide::Flat, Decimal::ZERO)
1304    } else {
1305        (PositionSide::Long, balance.total)
1306    };
1307
1308    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1309        .context("failed to create spot quantity from decimal")?;
1310
1311    Ok(PositionStatusReport::new(
1312        account_id,
1313        instrument.id(),
1314        position_side,
1315        quantity,
1316        ts_init,
1317        ts_init,
1318        Some(UUID4::new()),
1319        None,
1320        balance.avg_entry_px(),
1321    ))
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use rstest::rstest;
1327    use rust_decimal_macros::dec;
1328
1329    use super::{
1330        super::models::{
1331            HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1332            PerpAsset, SpotPair, SpotToken,
1333        },
1334        *,
1335    };
1336
1337    #[rstest]
1338    fn test_parse_fill_side() {
1339        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy,);
1340        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell,);
1341    }
1342
1343    #[rstest]
1344    fn test_pow10_neg() {
1345        assert_eq!(pow10_neg(0), dec!(1));
1346        assert_eq!(pow10_neg(1), dec!(0.1));
1347        assert_eq!(pow10_neg(5), dec!(0.00001));
1348    }
1349
1350    #[rstest]
1351    fn test_parse_perp_instruments() {
1352        let meta = PerpMeta {
1353            universe: vec![
1354                PerpAsset {
1355                    name: "BTC".to_string(),
1356                    sz_decimals: 5,
1357                    max_leverage: Some(50),
1358                    ..Default::default()
1359                },
1360                PerpAsset {
1361                    name: "DELIST".to_string(),
1362                    sz_decimals: 3,
1363                    max_leverage: Some(10),
1364                    only_isolated: Some(true),
1365                    is_delisted: Some(true),
1366                    ..Default::default()
1367                },
1368            ],
1369            margin_tables: vec![],
1370            collateral_token: None,
1371        };
1372
1373        let defs = parse_perp_instruments(&meta, 0).unwrap();
1374
1375        // Should have both BTC and DELIST (delisted instruments are included for historical data)
1376        assert_eq!(defs.len(), 2);
1377
1378        let btc = &defs[0];
1379        assert_eq!(btc.symbol, "BTC-USD-PERP");
1380        assert_eq!(btc.base, "BTC");
1381        assert_eq!(btc.quote, "USD");
1382        assert_eq!(btc.settlement.as_ref().unwrap(), "USDC");
1383        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1384        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
1385        assert_eq!(btc.size_decimals, 5);
1386        assert_eq!(btc.tick_size, dec!(0.1));
1387        assert_eq!(btc.lot_size, dec!(0.00001));
1388        assert_eq!(btc.max_leverage, Some(50));
1389        assert!(!btc.only_isolated);
1390        assert!(btc.active);
1391
1392        let delist = &defs[1];
1393        assert_eq!(delist.symbol, "DELIST-USD-PERP");
1394        assert_eq!(delist.base, "DELIST");
1395        assert!(!delist.active); // Delisted instruments are marked as inactive
1396    }
1397
1398    use crate::common::testing::load_test_data;
1399
1400    #[rstest]
1401    fn test_parse_perp_instruments_from_real_data() {
1402        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1403
1404        let defs = parse_perp_instruments(&meta, 0).unwrap();
1405
1406        // Should have 3 instruments (BTC, ETH, ATOM)
1407        assert_eq!(defs.len(), 3);
1408
1409        // Validate BTC
1410        let btc = &defs[0];
1411        assert_eq!(btc.symbol, "BTC-USD-PERP");
1412        assert_eq!(btc.base, "BTC");
1413        assert_eq!(btc.quote, "USD");
1414        assert_eq!(btc.settlement.as_ref().unwrap(), "USDC");
1415        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1416        assert_eq!(btc.size_decimals, 5);
1417        assert_eq!(btc.max_leverage, Some(40));
1418        assert!(btc.active);
1419
1420        // Validate ETH
1421        let eth = &defs[1];
1422        assert_eq!(eth.symbol, "ETH-USD-PERP");
1423        assert_eq!(eth.base, "ETH");
1424        assert_eq!(eth.size_decimals, 4);
1425        assert_eq!(eth.max_leverage, Some(25));
1426
1427        // Validate ATOM
1428        let atom = &defs[2];
1429        assert_eq!(atom.symbol, "ATOM-USD-PERP");
1430        assert_eq!(atom.base, "ATOM");
1431        assert_eq!(atom.size_decimals, 2);
1432        assert_eq!(atom.max_leverage, Some(5));
1433    }
1434
1435    #[rstest]
1436    fn test_parse_recent_trade() {
1437        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1438        let defs = parse_perp_instruments(&meta, 0).unwrap();
1439        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1440
1441        let trade = HyperliquidRecentTrade {
1442            coin: Ustr::from("BTC"),
1443            side: HyperliquidSide::Sell,
1444            px: dec!(50000.0),
1445            sz: dec!(0.5),
1446            hash: "0xhash".to_string(),
1447            time: 1_769_916_000_000,
1448            tid: 987_654_321,
1449            users: ["0xbuyer".to_string(), "0xseller".to_string()],
1450        };
1451
1452        let tick = parse_recent_trade(&trade, &instrument).unwrap();
1453
1454        assert_eq!(tick.instrument_id, instrument.id());
1455        assert_eq!(tick.price.as_decimal(), dec!(50000));
1456        assert_eq!(tick.size.as_decimal(), dec!(0.5));
1457        assert_eq!(tick.aggressor_side, AggressorSide::Sell);
1458        assert_eq!(tick.trade_id.to_string(), "987654321");
1459        assert_eq!(
1460            tick.ts_event,
1461            UnixNanos::from(1_769_916_000_000 * 1_000_000)
1462        );
1463        // Historical trades carry ts_init == ts_event so the engine's window
1464        // trimming (by ts_init) keeps bounded requests.
1465        assert_eq!(tick.ts_init, tick.ts_event);
1466    }
1467
1468    #[rstest]
1469    fn test_recent_trade_rejects_invalid_price() {
1470        // Price is now a Decimal field, so an invalid value is rejected at
1471        // deserialization rather than by parse_recent_trade.
1472        let json = r#"{"coin":"BTC","side":"B","px":"not-a-number","sz":"0.5","time":1769916000000,"tid":1}"#;
1473        assert!(serde_json::from_str::<HyperliquidRecentTrade>(json).is_err());
1474    }
1475
1476    #[rstest]
1477    fn test_create_instrument_from_def_perp_sets_min_notional() {
1478        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1479        let defs = parse_perp_instruments(&meta, 0).unwrap();
1480
1481        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1482
1483        match instrument {
1484            InstrumentAny::CryptoPerpetual(perp) => {
1485                let min_notional = perp.min_notional.unwrap();
1486                assert_eq!(min_notional.currency, Currency::USD());
1487                assert_eq!(min_notional.as_decimal(), dec!(10));
1488                assert_eq!(perp.settlement_currency.code, "USDC");
1489            }
1490            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1491        }
1492    }
1493
1494    #[rstest]
1495    fn test_create_instrument_from_def_carries_asset_index_on_info() {
1496        // The execution client resolves a market listed after its own bootstrap
1497        // from the instrument published on the message bus, so every market type
1498        // must carry its signing asset index on `info`.
1499        let perp_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1500        let outcome_meta = OutcomeMeta {
1501            outcomes: vec![OutcomeMarket {
1502                outcome: 2,
1503                name: "Recurring BTC".to_string(),
1504                description: "Daily settlement".to_string(),
1505                side_specs: vec![
1506                    OutcomeSideSpec {
1507                        name: "Yes".to_string(),
1508                    },
1509                    OutcomeSideSpec {
1510                        name: "No".to_string(),
1511                    },
1512                ],
1513            }],
1514            questions: vec![],
1515        };
1516
1517        let mut defs = parse_perp_instruments(&perp_meta, 0).unwrap();
1518        defs.extend(parse_perp_instruments(&perp_meta, 110_000).unwrap());
1519        defs.extend(parse_outcome_instruments(&outcome_meta).unwrap());
1520
1521        assert!(defs.iter().any(|def| def.is_hip3));
1522
1523        for def in &defs {
1524            let instrument = create_instrument_from_def(def, UnixNanos::default()).unwrap();
1525            let info = instrument
1526                .info()
1527                .unwrap_or_else(|| panic!("info missing for {}", def.symbol));
1528
1529            assert_eq!(
1530                info.get_u64(ASSET_INDEX_INFO_KEY),
1531                Some(u64::from(def.asset_index)),
1532                "asset index mismatch for {}",
1533                def.symbol,
1534            );
1535        }
1536    }
1537
1538    #[rstest]
1539    fn test_parse_perp_instruments_with_non_usdc_collateral() {
1540        let all_metas: Vec<PerpMeta> =
1541            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1542        let spot_meta: SpotMeta = load_test_data("http_spot_meta_non_usdc_collateral.json");
1543
1544        assert_eq!(all_metas[1].collateral_token, Some(360));
1545        assert_eq!(all_metas[2].collateral_token, Some(235));
1546
1547        let settlement_currency =
1548            resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap();
1549        let defs = parse_perp_instruments_with_settlement(
1550            &all_metas[1],
1551            110_000,
1552            settlement_currency.as_str(),
1553        );
1554
1555        assert_eq!(settlement_currency, "USDH");
1556        assert_eq!(defs.len(), 1);
1557        assert_eq!(defs[0].symbol, "km:US500-USD-PERP");
1558        assert_eq!(defs[0].quote, "USD");
1559        assert_eq!(defs[0].settlement.as_ref().unwrap(), "USDH");
1560
1561        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1562        match instrument {
1563            InstrumentAny::CryptoPerpetual(perp) => {
1564                assert_eq!(perp.quote_currency.code, "USD");
1565                assert_eq!(perp.settlement_currency.code, "USDH");
1566                assert_eq!(perp.settlement_currency.name, "Hyperliquid USD");
1567            }
1568            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1569        }
1570
1571        let settlement_currency =
1572            resolve_perp_settlement_currency(&all_metas[2], Some(&spot_meta)).unwrap();
1573        let defs = parse_perp_instruments_with_settlement(
1574            &all_metas[2],
1575            140_000,
1576            settlement_currency.as_str(),
1577        );
1578
1579        assert_eq!(settlement_currency, "USDE");
1580        assert_eq!(defs.len(), 1);
1581        assert_eq!(defs[0].symbol, "hyna:BTC-USD-PERP");
1582        assert_eq!(defs[0].quote, "USD");
1583        assert_eq!(defs[0].settlement.as_ref().unwrap(), "USDE");
1584
1585        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1586        match instrument {
1587            InstrumentAny::CryptoPerpetual(perp) => {
1588                assert_eq!(perp.quote_currency.code, "USD");
1589                assert_eq!(perp.settlement_currency.code, "USDE");
1590            }
1591            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1592        }
1593    }
1594
1595    #[rstest]
1596    fn test_create_instrument_from_def_perp_defaults_missing_settlement_to_usdc() {
1597        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1598        let mut defs = parse_perp_instruments(&meta, 0).unwrap();
1599        defs[0].settlement = None;
1600
1601        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1602
1603        match instrument {
1604            InstrumentAny::CryptoPerpetual(perp) => {
1605                assert_eq!(perp.quote_currency.code, "USD");
1606                assert_eq!(perp.settlement_currency.code, "USDC");
1607            }
1608            other => panic!("Expected CryptoPerpetual, was {other:?}"),
1609        }
1610    }
1611
1612    #[rstest]
1613    fn test_resolve_perp_settlement_currency_defaults_to_usdc() {
1614        let legacy_meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1615        let all_metas: Vec<PerpMeta> =
1616            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1617
1618        let legacy_settlement = resolve_perp_settlement_currency(&legacy_meta, None).unwrap();
1619        let token_zero_settlement = resolve_perp_settlement_currency(&all_metas[0], None).unwrap();
1620
1621        assert_eq!(legacy_settlement, "USDC");
1622        assert_eq!(token_zero_settlement, "USDC");
1623    }
1624
1625    #[rstest]
1626    fn test_resolve_perp_settlement_currency_requires_spot_meta_for_non_usdc() {
1627        let all_metas: Vec<PerpMeta> =
1628            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1629
1630        let err = resolve_perp_settlement_currency(&all_metas[1], None).unwrap_err();
1631
1632        assert_eq!(
1633            err,
1634            "Spot metadata required to resolve perp collateral token 360",
1635        );
1636    }
1637
1638    #[rstest]
1639    fn test_resolve_perp_settlement_currency_errors_on_missing_token_index() {
1640        let all_metas: Vec<PerpMeta> =
1641            load_test_data("http_all_perp_metas_non_usdc_collateral.json");
1642        let spot_meta = SpotMeta {
1643            tokens: Vec::new(),
1644            universe: Vec::new(),
1645        };
1646
1647        let err = resolve_perp_settlement_currency(&all_metas[1], Some(&spot_meta)).unwrap_err();
1648
1649        assert_eq!(
1650            err,
1651            "Perp collateral token index 360 not found in spot metadata",
1652        );
1653    }
1654
1655    #[rstest]
1656    fn test_deserialize_l2_book_from_real_data() {
1657        let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1658
1659        // Validate basic structure
1660        assert_eq!(book.coin, "BTC");
1661        assert_eq!(book.levels.len(), 2); // [bids, asks]
1662        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
1663        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
1664
1665        // Verify bids and asks are properly ordered
1666        let bids = &book.levels[0];
1667        let asks = &book.levels[1];
1668
1669        // Bids should be descending (highest first)
1670        for i in 1..bids.len() {
1671            let prev_price = bids[i - 1].px;
1672            let curr_price = bids[i].px;
1673            assert!(prev_price >= curr_price, "Bids should be descending");
1674        }
1675
1676        // Asks should be ascending (lowest first)
1677        for i in 1..asks.len() {
1678            let prev_price = asks[i - 1].px;
1679            let curr_price = asks[i].px;
1680            assert!(prev_price <= curr_price, "Asks should be ascending");
1681        }
1682    }
1683
1684    #[rstest]
1685    fn test_parse_spot_instruments() {
1686        let tokens = vec![
1687            SpotToken {
1688                name: "USDC".to_string(),
1689                sz_decimals: 6,
1690                wei_decimals: 6,
1691                index: 0,
1692                token_id: "0x1".to_string(),
1693                is_canonical: true,
1694                evm_contract: None,
1695                full_name: None,
1696                deployer_trading_fee_share: None,
1697            },
1698            SpotToken {
1699                name: "PURR".to_string(),
1700                sz_decimals: 0,
1701                wei_decimals: 5,
1702                index: 1,
1703                token_id: "0x2".to_string(),
1704                is_canonical: true,
1705                evm_contract: None,
1706                full_name: None,
1707                deployer_trading_fee_share: None,
1708            },
1709        ];
1710
1711        let pairs = vec![
1712            SpotPair {
1713                name: "PURR/USDC".to_string(),
1714                tokens: [1, 0], // PURR base, USDC quote
1715                index: 0,
1716                is_canonical: true,
1717            },
1718            SpotPair {
1719                name: "ALIAS".to_string(),
1720                tokens: [1, 0],
1721                index: 1,
1722                is_canonical: false,
1723            },
1724        ];
1725
1726        let meta = SpotMeta {
1727            tokens,
1728            universe: pairs,
1729        };
1730
1731        let defs = parse_spot_instruments(&meta).unwrap();
1732
1733        assert_eq!(defs.len(), 2);
1734
1735        let purr_usdc = &defs[0];
1736        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1737        assert_eq!(purr_usdc.base, "PURR");
1738        assert_eq!(purr_usdc.quote, "USDC");
1739        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1740        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
1741        assert_eq!(purr_usdc.size_decimals, 0);
1742        assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1743        assert_eq!(purr_usdc.lot_size, dec!(1));
1744        assert_eq!(purr_usdc.max_leverage, None);
1745        assert!(!purr_usdc.only_isolated);
1746        assert!(purr_usdc.active);
1747
1748        let alias = &defs[1];
1749        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1750        assert_eq!(alias.base, "PURR");
1751        assert!(alias.active);
1752
1753        let instrument = create_instrument_from_def(purr_usdc, UnixNanos::default()).unwrap();
1754
1755        match instrument {
1756            InstrumentAny::CurrencyPair(pair) => {
1757                let min_notional = pair.min_notional.unwrap();
1758                let info = pair.info.unwrap();
1759                assert_eq!(min_notional.currency, Currency::USDC());
1760                assert_eq!(min_notional.as_decimal(), dec!(10));
1761                assert_eq!(info.len(), 5);
1762                assert_eq!(info.get_str("name"), Some("PURR/USDC"));
1763                assert_eq!(info.get("tokens"), Some(&json!([1, 0])));
1764                assert_eq!(info.get_u64("index"), Some(0));
1765                assert_eq!(info.get_bool("isCanonical"), Some(true));
1766                assert_eq!(
1767                    info.get_u64(ASSET_INDEX_INFO_KEY),
1768                    Some(u64::from(purr_usdc.asset_index)),
1769                );
1770            }
1771            other => panic!("Expected CurrencyPair, was {other:?}"),
1772        }
1773
1774        let instrument = create_instrument_from_def(alias, UnixNanos::default()).unwrap();
1775
1776        match instrument {
1777            InstrumentAny::CurrencyPair(pair) => {
1778                let info = pair.info.unwrap();
1779                assert_eq!(info.len(), 5);
1780                assert_eq!(info.get_str("name"), Some("ALIAS"));
1781                assert_eq!(info.get("tokens"), Some(&json!([1, 0])));
1782                assert_eq!(info.get_u64("index"), Some(1));
1783                assert_eq!(info.get_bool("isCanonical"), Some(false));
1784                assert_eq!(
1785                    info.get_u64(ASSET_INDEX_INFO_KEY),
1786                    Some(u64::from(alias.asset_index)),
1787                );
1788            }
1789            other => panic!("Expected CurrencyPair, was {other:?}"),
1790        }
1791    }
1792
1793    #[rstest]
1794    fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1795        // Non-canonical pair uses a lower pair index than the canonical one;
1796        // the sort must still put canonical first so the base-token alias in
1797        // cache_instrument resolves to the canonical instrument.
1798        let tokens = vec![
1799            SpotToken {
1800                name: "USDC".to_string(),
1801                sz_decimals: 6,
1802                wei_decimals: 6,
1803                index: 0,
1804                token_id: "0x1".to_string(),
1805                is_canonical: true,
1806                evm_contract: None,
1807                full_name: None,
1808                deployer_trading_fee_share: None,
1809            },
1810            SpotToken {
1811                name: "HYPE".to_string(),
1812                sz_decimals: 2,
1813                wei_decimals: 8,
1814                index: 150,
1815                token_id: "0x2".to_string(),
1816                is_canonical: true,
1817                evm_contract: None,
1818                full_name: None,
1819                deployer_trading_fee_share: None,
1820            },
1821        ];
1822
1823        let pairs = vec![
1824            SpotPair {
1825                name: "HYPE_OLD".to_string(),
1826                tokens: [150, 0],
1827                index: 3,
1828                is_canonical: false,
1829            },
1830            SpotPair {
1831                name: "HYPE".to_string(),
1832                tokens: [150, 0],
1833                index: 107,
1834                is_canonical: true,
1835            },
1836        ];
1837
1838        let defs = parse_spot_instruments(&SpotMeta {
1839            tokens,
1840            universe: pairs,
1841        })
1842        .unwrap();
1843
1844        assert_eq!(defs.len(), 2);
1845        assert!(defs[0].active);
1846        assert_eq!(defs[0].raw_symbol, "@107");
1847        assert_eq!(defs[0].asset_index, 10000 + 107);
1848        assert!(defs[1].active);
1849        assert_eq!(defs[1].raw_symbol, "@3");
1850        assert_eq!(defs[1].asset_index, 10000 + 3);
1851    }
1852
1853    #[rstest]
1854    fn test_price_decimals_clamping() {
1855        let meta = PerpMeta {
1856            universe: vec![PerpAsset {
1857                name: "HIGHPREC".to_string(),
1858                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
1859                max_leverage: Some(1),
1860                ..Default::default()
1861            }],
1862            margin_tables: vec![],
1863            collateral_token: None,
1864        };
1865
1866        let defs = parse_perp_instruments(&meta, 0).unwrap();
1867        assert_eq!(defs[0].price_decimals, 0);
1868        assert_eq!(defs[0].tick_size, dec!(1));
1869    }
1870
1871    #[rstest]
1872    fn test_parse_perp_instruments_hip3_dex() {
1873        // HIP-3 dex at index 1: asset_index_base = 100_000 + 1 * 10_000 = 110_000
1874        let meta = PerpMeta {
1875            universe: vec![
1876                PerpAsset {
1877                    name: "xyz:TSLA".to_string(),
1878                    sz_decimals: 3,
1879                    max_leverage: Some(10),
1880                    only_isolated: None,
1881                    is_delisted: None,
1882                    growth_mode: Some("enabled".to_string()),
1883                    margin_mode: Some("strictIsolated".to_string()),
1884                },
1885                PerpAsset {
1886                    name: "xyz:NVDA".to_string(),
1887                    sz_decimals: 3,
1888                    max_leverage: Some(20),
1889                    only_isolated: None,
1890                    is_delisted: None,
1891                    growth_mode: None,
1892                    margin_mode: None,
1893                },
1894            ],
1895            margin_tables: vec![],
1896            collateral_token: None,
1897        };
1898
1899        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1900        assert_eq!(defs.len(), 2);
1901
1902        // HIP-3 asset: colon in symbol, offset asset index
1903        assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1904        assert!(defs[0].symbol.contains(':'));
1905        assert_eq!(defs[0].base, "xyz:TSLA");
1906        assert_eq!(defs[0].asset_index, 110_000);
1907        assert!(defs[0].active);
1908
1909        assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1910        assert_eq!(defs[1].asset_index, 110_001);
1911    }
1912
1913    #[rstest]
1914    #[case("BTC", "BTC")]
1915    #[case("kPEPE", "kPEPE")]
1916    #[case("xyz:TSLA", "xyz:TSLA")]
1917    #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1918    #[case("ABC?", "ABCx")]
1919    #[case("a*b?c", "axbxc")]
1920    fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1921        assert_eq!(sanitize_symbol(input), expected);
1922    }
1923
1924    #[rstest]
1925    fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1926        // Hypothetical spot token whose venue name contains `?`. Sanitization
1927        // must apply to the constructed `symbol` while leaving `raw_symbol`
1928        // and `base` carrying the venue-official name for wire I/O.
1929        let tokens = vec![
1930            SpotToken {
1931                name: "USDC".to_string(),
1932                sz_decimals: 6,
1933                wei_decimals: 6,
1934                index: 0,
1935                token_id: "0x1".to_string(),
1936                is_canonical: true,
1937                evm_contract: None,
1938                full_name: None,
1939                deployer_trading_fee_share: None,
1940            },
1941            SpotToken {
1942                name: "ABC?".to_string(),
1943                sz_decimals: 4,
1944                wei_decimals: 4,
1945                index: 1,
1946                token_id: "0x2".to_string(),
1947                is_canonical: true,
1948                evm_contract: None,
1949                full_name: None,
1950                deployer_trading_fee_share: None,
1951            },
1952        ];
1953
1954        let pairs = vec![SpotPair {
1955            name: "ABC?/USDC".to_string(),
1956            tokens: [1, 0],
1957            index: 50,
1958            is_canonical: true,
1959        }];
1960
1961        let meta = SpotMeta {
1962            tokens,
1963            universe: pairs,
1964        };
1965
1966        let defs = parse_spot_instruments(&meta).unwrap();
1967        assert_eq!(defs.len(), 1);
1968        assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1969        assert_eq!(defs[0].base, "ABC?");
1970        assert_eq!(defs[0].quote, "USDC");
1971    }
1972
1973    #[rstest]
1974    fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1975        let meta = PerpMeta {
1976            universe: vec![PerpAsset {
1977                name: "dex:STREAMABCD****".to_string(),
1978                sz_decimals: 3,
1979                max_leverage: Some(10),
1980                only_isolated: None,
1981                is_delisted: None,
1982                growth_mode: None,
1983                margin_mode: None,
1984            }],
1985            margin_tables: vec![],
1986            collateral_token: None,
1987        };
1988
1989        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1990        assert_eq!(defs.len(), 1);
1991        assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1992        assert_eq!(defs[0].raw_symbol, "dex:STREAMABCD****");
1993        assert_eq!(defs[0].base, "dex:STREAMABCD****");
1994    }
1995
1996    #[rstest]
1997    fn test_parse_outcome_instruments_emits_both_sides() {
1998        let meta = OutcomeMeta {
1999            outcomes: vec![OutcomeMarket {
2000                outcome: 1,
2001                name: "BTC daily".to_string(),
2002                description: "BTC settles above strike at 06:00 UTC".to_string(),
2003                side_specs: vec![
2004                    OutcomeSideSpec {
2005                        name: "Yes".to_string(),
2006                    },
2007                    OutcomeSideSpec {
2008                        name: "No".to_string(),
2009                    },
2010                ],
2011            }],
2012            questions: vec![],
2013        };
2014
2015        let defs = parse_outcome_instruments(&meta).unwrap();
2016        assert_eq!(defs.len(), 2);
2017
2018        let yes = &defs[0];
2019        assert_eq!(yes.symbol, "1-YES-OUTCOME");
2020        assert_eq!(yes.raw_symbol, "#10");
2021        assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
2022        assert_eq!(yes.asset_index, 100_000_010);
2023        assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
2024        assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
2025        assert_eq!(yes.tick_size, dec!(0.0001));
2026        assert_eq!(yes.lot_size, dec!(0.01));
2027        assert_eq!(yes.quote, "USDH");
2028        assert!(yes.active);
2029
2030        let yes_meta = yes.outcome.as_ref().unwrap();
2031        assert_eq!(yes_meta.outcome_index, 1);
2032        assert_eq!(yes_meta.outcome_side, 0);
2033        assert_eq!(yes_meta.market_name, "BTC daily");
2034        assert_eq!(yes_meta.side_name.unwrap(), "Yes");
2035        assert_eq!(
2036            yes_meta.description.unwrap(),
2037            "BTC settles above strike at 06:00 UTC"
2038        );
2039
2040        let no = &defs[1];
2041        assert_eq!(no.symbol, "1-NO-OUTCOME");
2042        assert_eq!(no.raw_symbol, "#11");
2043        assert_eq!(no.asset_index, 100_000_011);
2044        let no_meta = no.outcome.as_ref().unwrap();
2045        assert_eq!(no_meta.outcome_side, 1);
2046        assert_eq!(no_meta.side_name.unwrap(), "No");
2047    }
2048
2049    #[rstest]
2050    fn test_parse_outcome_instruments_handles_missing_side_specs() {
2051        let meta = OutcomeMeta {
2052            outcomes: vec![OutcomeMarket {
2053                outcome: 5,
2054                name: "Recurring".to_string(),
2055                description: String::new(),
2056                side_specs: vec![],
2057            }],
2058            questions: vec![],
2059        };
2060
2061        let defs = parse_outcome_instruments(&meta).unwrap();
2062        assert_eq!(defs.len(), 2);
2063
2064        // Even when the venue omits `sideSpecs`, the parser falls back to the
2065        // canonical HIP-4 labels ("Yes" / "No") so downstream `BinaryOption`
2066        // instruments always carry a meaningful side label.
2067        assert_eq!(defs[0].outcome.as_ref().unwrap().side_name.unwrap(), "Yes");
2068        assert_eq!(defs[1].outcome.as_ref().unwrap().side_name.unwrap(), "No");
2069
2070        for def in &defs {
2071            assert!(def.outcome.as_ref().unwrap().description.is_none());
2072        }
2073
2074        assert_eq!(defs[0].asset_index, 100_000_050);
2075        assert_eq!(defs[1].asset_index, 100_000_051);
2076    }
2077
2078    #[rstest]
2079    fn test_get_usdh_currency_registers_with_explicit_precision() {
2080        let currency = get_usdh_currency();
2081        assert_eq!(currency.code, "USDH");
2082        assert_eq!(currency.precision, 8);
2083        assert_eq!(currency.currency_type, CurrencyType::Crypto);
2084
2085        // Repeated calls return the same registered currency
2086        let again = get_usdh_currency();
2087        assert_eq!(again, currency);
2088        assert!(Currency::try_from_str("USDH").is_some());
2089    }
2090
2091    #[rstest]
2092    fn test_create_instrument_from_def_outcome_emits_binary_option() {
2093        let meta = OutcomeMeta {
2094            outcomes: vec![OutcomeMarket {
2095                outcome: 2,
2096                name: "Recurring BTC".to_string(),
2097                description: "Daily settlement".to_string(),
2098                side_specs: vec![
2099                    OutcomeSideSpec {
2100                        name: "Yes".to_string(),
2101                    },
2102                    OutcomeSideSpec {
2103                        name: "No".to_string(),
2104                    },
2105                ],
2106            }],
2107            questions: vec![],
2108        };
2109
2110        let defs = parse_outcome_instruments(&meta).unwrap();
2111        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2112
2113        match instrument {
2114            InstrumentAny::BinaryOption(bo) => {
2115                assert_eq!(bo.id.symbol.as_str(), "2-YES-OUTCOME");
2116                assert_eq!(bo.raw_symbol.as_str(), "#20");
2117                assert_eq!(bo.asset_class, AssetClass::Alternative);
2118                assert_eq!(bo.currency.code, "USDH");
2119                assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
2120                assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
2121                assert_eq!(bo.outcome.unwrap(), "Yes");
2122                assert_eq!(bo.description.unwrap(), "Daily settlement");
2123
2124                let info = bo.info.expect("info should be populated for outcomes");
2125                assert_eq!(info.get_u64("outcome_index"), Some(2));
2126                assert_eq!(info.get_u64("outcome_side"), Some(0));
2127                assert_eq!(info.get_u64("encoding"), Some(20));
2128                assert_eq!(info.get_u64("asset_id"), Some(100_000_020));
2129                assert_eq!(info.get_str("side_name"), Some("Yes"));
2130                assert_eq!(info.get_str("market_name"), Some("Recurring BTC"));
2131            }
2132            other => panic!("Expected BinaryOption, was {other:?}"),
2133        }
2134    }
2135
2136    #[rstest]
2137    fn test_create_instrument_from_def_outcome_info_carries_parsed_description() {
2138        let meta = OutcomeMeta {
2139            outcomes: vec![OutcomeMarket {
2140                outcome: 5,
2141                name: "Recurring BTC".to_string(),
2142                description:
2143                    "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2144                        .to_string(),
2145                side_specs: vec![
2146                    OutcomeSideSpec {
2147                        name: "Yes".to_string(),
2148                    },
2149                    OutcomeSideSpec {
2150                        name: "No".to_string(),
2151                    },
2152                ],
2153            }],
2154            questions: vec![],
2155        };
2156
2157        let defs = parse_outcome_instruments(&meta).unwrap();
2158        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2159
2160        match yes {
2161            InstrumentAny::BinaryOption(bo) => {
2162                let info = bo.info.expect("info should be populated for outcomes");
2163                assert_eq!(info.get_str("class"), Some("priceBinary"));
2164                assert_eq!(info.get_str("underlying"), Some("BTC"));
2165                assert_eq!(info.get_str("expiry"), Some("20260508-0600"));
2166                assert_eq!(info.get_str("target_price"), Some("81041"));
2167                assert_eq!(info.get_str("period"), Some("1d"));
2168                assert!(info.get("question").is_none());
2169            }
2170            other => panic!("Expected BinaryOption, was {other:?}"),
2171        }
2172    }
2173
2174    #[rstest]
2175    fn test_create_instrument_from_def_outcome_info_merges_parent_question() {
2176        let meta = OutcomeMeta {
2177            outcomes: vec![
2178                OutcomeMarket {
2179                    outcome: 6,
2180                    name: "Recurring Fallback".to_string(),
2181                    description: "other".to_string(),
2182                    side_specs: vec![],
2183                },
2184                OutcomeMarket {
2185                    outcome: 7,
2186                    name: "Recurring Named Outcome".to_string(),
2187                    description: "index:0".to_string(),
2188                    side_specs: vec![],
2189                },
2190            ],
2191            questions: vec![OutcomeQuestion {
2192                question: 0,
2193                name: "Recurring".to_string(),
2194                description:
2195                    "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2196                        .to_string(),
2197                fallback_outcome: Some(6),
2198                named_outcomes: vec![7, 8, 9],
2199                settled_named_outcomes: vec![],
2200            }],
2201        };
2202
2203        let defs = parse_outcome_instruments(&meta).unwrap();
2204
2205        // Named outcome 7, Yes side (defs[2]).
2206        let named = create_instrument_from_def(&defs[2], UnixNanos::default()).unwrap();
2207        match named {
2208            InstrumentAny::BinaryOption(bo) => {
2209                assert_eq!(bo.id.symbol.as_str(), "7-YES-OUTCOME");
2210                let info = bo.info.expect("info should be populated for outcomes");
2211                assert_eq!(info.get_u64("named_index"), Some(0));
2212                assert_eq!(info.get_u64("question"), Some(0));
2213                assert_eq!(info.get_str("question_name"), Some("Recurring"));
2214                assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2215                assert_eq!(info.get_str("question_underlying"), Some("BTC"));
2216                assert_eq!(
2217                    info.get_str("question_price_thresholds"),
2218                    Some("79303,82540"),
2219                );
2220                assert_eq!(info.get_str("question_expiry"), Some("20260508-0600"));
2221            }
2222            other => panic!("Expected BinaryOption, was {other:?}"),
2223        }
2224
2225        // Fallback outcome 6, Yes side (defs[0]).
2226        let fallback = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2227        match fallback {
2228            InstrumentAny::BinaryOption(bo) => {
2229                assert_eq!(bo.id.symbol.as_str(), "6-YES-OUTCOME");
2230                let info = bo.info.expect("info should be populated for outcomes");
2231                assert_eq!(info.get_bool("is_fallback"), Some(true));
2232                assert_eq!(info.get_u64("question"), Some(0));
2233                assert_eq!(info.get_str("question_class"), Some("priceBucket"));
2234            }
2235            other => panic!("Expected BinaryOption, was {other:?}"),
2236        }
2237    }
2238
2239    #[rstest]
2240    fn test_parse_fill_report_outcome_round_trip() {
2241        let meta = OutcomeMeta {
2242            outcomes: vec![OutcomeMarket {
2243                outcome: 42,
2244                name: "BTC daily".to_string(),
2245                description: "BTC settles above strike at 06:00 UTC".to_string(),
2246                side_specs: vec![
2247                    OutcomeSideSpec {
2248                        name: "Yes".to_string(),
2249                    },
2250                    OutcomeSideSpec {
2251                        name: "No".to_string(),
2252                    },
2253                ],
2254            }],
2255            questions: vec![],
2256        };
2257
2258        let defs = parse_outcome_instruments(&meta).unwrap();
2259        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2260        assert_eq!(yes.id().symbol.as_str(), "42-YES-OUTCOME");
2261
2262        let fill = HyperliquidFill {
2263            coin: Ustr::from("#420"),
2264            px: dec!(0.5500),
2265            sz: dec!(1000.00),
2266            side: HyperliquidSide::Buy,
2267            time: 1_704_470_400_000,
2268            start_position: dec!(0.00),
2269            dir: HyperliquidFillDirection::OpenLong,
2270            closed_pnl: dec!(0.0),
2271            hash: "0xfeed".to_string(),
2272            oid: 99_001,
2273            crossed: true,
2274            fee: dec!(0.0),
2275            tid: 77_001,
2276            fee_token: Ustr::from("+420"),
2277            builder_fee: Some(dec!(0.0001)),
2278        };
2279
2280        let account_id = AccountId::from("HYPERLIQUID-001");
2281        let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
2282
2283        // Zero-fee outcome fills resolve commission to the instrument's quote
2284        // currency (USDH) rather than the side token, so downstream OrderFilled
2285        // events and persistence carry a registered currency.
2286        assert_eq!(report.commission.currency.code, "USDH");
2287        assert!(report.commission.as_decimal().is_zero());
2288        assert_eq!(report.order_side, OrderSide::Buy);
2289        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2290        assert_eq!(report.last_qty.as_decimal(), dec!(1000));
2291        assert_eq!(report.last_px.as_decimal(), dec!(0.55));
2292    }
2293
2294    #[rstest]
2295    fn test_deserialize_user_fills_with_dust_conversion() {
2296        // #4325 regression: a userFills batch must decode whole, not fail on one
2297        // unmodeled direction. Fixture is real mainnet wire data.
2298        let fills: Vec<HyperliquidFill> = load_test_data("http_user_fills_dust_conversion.json");
2299
2300        let dirs: Vec<HyperliquidFillDirection> = fills.iter().map(|f| f.dir).collect();
2301
2302        assert_eq!(
2303            dirs,
2304            vec![
2305                HyperliquidFillDirection::OpenLong,
2306                HyperliquidFillDirection::CloseShort,
2307                HyperliquidFillDirection::Buy,
2308                HyperliquidFillDirection::SpotDustConversion,
2309                HyperliquidFillDirection::NetChildVaults,
2310            ],
2311        );
2312    }
2313
2314    #[rstest]
2315    fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
2316        let meta = OutcomeMeta {
2317            outcomes: vec![OutcomeMarket {
2318                outcome: 88,
2319                name: "Edge".to_string(),
2320                description: String::new(),
2321                side_specs: vec![],
2322            }],
2323            questions: vec![],
2324        };
2325        let defs = parse_outcome_instruments(&meta).unwrap();
2326        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2327
2328        // Simulate another adapter path (e.g. spot balance parsing) having already
2329        // registered the side token in the global currency registry.
2330        let _ = get_currency("+880");
2331        assert!(Currency::try_from_str("+880").is_some());
2332
2333        let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
2334            .expect("zero-fee outcome side token must resolve to quote currency");
2335        assert_eq!(currency.code, "USDH");
2336
2337        let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
2338        let err_msg = err.to_string();
2339        assert!(err_msg.contains("Outcome side token '+880'"));
2340        assert!(err_msg.contains("non-zero fee"));
2341    }
2342
2343    #[rstest]
2344    #[case("+50", true)]
2345    #[case("+0", true)]
2346    #[case("+880", true)]
2347    #[case("", false)]
2348    #[case("+", false)]
2349    #[case("+abc", false)]
2350    #[case("+50a", false)]
2351    #[case("#50", false)]
2352    #[case("USDC", false)]
2353    #[case("-50", false)]
2354    fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
2355        assert_eq!(is_outcome_side_token(input), expected);
2356    }
2357
2358    #[rstest]
2359    fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
2360        let meta = OutcomeMeta {
2361            outcomes: vec![OutcomeMarket {
2362                outcome: 77,
2363                name: "Edge".to_string(),
2364                description: String::new(),
2365                side_specs: vec![],
2366            }],
2367            questions: vec![],
2368        };
2369
2370        let defs = parse_outcome_instruments(&meta).unwrap();
2371        let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
2372
2373        // Use a token that the venue would not normally emit; resolve_fee_currency must still
2374        // return the instrument's quote currency on a zero-fee fill.
2375        let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
2376            .expect("zero-fee fallback should succeed");
2377        assert_eq!(currency.code, "USDH");
2378
2379        let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
2380        assert!(err.to_string().contains("non-zero fee"));
2381    }
2382
2383    #[rstest]
2384    fn test_parse_outcome_expiry_ns_round_trip() {
2385        // 2026-05-08 06:00:00 UTC == 1778652000 seconds since epoch
2386        let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
2387        assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
2388    }
2389
2390    #[rstest]
2391    #[case("")]
2392    #[case("20260508")]
2393    #[case("20260508-")]
2394    #[case("20260508-0600 ")]
2395    #[case("2026-05-08-06-00")]
2396    #[case("20261308-0600")]
2397    fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
2398        assert!(parse_outcome_expiry_ns(input).is_none());
2399    }
2400
2401    #[rstest]
2402    fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
2403        let meta = OutcomeMeta {
2404            outcomes: vec![OutcomeMarket {
2405                outcome: 5,
2406                name: "Recurring".to_string(),
2407                description:
2408                    "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
2409                        .to_string(),
2410                side_specs: vec![
2411                    OutcomeSideSpec {
2412                        name: "Yes".to_string(),
2413                    },
2414                    OutcomeSideSpec {
2415                        name: "No".to_string(),
2416                    },
2417                ],
2418            }],
2419            questions: vec![],
2420        };
2421
2422        let defs = parse_outcome_instruments(&meta).unwrap();
2423        let yes_meta = defs[0].outcome.as_ref().unwrap();
2424        assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
2425    }
2426
2427    #[rstest]
2428    fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
2429        // outcome=7 has `index:0` description and is referenced by question 0's
2430        // `named_outcomes`. outcome=6 has `other` description and is the
2431        // `fallback_outcome`. Both should pick up the question's expiry.
2432        let meta = OutcomeMeta {
2433            outcomes: vec![
2434                OutcomeMarket {
2435                    outcome: 6,
2436                    name: "Recurring Fallback".to_string(),
2437                    description: "other".to_string(),
2438                    side_specs: vec![],
2439                },
2440                OutcomeMarket {
2441                    outcome: 7,
2442                    name: "Recurring Named Outcome".to_string(),
2443                    description: "index:0".to_string(),
2444                    side_specs: vec![],
2445                },
2446            ],
2447            questions: vec![OutcomeQuestion {
2448                question: 0,
2449                name: "Recurring".to_string(),
2450                description:
2451                    "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
2452                        .to_string(),
2453                fallback_outcome: Some(6),
2454                named_outcomes: vec![7, 8, 9],
2455                settled_named_outcomes: vec![],
2456            }],
2457        };
2458
2459        let defs = parse_outcome_instruments(&meta).unwrap();
2460        let expected_ns: u64 = 1_778_220_000_000_000_000;
2461
2462        for def in &defs {
2463            let outcome = def.outcome.as_ref().unwrap();
2464            assert_eq!(
2465                outcome.expiration_ns.as_u64(),
2466                expected_ns,
2467                "outcome {} side {} should inherit expiry",
2468                outcome.outcome_index,
2469                outcome.outcome_side,
2470            );
2471        }
2472    }
2473
2474    #[rstest]
2475    fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
2476        let meta = OutcomeMeta {
2477            outcomes: vec![],
2478            questions: vec![],
2479        };
2480        assert!(derive_outcome_settlements(&meta).is_empty());
2481    }
2482
2483    #[rstest]
2484    fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
2485        let meta = OutcomeMeta {
2486            outcomes: vec![],
2487            questions: vec![OutcomeQuestion {
2488                question: 0,
2489                name: "Recurring".to_string(),
2490                description: "class:priceBucket|expiry:20260508-0600".to_string(),
2491                fallback_outcome: Some(6),
2492                named_outcomes: vec![7, 8, 9],
2493                settled_named_outcomes: vec![],
2494            }],
2495        };
2496
2497        assert!(derive_outcome_settlements(&meta).is_empty());
2498    }
2499
2500    #[rstest]
2501    fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
2502        let meta = OutcomeMeta {
2503            outcomes: vec![],
2504            questions: vec![OutcomeQuestion {
2505                question: 0,
2506                name: "Recurring".to_string(),
2507                description: "class:priceBucket|expiry:20260508-0600".to_string(),
2508                fallback_outcome: Some(6),
2509                named_outcomes: vec![7, 8, 9],
2510                settled_named_outcomes: vec![8],
2511            }],
2512        };
2513
2514        let settlements = derive_outcome_settlements(&meta);
2515        let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
2516            .into_iter()
2517            .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
2518            .collect();
2519
2520        // Winning named outcome 8: Yes -> 1, No -> 0
2521        assert_eq!(lookup[&(8, 0)], 1);
2522        assert_eq!(lookup[&(8, 1)], 0);
2523
2524        // Losing named outcomes 7, 9 and fallback 6: Yes -> 0, No -> 1
2525        for losing in [7, 9, 6] {
2526            assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
2527            assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
2528        }
2529
2530        assert_eq!(lookup.len(), 8);
2531    }
2532
2533    #[rstest]
2534    fn test_parse_outcome_meta_question_settlement_round_trip() {
2535        let json = r#"{
2536            "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
2537            "questions": [{
2538                "question": 0,
2539                "name": "Recurring",
2540                "description": "class:priceBucket|expiry:20260508-0600",
2541                "fallbackOutcome": 6,
2542                "namedOutcomes": [7, 8, 9],
2543                "settledNamedOutcomes": [8]
2544            }]
2545        }"#;
2546
2547        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
2548        assert_eq!(meta.questions.len(), 1);
2549        let q = &meta.questions[0];
2550        assert_eq!(q.fallback_outcome, Some(6));
2551        assert_eq!(q.named_outcomes, vec![7, 8, 9]);
2552        assert_eq!(q.settled_named_outcomes, vec![8]);
2553
2554        assert!(meta.parent_question(7).is_some());
2555        assert!(meta.parent_question(6).is_some());
2556        assert!(meta.parent_question(99).is_none());
2557    }
2558}