Skip to main content

nautilus_hyperliquid/http/
parse.rs

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