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