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 nautilus_core::{UUID4, UnixNanos};
18use nautilus_model::{
19    enums::{
20        AssetClass, CurrencyType, LiquiditySide, OrderSide, OrderStatus, OrderType,
21        PositionSideSpecified, TimeInForce, TriggerType,
22    },
23    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
24    instruments::{BinaryOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
25    reports::{FillReport, OrderStatusReport, PositionStatusReport},
26    types::{Currency, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::models::{
33    AssetPosition, HyperliquidFill, OutcomeMarket, OutcomeMeta, PerpMeta, SpotBalance, SpotMeta,
34};
35use crate::{
36    common::{
37        consts::HYPERLIQUID_VENUE,
38        enums::{
39            HyperliquidFillDirection, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
40            HyperliquidSide, HyperliquidTpSl,
41        },
42        parse::make_fill_trade_id,
43        types::HyperliquidAssetId,
44    },
45    websocket::messages::{WsBasicOrderData, WsOrderData},
46};
47
48/// Market type enumeration for normalized instrument definitions.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum HyperliquidMarketType {
51    /// Perpetual futures contract.
52    Perp,
53    /// Spot trading pair.
54    Spot,
55    /// HIP-4 binary outcome side token.
56    Outcome,
57}
58
59/// Outcome-specific metadata carried on [`HyperliquidInstrumentDef`] for HIP-4
60/// binary outcome side tokens.
61///
62/// The venue's `outcomeMeta` payload is partial today (no precision or
63/// expiry fields), so unknown values are left as defaults until real venue
64/// payloads are available.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct HyperliquidOutcomeMetadata {
67    /// HIP-4 outcome index (`outcome` field from `outcomeMeta`).
68    pub outcome_index: u32,
69    /// Side digit (`0` or `1`).
70    pub outcome_side: u8,
71    /// Outcome market name (for example, "BTC daily").
72    pub market_name: Ustr,
73    /// Side specification name (for example, "Yes" or "No"); `None` when the
74    /// venue payload omits side specs.
75    pub side_name: Option<Ustr>,
76    /// Venue-supplied description.
77    pub description: Option<Ustr>,
78    /// Activation timestamp; `0` when the venue payload does not expose it.
79    pub activation_ns: UnixNanos,
80    /// Expiration timestamp; `0` when the venue payload does not expose it.
81    pub expiration_ns: UnixNanos,
82}
83
84/// Normalized instrument definition produced by this parser.
85///
86/// This deliberately avoids any tight coupling to Nautilus' Cython types.
87/// The InstrumentProvider can later convert this into Nautilus `Instrument`s.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct HyperliquidInstrumentDef {
90    /// Human-readable symbol (e.g., "BTC-USD-PERP", "PURR-USDC-SPOT").
91    pub symbol: Ustr,
92    /// Raw symbol used in Hyperliquid WebSocket subscriptions/messages.
93    /// For perps: base currency (e.g., "BTC").
94    /// For spot: `@{pair_index}` format (e.g., "@107" for HYPE-USDC).
95    /// For outcomes: `#<encoding>` spot-coin form (e.g., "#10").
96    pub raw_symbol: Ustr,
97    /// Base currency/asset (e.g., "BTC", "PURR").
98    pub base: Ustr,
99    /// Quote currency (e.g., "USD" for perps, "USDC" for spot).
100    pub quote: Ustr,
101    /// Market type (perpetual, spot, or outcome).
102    pub market_type: HyperliquidMarketType,
103    /// Asset index used for order submission.
104    /// For perps: index in meta.universe (0, 1, 2, ...).
105    /// For spot: 10000 + index in spotMeta.universe.
106    /// For outcomes: `100_000_000 + 10 * outcome + side`.
107    pub asset_index: u32,
108    /// Number of decimal places for price precision.
109    pub price_decimals: u32,
110    /// Number of decimal places for size precision.
111    pub size_decimals: u32,
112    /// Price tick size as decimal.
113    pub tick_size: Decimal,
114    /// Size lot increment as decimal.
115    pub lot_size: Decimal,
116    /// Maximum leverage (for perps).
117    pub max_leverage: Option<u32>,
118    /// Whether requires isolated margin only.
119    pub only_isolated: bool,
120    /// Whether this is a HIP-3 builder-deployed perpetual.
121    pub is_hip3: bool,
122    /// Whether the instrument is active/tradeable.
123    pub active: bool,
124    /// Outcome-specific metadata when [`market_type`](Self::market_type) is
125    /// [`HyperliquidMarketType::Outcome`].
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub outcome: Option<HyperliquidOutcomeMetadata>,
128    /// Raw upstream data for debugging.
129    pub raw_data: String,
130}
131
132// Replace wildcard bytes (`*`, `?`) in a venue-supplied symbol component with
133// `x` so the value is safe to embed in a Nautilus `InstrumentId`. HIP-3
134// perpetual names from Hyperliquid (e.g. `dex:STREAMABCD****-USD-PERP`)
135// collide with msgbus pattern syntax; the venue-official name is preserved on
136// `raw_symbol` for HTTP/WS wire calls, and orders use the numeric
137// `asset_index` so they do not see the substitution.
138#[must_use]
139fn sanitize_symbol(value: &str) -> std::borrow::Cow<'_, str> {
140    if value.bytes().any(|b| b == b'*' || b == b'?') {
141        let mut out = String::with_capacity(value.len());
142        for ch in value.chars() {
143            out.push(if ch == '*' || ch == '?' { 'x' } else { ch });
144        }
145        std::borrow::Cow::Owned(out)
146    } else {
147        std::borrow::Cow::Borrowed(value)
148    }
149}
150
151/// Parse perpetual instrument definitions from Hyperliquid `meta` response.
152///
153/// Hyperliquid perps follow specific rules:
154/// - Quote is always USD (USDC settled)
155/// - Price decimals = max(0, 6 - sz_decimals) per venue docs
156/// - Active = !is_delisted
157///
158/// `asset_index_base` controls the starting offset for asset IDs:
159/// - Standard perps (dex 0): base = 0
160/// - HIP-3 dexes: base = 100_000 + dex_index * 10_000
161///
162/// Delisted instruments are included but marked as inactive to support
163/// parsing historical data for instruments that may still have trading history.
164pub fn parse_perp_instruments(
165    meta: &PerpMeta,
166    asset_index_base: u32,
167) -> Result<Vec<HyperliquidInstrumentDef>, String> {
168    const PERP_MAX_DECIMALS: i32 = 6;
169
170    let mut defs = Vec::new();
171
172    for (index, asset) in meta.universe.iter().enumerate() {
173        let is_delisted = asset.is_delisted.unwrap_or(false);
174
175        let price_decimals = (PERP_MAX_DECIMALS - asset.sz_decimals as i32).max(0) as u32;
176        let tick_size = pow10_neg(price_decimals);
177        let lot_size = pow10_neg(asset.sz_decimals);
178
179        let symbol = format!("{}-USD-PERP", sanitize_symbol(&asset.name));
180
181        let raw_symbol: Ustr = asset.name.as_str().into();
182
183        let def = HyperliquidInstrumentDef {
184            symbol: symbol.into(),
185            raw_symbol,
186            base: asset.name.clone().into(),
187            quote: "USD".into(),
188            market_type: HyperliquidMarketType::Perp,
189            asset_index: asset_index_base + index as u32,
190            price_decimals,
191            size_decimals: asset.sz_decimals,
192            tick_size,
193            lot_size,
194            max_leverage: asset.max_leverage,
195            only_isolated: asset.only_isolated.unwrap_or(false),
196            is_hip3: asset_index_base > 0,
197            active: !is_delisted,
198            outcome: None,
199            raw_data: serde_json::to_string(asset).unwrap_or_default(),
200        };
201
202        defs.push(def);
203    }
204
205    Ok(defs)
206}
207
208/// Parse spot instrument definitions from Hyperliquid `spotMeta` response.
209///
210/// Hyperliquid spot follows these rules:
211/// - Price decimals = max(0, 8 - base_sz_decimals) per venue docs
212/// - Size decimals from base token
213/// - All pairs are loaded (including non-canonical) to support parsing fills/positions
214///   for instruments that may have been traded
215pub fn parse_spot_instruments(meta: &SpotMeta) -> Result<Vec<HyperliquidInstrumentDef>, String> {
216    const SPOT_MAX_DECIMALS: i32 = 8; // Hyperliquid spot price decimal limit
217    const SPOT_INDEX_OFFSET: u32 = 10000; // Spot assets use 10000 + index
218
219    let mut defs = Vec::new();
220
221    // Build index -> token lookup
222    let mut tokens_by_index = ahash::AHashMap::new();
223    for token in &meta.tokens {
224        tokens_by_index.insert(token.index, token);
225    }
226
227    for pair in &meta.universe {
228        // Load all pairs (including non-canonical) to support parsing fills/positions
229        // for instruments that may have been traded but are not currently canonical
230
231        let base_token = tokens_by_index
232            .get(&pair.tokens[0])
233            .ok_or_else(|| format!("Base token index {} not found", pair.tokens[0]))?;
234        let quote_token = tokens_by_index
235            .get(&pair.tokens[1])
236            .ok_or_else(|| format!("Quote token index {} not found", pair.tokens[1]))?;
237
238        let price_decimals = (SPOT_MAX_DECIMALS - base_token.sz_decimals as i32).max(0) as u32;
239        let tick_size = pow10_neg(price_decimals);
240        let lot_size = pow10_neg(base_token.sz_decimals);
241
242        let symbol = format!(
243            "{}-{}-SPOT",
244            sanitize_symbol(&base_token.name),
245            sanitize_symbol(&quote_token.name),
246        );
247
248        // Hyperliquid spot raw_symbol formats (per API docs):
249        // - PURR uses slash format from pair.name (e.g., "PURR/USDC")
250        // - All others use "@{pair_index}" format (e.g., "@107" for HYPE)
251        let raw_symbol: Ustr = if base_token.name == "PURR" {
252            pair.name.as_str().into()
253        } else {
254            format!("@{}", pair.index).into()
255        };
256
257        let def = HyperliquidInstrumentDef {
258            symbol: symbol.into(),
259            raw_symbol,
260            base: base_token.name.clone().into(),
261            quote: quote_token.name.clone().into(),
262            market_type: HyperliquidMarketType::Spot,
263            asset_index: SPOT_INDEX_OFFSET + pair.index,
264            price_decimals,
265            size_decimals: base_token.sz_decimals,
266            tick_size,
267            lot_size,
268            max_leverage: None,
269            only_isolated: false,
270            is_hip3: false,
271            active: pair.is_canonical, // Use canonical status to indicate if pair is actively tradeable
272            outcome: None,
273            raw_data: serde_json::to_string(pair).unwrap_or_default(),
274        };
275
276        defs.push(def);
277    }
278
279    // Canonical pairs must be cached first so the base-token alias (e.g.
280    // "PURR" -> PURR-USDC-SPOT) resolves to the canonical instrument when
281    // non-canonical pairs share the same base. Secondary key keeps the
282    // order stable within each bucket.
283    defs.sort_by(|a, b| {
284        b.active
285            .cmp(&a.active)
286            .then(a.asset_index.cmp(&b.asset_index))
287    });
288
289    Ok(defs)
290}
291
292// Default precision for HIP-4 outcome side tokens until the venue exposes
293// per-market values via `outcomeMeta`. Outcomes settle in `[0, 1]` so 4
294// decimals of price granularity (tick `0.0001`) and 2 decimals of size
295// granularity (lot `0.01`) are conservative starting values; refine when
296// real venue payloads land.
297pub const OUTCOME_PRICE_DECIMALS: u32 = 4;
298pub const OUTCOME_SIZE_DECIMALS: u32 = 2;
299
300/// Parse outcome instrument definitions from Hyperliquid `outcomeMeta` response.
301///
302/// Each [`OutcomeMarket`] yields two definitions, one per side (`0` and `1`),
303/// modeled as binary outcome side tokens. The Nautilus internal symbol uses
304/// the venue's token form (`+<encoding>`), and the wire `raw_symbol` uses the
305/// spot-coin form (`#<encoding>`) which is what `l2Book`, `trades`, and `bbo`
306/// subscriptions accept.
307///
308/// Expiry is read from the market's own description when it carries
309/// `class:priceBinary`; for outcomes that point at a parent question (`other`
310/// or `index:N`), the expiry is inherited from that question's description.
311///
312/// `side_name` is left unset on the resulting metadata when the venue payload
313/// does not supply `sideSpecs`.
314pub fn parse_outcome_instruments(
315    meta: &OutcomeMeta,
316) -> Result<Vec<HyperliquidInstrumentDef>, String> {
317    let mut defs = Vec::with_capacity(meta.outcomes.len() * 2);
318
319    for market in &meta.outcomes {
320        for side in 0u8..=1u8 {
321            defs.push(build_outcome_def(market, side, meta)?);
322        }
323    }
324
325    Ok(defs)
326}
327
328fn build_outcome_def(
329    market: &OutcomeMarket,
330    side: u8,
331    meta: &OutcomeMeta,
332) -> Result<HyperliquidInstrumentDef, String> {
333    let outcome = market.outcome;
334    let asset_id = HyperliquidAssetId::outcome(outcome, side);
335    let encoding = asset_id
336        .outcome_encoding()
337        .ok_or_else(|| format!("Invalid outcome encoding for outcome={outcome} side={side}"))?;
338
339    let token = format!("+{encoding}");
340    let coin = format!("#{encoding}");
341
342    let side_name = market
343        .side_specs
344        .get(usize::from(side))
345        .map(|spec| Ustr::from(spec.name.as_str()));
346
347    let description = if market.description.is_empty() {
348        None
349    } else {
350        Some(Ustr::from(market.description.as_str()))
351    };
352
353    let expiration_ns = resolve_outcome_expiration_ns(market, meta);
354
355    let outcome = HyperliquidOutcomeMetadata {
356        outcome_index: market.outcome,
357        outcome_side: side,
358        market_name: Ustr::from(market.name.as_str()),
359        side_name,
360        description,
361        activation_ns: UnixNanos::default(),
362        expiration_ns,
363    };
364
365    Ok(HyperliquidInstrumentDef {
366        symbol: Ustr::from(token.as_str()),
367        raw_symbol: Ustr::from(coin.as_str()),
368        base: Ustr::from(token.as_str()),
369        quote: "USDH".into(),
370        market_type: HyperliquidMarketType::Outcome,
371        asset_index: asset_id.to_raw(),
372        price_decimals: OUTCOME_PRICE_DECIMALS,
373        size_decimals: OUTCOME_SIZE_DECIMALS,
374        tick_size: pow10_neg(OUTCOME_PRICE_DECIMALS),
375        lot_size: pow10_neg(OUTCOME_SIZE_DECIMALS),
376        max_leverage: None,
377        only_isolated: false,
378        is_hip3: false,
379        active: true,
380        outcome: Some(outcome),
381        raw_data: serde_json::to_string(market).unwrap_or_default(),
382    })
383}
384
385fn pow10_neg(decimals: u32) -> Decimal {
386    if decimals == 0 {
387        return Decimal::ONE;
388    }
389
390    // Build 1 / 10^decimals using integer arithmetic
391    Decimal::from_i128_with_scale(1, decimals)
392}
393
394// Direct binary outcomes carry `expiry:` in their own description. Named
395// outcomes (`index:N`) and the `other` fallback inherit expiry from the
396// parent question. Returns zero when no expiry can be located.
397fn resolve_outcome_expiration_ns(market: &OutcomeMarket, meta: &OutcomeMeta) -> UnixNanos {
398    if let Some(ns) = parse_expiry_from_description(&market.description) {
399        return ns;
400    }
401
402    meta.parent_question(market.outcome)
403        .and_then(|q| parse_expiry_from_description(&q.description))
404        .unwrap_or_default()
405}
406
407fn parse_expiry_from_description(description: &str) -> Option<UnixNanos> {
408    description
409        .split('|')
410        .filter_map(|piece| piece.split_once(':'))
411        .find_map(|(key, value)| (key == "expiry").then_some(value))
412        .and_then(parse_outcome_expiry_ns)
413}
414
415// Parses a Hyperliquid outcome expiry stamp `YYYYMMDD-HHMM` (UTC) to UnixNanos.
416fn parse_outcome_expiry_ns(s: &str) -> Option<UnixNanos> {
417    let (date_part, time_part) = s.split_once('-')?;
418    if date_part.len() != 8 || time_part.len() != 4 {
419        return None;
420    }
421
422    let year: i32 = date_part[0..4].parse().ok()?;
423    let month: u32 = date_part[4..6].parse().ok()?;
424    let day: u32 = date_part[6..8].parse().ok()?;
425    let hour: u32 = time_part[0..2].parse().ok()?;
426    let minute: u32 = time_part[2..4].parse().ok()?;
427
428    let datetime = chrono::NaiveDate::from_ymd_opt(year, month, day)?
429        .and_hms_opt(hour, minute, 0)?
430        .and_utc();
431    let nanos = datetime.timestamp_nanos_opt()?;
432    u64::try_from(nanos).ok().map(UnixNanos::from)
433}
434
435/// Settlement state for a single HIP-4 outcome side token.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct OutcomeSettlement {
438    /// Outcome index from `outcomeMeta`.
439    pub outcome_index: u32,
440    /// Side token (`0` or `1`).
441    pub outcome_side: u8,
442    /// Final settlement value: `1` for the winning side, `0` for losing sides.
443    pub final_value: u8,
444}
445
446/// Derives per-side settlement values from an `outcomeMeta` snapshot.
447///
448/// Returns one [`OutcomeSettlement`] for every side of every outcome whose
449/// resolution can be inferred from the snapshot:
450///
451/// - For each question with non-empty `settled_named_outcomes`, every named
452///   outcome and the fallback are emitted: the winning named outcomes get
453///   `Yes -> 1, No -> 0`, every other named outcome and the fallback get
454///   `Yes -> 0, No -> 1`.
455/// - Standalone outcomes (not referenced by any question) are skipped because
456///   the venue does not expose their resolution in `outcomeMeta`. They will
457///   need a separate signal (status flag, fill, or position-state event).
458///
459/// Outcomes referenced by a question that has not yet settled are also
460/// skipped. This lets a caller poll `outcomeMeta` and emit settlement events
461/// when entries first appear in the result.
462#[must_use]
463pub fn derive_outcome_settlements(meta: &OutcomeMeta) -> Vec<OutcomeSettlement> {
464    let mut settlements = Vec::new();
465
466    for question in &meta.questions {
467        if question.settled_named_outcomes.is_empty() {
468            continue;
469        }
470
471        let losing_sides_won = |outcome_index: u32| -> [OutcomeSettlement; 2] {
472            // Named outcome did not win; Yes side -> 0, No side -> 1.
473            [
474                OutcomeSettlement {
475                    outcome_index,
476                    outcome_side: 0,
477                    final_value: 0,
478                },
479                OutcomeSettlement {
480                    outcome_index,
481                    outcome_side: 1,
482                    final_value: 1,
483                },
484            ]
485        };
486
487        let winning_sides = |outcome_index: u32| -> [OutcomeSettlement; 2] {
488            // Named outcome won; Yes side -> 1, No side -> 0.
489            [
490                OutcomeSettlement {
491                    outcome_index,
492                    outcome_side: 0,
493                    final_value: 1,
494                },
495                OutcomeSettlement {
496                    outcome_index,
497                    outcome_side: 1,
498                    final_value: 0,
499                },
500            ]
501        };
502
503        for outcome_index in &question.named_outcomes {
504            if question.settled_named_outcomes.contains(outcome_index) {
505                settlements.extend(winning_sides(*outcome_index));
506            } else {
507                settlements.extend(losing_sides_won(*outcome_index));
508            }
509        }
510
511        // The fallback is the "no named outcome resolved" branch; it loses
512        // whenever any named outcome won.
513        if let Some(fallback) = question.fallback_outcome {
514            settlements.extend(losing_sides_won(fallback));
515        }
516    }
517
518    settlements
519}
520
521pub fn get_currency(code: &str) -> Currency {
522    Currency::try_from_str(code).unwrap_or_else(|| {
523        let currency = Currency::new(code, 8, 0, code, CurrencyType::Crypto);
524        if let Err(e) = Currency::register(currency, false) {
525            log::error!("Failed to register currency '{code}': {e}");
526        }
527        currency
528    })
529}
530
531/// Returns the HIP-4 outcome settlement currency, registering it on first call.
532///
533/// Outcome markets settle in USDH (token index 360 on the `USDH/USDC` spot pair
534/// `@230`), not USDC. The registration is explicit so the precision is
535/// deterministic rather than dependent on whichever caller first triggers
536/// `get_currency`'s auto-register path.
537pub fn get_usdh_currency() -> Currency {
538    Currency::try_from_str("USDH").unwrap_or_else(|| {
539        let currency = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
540        if let Err(e) = Currency::register(currency, false) {
541            log::error!("Failed to register USDH currency: {e}");
542        }
543        currency
544    })
545}
546
547/// Resolves the commission currency for a fill given the venue's `feeToken` field.
548///
549/// HIP-4 outcome fills echo the side token (e.g. `+50`) as `feeToken` even when
550/// the fee is zero. The side token is not a Nautilus currency and emitting it as
551/// the commission currency would leak into `OrderFilled` events and persistence;
552/// for outcome side tokens the instrument's quote currency is always used, even
553/// when another adapter path (such as spot-balance parsing) has registered the
554/// side token in the global registry. Non-zero side-token fees error: the venue
555/// does not denominate fees in side tokens. Other unknown tokens fall back to
556/// the instrument's quote currency only when the fee is zero.
557///
558/// # Errors
559///
560/// Returns an error when an outcome side token carries a non-zero fee, or when
561/// `fee_token` cannot be resolved and `fee_amount` is non-zero.
562pub fn resolve_fee_currency(
563    fee_token: &str,
564    fee_amount: Decimal,
565    instrument: &dyn Instrument,
566) -> anyhow::Result<Currency> {
567    if is_outcome_side_token(fee_token) {
568        if !fee_amount.is_zero() {
569            anyhow::bail!(
570                "Outcome side token '{fee_token}' carried a non-zero fee {fee_amount}; \
571                 venue does not denominate fees in side tokens",
572            );
573        }
574        return Ok(instrument.quote_currency());
575    }
576
577    if let Some(currency) = Currency::try_from_str(fee_token) {
578        return Ok(currency);
579    }
580
581    if fee_amount.is_zero() {
582        let fallback = instrument.quote_currency();
583        log::debug!(
584            "Unregistered fee token '{fee_token}' on zero-fee fill for {}; using {fallback} as fallback",
585            instrument.id(),
586        );
587        return Ok(fallback);
588    }
589
590    anyhow::bail!("Unknown fee token '{fee_token}' with non-zero fee {fee_amount}")
591}
592
593fn is_outcome_side_token(symbol: &str) -> bool {
594    let Some(rest) = symbol.strip_prefix('+') else {
595        return false;
596    };
597    !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
598}
599
600/// Converts a single Hyperliquid instrument definition into a Nautilus `InstrumentAny`.
601///
602/// Returns `None` if the conversion fails (e.g., unsupported market type).
603#[must_use]
604pub fn create_instrument_from_def(
605    def: &HyperliquidInstrumentDef,
606    ts_init: UnixNanos,
607) -> Option<InstrumentAny> {
608    let symbol = Symbol::new(def.symbol);
609    let venue = *HYPERLIQUID_VENUE;
610    let instrument_id = InstrumentId::new(symbol, venue);
611
612    // Use the raw_symbol from the definition which is format-specific:
613    // - Perps: base currency (e.g., "BTC")
614    // - Spot PURR: slash format (e.g., "PURR/USDC")
615    // - Spot others: @{index} format (e.g., "@107")
616    let raw_symbol = Symbol::new(def.raw_symbol);
617    let price_increment = Price::from(def.tick_size.to_string());
618    let size_increment = Quantity::from(def.lot_size.to_string());
619
620    match def.market_type {
621        HyperliquidMarketType::Spot => {
622            let base_currency = get_currency(&def.base);
623            let quote_currency = get_currency(&def.quote);
624
625            Some(InstrumentAny::CurrencyPair(CurrencyPair::new(
626                instrument_id,
627                raw_symbol,
628                base_currency,
629                quote_currency,
630                def.price_decimals as u8,
631                def.size_decimals as u8,
632                price_increment,
633                size_increment,
634                None,
635                None,
636                None,
637                None,
638                None,
639                None,
640                None,
641                None,
642                None,
643                None,
644                None,
645                None,
646                None,
647                ts_init, // Identical to ts_init for now
648                ts_init,
649            )))
650        }
651        HyperliquidMarketType::Perp => {
652            let base_currency = get_currency(&def.base);
653            let quote_currency = get_currency(&def.quote);
654            let settlement_currency = get_currency("USDC");
655
656            Some(InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
657                instrument_id,
658                raw_symbol,
659                base_currency,
660                quote_currency,
661                settlement_currency,
662                false,
663                def.price_decimals as u8,
664                def.size_decimals as u8,
665                price_increment,
666                size_increment,
667                None, // multiplier
668                None,
669                None,
670                None,
671                None,
672                None,
673                None,
674                None,
675                None,
676                None,
677                None,
678                None,
679                None,
680                ts_init, // Identical to ts_init for now
681                ts_init,
682            )))
683        }
684        HyperliquidMarketType::Outcome => {
685            let outcome = def.outcome.as_ref()?;
686            let currency = get_usdh_currency();
687
688            Some(InstrumentAny::BinaryOption(BinaryOption::new(
689                instrument_id,
690                raw_symbol,
691                AssetClass::Alternative,
692                currency,
693                outcome.activation_ns,
694                outcome.expiration_ns,
695                def.price_decimals as u8,
696                def.size_decimals as u8,
697                price_increment,
698                size_increment,
699                outcome.side_name,
700                outcome.description,
701                None, // max_quantity
702                None, // min_quantity
703                None, // max_notional
704                None, // min_notional
705                None, // max_price
706                None, // min_price
707                None, // margin_init
708                None, // margin_maint
709                None, // maker_fee
710                None, // taker_fee
711                None, // info
712                ts_init,
713                ts_init,
714            )))
715        }
716    }
717}
718
719/// Convert a collection of Hyperliquid instrument definitions into Nautilus instruments,
720/// discarding any definitions that fail to convert.
721#[must_use]
722pub fn instruments_from_defs(
723    defs: &[HyperliquidInstrumentDef],
724    ts_init: UnixNanos,
725) -> Vec<InstrumentAny> {
726    defs.iter()
727        .filter_map(|def| create_instrument_from_def(def, ts_init))
728        .collect()
729}
730
731/// Convert owned definitions into Nautilus instruments, consuming the input vector.
732#[must_use]
733pub fn instruments_from_defs_owned(
734    defs: Vec<HyperliquidInstrumentDef>,
735    ts_init: UnixNanos,
736) -> Vec<InstrumentAny> {
737    defs.into_iter()
738        .filter_map(|def| create_instrument_from_def(&def, ts_init))
739        .collect()
740}
741
742fn parse_fill_side(side: &HyperliquidSide) -> OrderSide {
743    match side {
744        HyperliquidSide::Buy => OrderSide::Buy,
745        HyperliquidSide::Sell => OrderSide::Sell,
746    }
747}
748
749/// Parse WebSocket order data to OrderStatusReport.
750///
751/// # Errors
752///
753/// Returns an error if required fields are missing or invalid.
754pub fn parse_order_status_report_from_ws(
755    order_data: &WsOrderData,
756    instrument: &dyn Instrument,
757    account_id: AccountId,
758    ts_init: UnixNanos,
759) -> anyhow::Result<OrderStatusReport> {
760    parse_order_status_report_from_basic(
761        &order_data.order,
762        &order_data.status,
763        instrument,
764        account_id,
765        ts_init,
766    )
767}
768
769/// Parse basic order data to OrderStatusReport.
770///
771/// # Errors
772///
773/// Returns an error if required fields are missing or invalid.
774pub fn parse_order_status_report_from_basic(
775    order: &WsBasicOrderData,
776    status: &HyperliquidOrderStatusEnum,
777    instrument: &dyn Instrument,
778    account_id: AccountId,
779    ts_init: UnixNanos,
780) -> anyhow::Result<OrderStatusReport> {
781    let instrument_id = instrument.id();
782    let venue_order_id = VenueOrderId::new(order.oid.to_string());
783    let order_side = OrderSide::from(order.side);
784
785    // Determine order type based on trigger parameters
786    let order_type = if order.trigger_px.is_some() {
787        if order.is_market == Some(true) {
788            // Check if it's stop-loss or take-profit based on tpsl field
789            match order.tpsl.as_ref() {
790                Some(HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
791                Some(HyperliquidTpSl::Sl) => OrderType::StopMarket,
792                _ => OrderType::StopMarket,
793            }
794        } else {
795            match order.tpsl.as_ref() {
796                Some(HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
797                Some(HyperliquidTpSl::Sl) => OrderType::StopLimit,
798                _ => OrderType::StopLimit,
799            }
800        }
801    } else {
802        OrderType::Limit
803    };
804
805    let time_in_force = TimeInForce::Gtc;
806    let order_status = OrderStatus::from(*status);
807
808    let price_precision = instrument.price_precision();
809    let size_precision = instrument.size_precision();
810
811    let orig_sz: Decimal = order
812        .orig_sz
813        .parse()
814        .map_err(|e| anyhow::anyhow!("Failed to parse orig_sz: {e}"))?;
815    let current_sz: Decimal = order
816        .sz
817        .parse()
818        .map_err(|e| anyhow::anyhow!("Failed to parse sz: {e}"))?;
819
820    let quantity = Quantity::from_decimal_dp(orig_sz.abs(), size_precision)
821        .map_err(|e| anyhow::anyhow!("Failed to create quantity from orig_sz: {e}"))?;
822    let filled_sz = orig_sz.abs() - current_sz.abs();
823    let filled_qty = Quantity::from_decimal_dp(filled_sz, size_precision)
824        .map_err(|e| anyhow::anyhow!("Failed to create quantity from filled_sz: {e}"))?;
825
826    let ts_accepted = UnixNanos::from(order.timestamp * 1_000_000);
827    let ts_last = ts_accepted;
828    let report_id = UUID4::new();
829
830    let mut report = OrderStatusReport::new(
831        account_id,
832        instrument_id,
833        None, // client_order_id - will be set if present
834        venue_order_id,
835        order_side,
836        order_type,
837        time_in_force,
838        order_status,
839        quantity,
840        filled_qty,
841        ts_accepted,
842        ts_last,
843        ts_init,
844        Some(report_id),
845    );
846
847    // Add client order ID if present
848    if let Some(cloid) = &order.cloid {
849        report = report.with_client_order_id(ClientOrderId::new(cloid.as_str()));
850    }
851
852    // Only set price for non-filled orders. For filled orders, the limit price is not
853    // the execution price, and setting it would cause bogus inferred fills to be created
854    // during reconciliation. Real fills arrive via the userEvents WebSocket channel.
855    if !matches!(
856        order_status,
857        OrderStatus::Filled | OrderStatus::PartiallyFilled
858    ) {
859        let limit_px: Decimal = order
860            .limit_px
861            .parse()
862            .map_err(|e| anyhow::anyhow!("Failed to parse limit_px: {e}"))?;
863        let price = Price::from_decimal_dp(limit_px, price_precision)
864            .map_err(|e| anyhow::anyhow!("Failed to create price from limit_px: {e}"))?;
865        report = report.with_price(price);
866    }
867
868    // Add trigger price if present
869    if let Some(trigger_px) = &order.trigger_px {
870        let trig_px: Decimal = trigger_px
871            .parse()
872            .map_err(|e| anyhow::anyhow!("Failed to parse trigger_px: {e}"))?;
873        let trigger_price = Price::from_decimal_dp(trig_px, price_precision)
874            .map_err(|e| anyhow::anyhow!("Failed to create trigger price: {e}"))?;
875        report = report
876            .with_trigger_price(trigger_price)
877            .with_trigger_type(TriggerType::Default);
878    }
879
880    Ok(report)
881}
882
883/// Parse Hyperliquid fill to FillReport.
884///
885/// # Errors
886///
887/// Returns an error if required fields are missing or invalid.
888pub fn parse_fill_report(
889    fill: &HyperliquidFill,
890    instrument: &dyn Instrument,
891    account_id: AccountId,
892    ts_init: UnixNanos,
893) -> anyhow::Result<FillReport> {
894    let instrument_id = instrument.id();
895    let venue_order_id = VenueOrderId::new(fill.oid.to_string());
896
897    if matches!(fill.dir, HyperliquidFillDirection::AutoDeleveraging) {
898        log::warn!(
899            "Auto-deleveraging fill: {instrument_id} oid={} px={} sz={}",
900            fill.oid,
901            fill.px,
902            fill.sz,
903        );
904    }
905
906    let trade_id = make_fill_trade_id(
907        &fill.hash,
908        fill.oid,
909        &fill.px,
910        &fill.sz,
911        fill.time,
912        &fill.start_position,
913    );
914    let order_side = parse_fill_side(&fill.side);
915
916    let price_precision = instrument.price_precision();
917    let size_precision = instrument.size_precision();
918
919    let px: Decimal = fill
920        .px
921        .parse()
922        .map_err(|e| anyhow::anyhow!("Failed to parse fill price: {e}"))?;
923    let sz: Decimal = fill
924        .sz
925        .parse()
926        .map_err(|e| anyhow::anyhow!("Failed to parse fill size: {e}"))?;
927
928    let last_px = Price::from_decimal_dp(px, price_precision)
929        .map_err(|e| anyhow::anyhow!("Failed to create price from fill px: {e}"))?;
930    let last_qty = Quantity::from_decimal_dp(sz.abs(), size_precision)
931        .map_err(|e| anyhow::anyhow!("Failed to create quantity from fill sz: {e}"))?;
932
933    let fee_amount: Decimal = fill
934        .fee
935        .parse()
936        .map_err(|e| anyhow::anyhow!("Failed to parse fee: {e}"))?;
937
938    let fee_currency = resolve_fee_currency(fill.fee_token.as_str(), fee_amount, instrument)?;
939    let commission = Money::from_decimal(fee_amount, fee_currency)
940        .map_err(|e| anyhow::anyhow!("Failed to create commission from fee: {e}"))?;
941
942    // Determine liquidity side based on 'crossed' flag
943    let liquidity_side = if fill.crossed {
944        LiquiditySide::Taker
945    } else {
946        LiquiditySide::Maker
947    };
948
949    let ts_event = UnixNanos::from(fill.time * 1_000_000);
950    let report_id = UUID4::new();
951
952    let report = FillReport::new(
953        account_id,
954        instrument_id,
955        venue_order_id,
956        trade_id,
957        order_side,
958        last_qty,
959        last_px,
960        commission,
961        liquidity_side,
962        None, // client_order_id - to be linked by execution engine
963        None, // venue_position_id
964        ts_event,
965        ts_init,
966        Some(report_id),
967    );
968
969    Ok(report)
970}
971
972/// Parse position data from clearinghouse state to PositionStatusReport.
973///
974/// # Errors
975///
976/// Returns an error if required fields are missing or invalid.
977pub fn parse_position_status_report(
978    position_data: &serde_json::Value,
979    instrument: &dyn Instrument,
980    account_id: AccountId,
981    ts_init: UnixNanos,
982) -> anyhow::Result<PositionStatusReport> {
983    // Deserialize the position data
984    let asset_position: AssetPosition = serde_json::from_value(position_data.clone())
985        .context("failed to deserialize AssetPosition")?;
986
987    let position = &asset_position.position;
988    let instrument_id = instrument.id();
989
990    // Determine position side based on size (szi)
991    let (position_side, quantity_value) = if position.szi.is_zero() {
992        (PositionSideSpecified::Flat, Decimal::ZERO)
993    } else if position.szi.is_sign_positive() {
994        (PositionSideSpecified::Long, position.szi)
995    } else {
996        (PositionSideSpecified::Short, position.szi.abs())
997    };
998
999    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1000        .context("failed to create quantity from decimal")?;
1001    let report_id = UUID4::new();
1002    let ts_last = ts_init;
1003    let avg_px_open = position.entry_px;
1004
1005    // Hyperliquid uses netting (one position per instrument), not hedging
1006    Ok(PositionStatusReport::new(
1007        account_id,
1008        instrument_id,
1009        position_side,
1010        quantity,
1011        ts_last,
1012        ts_init,
1013        Some(report_id),
1014        None, // No venue_position_id for netting positions
1015        avg_px_open,
1016    ))
1017}
1018
1019/// Parse a spot token balance into a [`PositionStatusReport`] against the spot instrument.
1020///
1021/// Spot holdings are always Long (Hyperliquid spot has no short exposure). The average
1022/// entry price is derived from `entry_ntl / total` when both are non-zero; otherwise it
1023/// is omitted.
1024///
1025/// # Errors
1026///
1027/// Returns an error if the quantity cannot be constructed at the instrument's precision.
1028pub fn parse_spot_position_status_report(
1029    balance: &SpotBalance,
1030    instrument: &dyn Instrument,
1031    account_id: AccountId,
1032    ts_init: UnixNanos,
1033) -> anyhow::Result<PositionStatusReport> {
1034    let (position_side, quantity_value) = if balance.total.is_zero() {
1035        (PositionSideSpecified::Flat, Decimal::ZERO)
1036    } else {
1037        (PositionSideSpecified::Long, balance.total)
1038    };
1039
1040    let quantity = Quantity::from_decimal_dp(quantity_value, instrument.size_precision())
1041        .context("failed to create spot quantity from decimal")?;
1042
1043    Ok(PositionStatusReport::new(
1044        account_id,
1045        instrument.id(),
1046        position_side,
1047        quantity,
1048        ts_init,
1049        ts_init,
1050        Some(UUID4::new()),
1051        None,
1052        balance.avg_entry_px(),
1053    ))
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use rstest::rstest;
1059    use rust_decimal_macros::dec;
1060
1061    use super::{
1062        super::models::{
1063            HyperliquidL2Book, OutcomeMarket, OutcomeMeta, OutcomeQuestion, OutcomeSideSpec,
1064            PerpAsset, SpotPair, SpotToken,
1065        },
1066        *,
1067    };
1068
1069    #[rstest]
1070    fn test_parse_fill_side() {
1071        assert_eq!(parse_fill_side(&HyperliquidSide::Buy), OrderSide::Buy);
1072        assert_eq!(parse_fill_side(&HyperliquidSide::Sell), OrderSide::Sell);
1073    }
1074
1075    #[rstest]
1076    fn test_pow10_neg() {
1077        assert_eq!(pow10_neg(0), dec!(1));
1078        assert_eq!(pow10_neg(1), dec!(0.1));
1079        assert_eq!(pow10_neg(5), dec!(0.00001));
1080    }
1081
1082    #[rstest]
1083    fn test_parse_perp_instruments() {
1084        let meta = PerpMeta {
1085            universe: vec![
1086                PerpAsset {
1087                    name: "BTC".to_string(),
1088                    sz_decimals: 5,
1089                    max_leverage: Some(50),
1090                    ..Default::default()
1091                },
1092                PerpAsset {
1093                    name: "DELIST".to_string(),
1094                    sz_decimals: 3,
1095                    max_leverage: Some(10),
1096                    only_isolated: Some(true),
1097                    is_delisted: Some(true),
1098                    ..Default::default()
1099                },
1100            ],
1101            margin_tables: vec![],
1102        };
1103
1104        let defs = parse_perp_instruments(&meta, 0).unwrap();
1105
1106        // Should have both BTC and DELIST (delisted instruments are included for historical data)
1107        assert_eq!(defs.len(), 2);
1108
1109        let btc = &defs[0];
1110        assert_eq!(btc.symbol, "BTC-USD-PERP");
1111        assert_eq!(btc.base, "BTC");
1112        assert_eq!(btc.quote, "USD");
1113        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1114        assert_eq!(btc.price_decimals, 1); // 6 - 5 = 1
1115        assert_eq!(btc.size_decimals, 5);
1116        assert_eq!(btc.tick_size, dec!(0.1));
1117        assert_eq!(btc.lot_size, dec!(0.00001));
1118        assert_eq!(btc.max_leverage, Some(50));
1119        assert!(!btc.only_isolated);
1120        assert!(btc.active);
1121
1122        let delist = &defs[1];
1123        assert_eq!(delist.symbol, "DELIST-USD-PERP");
1124        assert_eq!(delist.base, "DELIST");
1125        assert!(!delist.active); // Delisted instruments are marked as inactive
1126    }
1127
1128    use crate::common::testing::load_test_data;
1129
1130    #[rstest]
1131    fn test_parse_perp_instruments_from_real_data() {
1132        let meta: PerpMeta = load_test_data("http_meta_perp_sample.json");
1133
1134        let defs = parse_perp_instruments(&meta, 0).unwrap();
1135
1136        // Should have 3 instruments (BTC, ETH, ATOM)
1137        assert_eq!(defs.len(), 3);
1138
1139        // Validate BTC
1140        let btc = &defs[0];
1141        assert_eq!(btc.symbol, "BTC-USD-PERP");
1142        assert_eq!(btc.base, "BTC");
1143        assert_eq!(btc.quote, "USD");
1144        assert_eq!(btc.market_type, HyperliquidMarketType::Perp);
1145        assert_eq!(btc.size_decimals, 5);
1146        assert_eq!(btc.max_leverage, Some(40));
1147        assert!(btc.active);
1148
1149        // Validate ETH
1150        let eth = &defs[1];
1151        assert_eq!(eth.symbol, "ETH-USD-PERP");
1152        assert_eq!(eth.base, "ETH");
1153        assert_eq!(eth.size_decimals, 4);
1154        assert_eq!(eth.max_leverage, Some(25));
1155
1156        // Validate ATOM
1157        let atom = &defs[2];
1158        assert_eq!(atom.symbol, "ATOM-USD-PERP");
1159        assert_eq!(atom.base, "ATOM");
1160        assert_eq!(atom.size_decimals, 2);
1161        assert_eq!(atom.max_leverage, Some(5));
1162    }
1163
1164    #[rstest]
1165    fn test_deserialize_l2_book_from_real_data() {
1166        let book: HyperliquidL2Book = load_test_data("http_l2_book_btc.json");
1167
1168        // Validate basic structure
1169        assert_eq!(book.coin, "BTC");
1170        assert_eq!(book.levels.len(), 2); // [bids, asks]
1171        assert_eq!(book.levels[0].len(), 5); // 5 bid levels
1172        assert_eq!(book.levels[1].len(), 5); // 5 ask levels
1173
1174        // Verify bids and asks are properly ordered
1175        let bids = &book.levels[0];
1176        let asks = &book.levels[1];
1177
1178        // Bids should be descending (highest first)
1179        for i in 1..bids.len() {
1180            let prev_price = bids[i - 1].px.parse::<f64>().unwrap();
1181            let curr_price = bids[i].px.parse::<f64>().unwrap();
1182            assert!(prev_price >= curr_price, "Bids should be descending");
1183        }
1184
1185        // Asks should be ascending (lowest first)
1186        for i in 1..asks.len() {
1187            let prev_price = asks[i - 1].px.parse::<f64>().unwrap();
1188            let curr_price = asks[i].px.parse::<f64>().unwrap();
1189            assert!(prev_price <= curr_price, "Asks should be ascending");
1190        }
1191    }
1192
1193    #[rstest]
1194    fn test_parse_spot_instruments() {
1195        let tokens = vec![
1196            SpotToken {
1197                name: "USDC".to_string(),
1198                sz_decimals: 6,
1199                wei_decimals: 6,
1200                index: 0,
1201                token_id: "0x1".to_string(),
1202                is_canonical: true,
1203                evm_contract: None,
1204                full_name: None,
1205                deployer_trading_fee_share: None,
1206            },
1207            SpotToken {
1208                name: "PURR".to_string(),
1209                sz_decimals: 0,
1210                wei_decimals: 5,
1211                index: 1,
1212                token_id: "0x2".to_string(),
1213                is_canonical: true,
1214                evm_contract: None,
1215                full_name: None,
1216                deployer_trading_fee_share: None,
1217            },
1218        ];
1219
1220        let pairs = vec![
1221            SpotPair {
1222                name: "PURR/USDC".to_string(),
1223                tokens: [1, 0], // PURR base, USDC quote
1224                index: 0,
1225                is_canonical: true,
1226            },
1227            SpotPair {
1228                name: "ALIAS".to_string(),
1229                tokens: [1, 0],
1230                index: 1,
1231                is_canonical: false, // Should be included but marked as inactive
1232            },
1233        ];
1234
1235        let meta = SpotMeta {
1236            tokens,
1237            universe: pairs,
1238        };
1239
1240        let defs = parse_spot_instruments(&meta).unwrap();
1241
1242        // Should have both PURR/USDC and ALIAS (non-canonical pairs are included for historical data)
1243        assert_eq!(defs.len(), 2);
1244
1245        let purr_usdc = &defs[0];
1246        assert_eq!(purr_usdc.symbol, "PURR-USDC-SPOT");
1247        assert_eq!(purr_usdc.base, "PURR");
1248        assert_eq!(purr_usdc.quote, "USDC");
1249        assert_eq!(purr_usdc.market_type, HyperliquidMarketType::Spot);
1250        assert_eq!(purr_usdc.price_decimals, 8); // 8 - 0 = 8 (PURR sz_decimals = 0)
1251        assert_eq!(purr_usdc.size_decimals, 0);
1252        assert_eq!(purr_usdc.tick_size, dec!(0.00000001));
1253        assert_eq!(purr_usdc.lot_size, dec!(1));
1254        assert_eq!(purr_usdc.max_leverage, None);
1255        assert!(!purr_usdc.only_isolated);
1256        assert!(purr_usdc.active);
1257
1258        let alias = &defs[1];
1259        assert_eq!(alias.symbol, "PURR-USDC-SPOT");
1260        assert_eq!(alias.base, "PURR");
1261        assert!(!alias.active); // Non-canonical pairs are marked as inactive
1262    }
1263
1264    #[rstest]
1265    fn test_parse_spot_instruments_sorts_canonical_before_non_canonical() {
1266        // Non-canonical pair uses a lower pair index than the canonical one;
1267        // the sort must still put canonical first so the base-token alias in
1268        // cache_instrument resolves to the canonical instrument.
1269        let tokens = vec![
1270            SpotToken {
1271                name: "USDC".to_string(),
1272                sz_decimals: 6,
1273                wei_decimals: 6,
1274                index: 0,
1275                token_id: "0x1".to_string(),
1276                is_canonical: true,
1277                evm_contract: None,
1278                full_name: None,
1279                deployer_trading_fee_share: None,
1280            },
1281            SpotToken {
1282                name: "HYPE".to_string(),
1283                sz_decimals: 2,
1284                wei_decimals: 8,
1285                index: 150,
1286                token_id: "0x2".to_string(),
1287                is_canonical: true,
1288                evm_contract: None,
1289                full_name: None,
1290                deployer_trading_fee_share: None,
1291            },
1292        ];
1293
1294        let pairs = vec![
1295            SpotPair {
1296                name: "HYPE_OLD".to_string(),
1297                tokens: [150, 0],
1298                index: 3,
1299                is_canonical: false,
1300            },
1301            SpotPair {
1302                name: "HYPE".to_string(),
1303                tokens: [150, 0],
1304                index: 107,
1305                is_canonical: true,
1306            },
1307        ];
1308
1309        let defs = parse_spot_instruments(&SpotMeta {
1310            tokens,
1311            universe: pairs,
1312        })
1313        .unwrap();
1314
1315        assert_eq!(defs.len(), 2);
1316        assert!(defs[0].active, "canonical must sort first");
1317        assert_eq!(defs[0].asset_index, 10000 + 107);
1318        assert!(!defs[1].active);
1319        assert_eq!(defs[1].asset_index, 10000 + 3);
1320    }
1321
1322    #[rstest]
1323    fn test_price_decimals_clamping() {
1324        let meta = PerpMeta {
1325            universe: vec![PerpAsset {
1326                name: "HIGHPREC".to_string(),
1327                sz_decimals: 10, // 6 - 10 = -4, should clamp to 0
1328                max_leverage: Some(1),
1329                ..Default::default()
1330            }],
1331            margin_tables: vec![],
1332        };
1333
1334        let defs = parse_perp_instruments(&meta, 0).unwrap();
1335        assert_eq!(defs[0].price_decimals, 0);
1336        assert_eq!(defs[0].tick_size, dec!(1));
1337    }
1338
1339    #[rstest]
1340    fn test_parse_perp_instruments_hip3_dex() {
1341        // HIP-3 dex at index 1: asset_index_base = 100_000 + 1 * 10_000 = 110_000
1342        let meta = PerpMeta {
1343            universe: vec![
1344                PerpAsset {
1345                    name: "xyz:TSLA".to_string(),
1346                    sz_decimals: 3,
1347                    max_leverage: Some(10),
1348                    only_isolated: None,
1349                    is_delisted: None,
1350                    growth_mode: Some("enabled".to_string()),
1351                    margin_mode: Some("strictIsolated".to_string()),
1352                },
1353                PerpAsset {
1354                    name: "xyz:NVDA".to_string(),
1355                    sz_decimals: 3,
1356                    max_leverage: Some(20),
1357                    only_isolated: None,
1358                    is_delisted: None,
1359                    growth_mode: None,
1360                    margin_mode: None,
1361                },
1362            ],
1363            margin_tables: vec![],
1364        };
1365
1366        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1367        assert_eq!(defs.len(), 2);
1368
1369        // HIP-3 asset: colon in symbol, offset asset index
1370        assert_eq!(defs[0].symbol, "xyz:TSLA-USD-PERP");
1371        assert!(defs[0].symbol.contains(':'));
1372        assert_eq!(defs[0].base, "xyz:TSLA");
1373        assert_eq!(defs[0].asset_index, 110_000);
1374        assert!(defs[0].active);
1375
1376        assert_eq!(defs[1].symbol, "xyz:NVDA-USD-PERP");
1377        assert_eq!(defs[1].asset_index, 110_001);
1378    }
1379
1380    #[rstest]
1381    #[case("BTC", "BTC")]
1382    #[case("kPEPE", "kPEPE")]
1383    #[case("xyz:TSLA", "xyz:TSLA")]
1384    #[case("dex:STREAMABCD****", "dex:STREAMABCDxxxx")]
1385    #[case("ABC?", "ABCx")]
1386    #[case("a*b?c", "axbxc")]
1387    fn test_sanitize_symbol(#[case] input: &str, #[case] expected: &str) {
1388        assert_eq!(sanitize_symbol(input), expected);
1389    }
1390
1391    #[rstest]
1392    fn test_parse_spot_instruments_sanitizes_wildcard_token_names() {
1393        // Hypothetical spot token whose venue name contains `?`. Sanitization
1394        // must apply to the constructed `symbol` while leaving `raw_symbol`
1395        // and `base` carrying the venue-official name for wire I/O.
1396        let tokens = vec![
1397            SpotToken {
1398                name: "USDC".to_string(),
1399                sz_decimals: 6,
1400                wei_decimals: 6,
1401                index: 0,
1402                token_id: "0x1".to_string(),
1403                is_canonical: true,
1404                evm_contract: None,
1405                full_name: None,
1406                deployer_trading_fee_share: None,
1407            },
1408            SpotToken {
1409                name: "ABC?".to_string(),
1410                sz_decimals: 4,
1411                wei_decimals: 4,
1412                index: 1,
1413                token_id: "0x2".to_string(),
1414                is_canonical: true,
1415                evm_contract: None,
1416                full_name: None,
1417                deployer_trading_fee_share: None,
1418            },
1419        ];
1420
1421        let pairs = vec![SpotPair {
1422            name: "ABC?/USDC".to_string(),
1423            tokens: [1, 0],
1424            index: 50,
1425            is_canonical: true,
1426        }];
1427
1428        let meta = SpotMeta {
1429            tokens,
1430            universe: pairs,
1431        };
1432
1433        let defs = parse_spot_instruments(&meta).unwrap();
1434        assert_eq!(defs.len(), 1);
1435        assert_eq!(defs[0].symbol, "ABCx-USDC-SPOT");
1436        assert_eq!(defs[0].base, "ABC?");
1437        assert_eq!(defs[0].quote, "USDC");
1438    }
1439
1440    #[rstest]
1441    fn test_parse_perp_instruments_sanitizes_hip3_wildcards() {
1442        let meta = PerpMeta {
1443            universe: vec![PerpAsset {
1444                name: "dex:STREAMABCD****".to_string(),
1445                sz_decimals: 3,
1446                max_leverage: Some(10),
1447                only_isolated: None,
1448                is_delisted: None,
1449                growth_mode: None,
1450                margin_mode: None,
1451            }],
1452            margin_tables: vec![],
1453        };
1454
1455        let defs = parse_perp_instruments(&meta, 110_000).unwrap();
1456        assert_eq!(defs.len(), 1);
1457        assert_eq!(defs[0].symbol, "dex:STREAMABCDxxxx-USD-PERP");
1458        assert_eq!(defs[0].raw_symbol.as_str(), "dex:STREAMABCD****");
1459        assert_eq!(defs[0].base.as_str(), "dex:STREAMABCD****");
1460    }
1461
1462    #[rstest]
1463    fn test_parse_outcome_instruments_emits_both_sides() {
1464        let meta = OutcomeMeta {
1465            outcomes: vec![OutcomeMarket {
1466                outcome: 1,
1467                name: "BTC daily".to_string(),
1468                description: "BTC settles above strike at 06:00 UTC".to_string(),
1469                side_specs: vec![
1470                    OutcomeSideSpec {
1471                        name: "Yes".to_string(),
1472                    },
1473                    OutcomeSideSpec {
1474                        name: "No".to_string(),
1475                    },
1476                ],
1477            }],
1478            questions: vec![],
1479        };
1480
1481        let defs = parse_outcome_instruments(&meta).unwrap();
1482        assert_eq!(defs.len(), 2);
1483
1484        let yes = &defs[0];
1485        assert_eq!(yes.symbol.as_str(), "+10");
1486        assert_eq!(yes.raw_symbol.as_str(), "#10");
1487        assert_eq!(yes.market_type, HyperliquidMarketType::Outcome);
1488        assert_eq!(yes.asset_index, 100_000_010);
1489        assert_eq!(yes.price_decimals, OUTCOME_PRICE_DECIMALS);
1490        assert_eq!(yes.size_decimals, OUTCOME_SIZE_DECIMALS);
1491        assert_eq!(yes.tick_size, dec!(0.0001));
1492        assert_eq!(yes.lot_size, dec!(0.01));
1493        assert_eq!(yes.quote.as_str(), "USDH");
1494        assert!(yes.active);
1495
1496        let yes_meta = yes.outcome.as_ref().unwrap();
1497        assert_eq!(yes_meta.outcome_index, 1);
1498        assert_eq!(yes_meta.outcome_side, 0);
1499        assert_eq!(yes_meta.market_name.as_str(), "BTC daily");
1500        assert_eq!(yes_meta.side_name.unwrap().as_str(), "Yes");
1501        assert_eq!(
1502            yes_meta.description.unwrap().as_str(),
1503            "BTC settles above strike at 06:00 UTC"
1504        );
1505
1506        let no = &defs[1];
1507        assert_eq!(no.symbol.as_str(), "+11");
1508        assert_eq!(no.raw_symbol.as_str(), "#11");
1509        assert_eq!(no.asset_index, 100_000_011);
1510        let no_meta = no.outcome.as_ref().unwrap();
1511        assert_eq!(no_meta.outcome_side, 1);
1512        assert_eq!(no_meta.side_name.unwrap().as_str(), "No");
1513    }
1514
1515    #[rstest]
1516    fn test_parse_outcome_instruments_handles_missing_side_specs() {
1517        let meta = OutcomeMeta {
1518            outcomes: vec![OutcomeMarket {
1519                outcome: 5,
1520                name: "Recurring".to_string(),
1521                description: String::new(),
1522                side_specs: vec![],
1523            }],
1524            questions: vec![],
1525        };
1526
1527        let defs = parse_outcome_instruments(&meta).unwrap();
1528        assert_eq!(defs.len(), 2);
1529
1530        for def in &defs {
1531            let outcome = def.outcome.as_ref().unwrap();
1532            assert!(outcome.side_name.is_none());
1533            assert!(outcome.description.is_none());
1534        }
1535
1536        assert_eq!(defs[0].asset_index, 100_000_050);
1537        assert_eq!(defs[1].asset_index, 100_000_051);
1538    }
1539
1540    #[rstest]
1541    fn test_get_usdh_currency_registers_with_explicit_precision() {
1542        let currency = get_usdh_currency();
1543        assert_eq!(currency.code.as_str(), "USDH");
1544        assert_eq!(currency.precision, 8);
1545        assert_eq!(currency.currency_type, CurrencyType::Crypto);
1546
1547        // Repeated calls return the same registered currency
1548        let again = get_usdh_currency();
1549        assert_eq!(again, currency);
1550        assert!(Currency::try_from_str("USDH").is_some());
1551    }
1552
1553    #[rstest]
1554    fn test_create_instrument_from_def_outcome_emits_binary_option() {
1555        let meta = OutcomeMeta {
1556            outcomes: vec![OutcomeMarket {
1557                outcome: 2,
1558                name: "Recurring BTC".to_string(),
1559                description: "Daily settlement".to_string(),
1560                side_specs: vec![
1561                    OutcomeSideSpec {
1562                        name: "Yes".to_string(),
1563                    },
1564                    OutcomeSideSpec {
1565                        name: "No".to_string(),
1566                    },
1567                ],
1568            }],
1569            questions: vec![],
1570        };
1571
1572        let defs = parse_outcome_instruments(&meta).unwrap();
1573        let instrument = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1574
1575        match instrument {
1576            InstrumentAny::BinaryOption(bo) => {
1577                assert_eq!(bo.id.symbol.as_str(), "+20");
1578                assert_eq!(bo.raw_symbol.as_str(), "#20");
1579                assert_eq!(bo.asset_class, AssetClass::Alternative);
1580                assert_eq!(bo.currency.code.as_str(), "USDH");
1581                assert_eq!(bo.price_precision, OUTCOME_PRICE_DECIMALS as u8);
1582                assert_eq!(bo.size_precision, OUTCOME_SIZE_DECIMALS as u8);
1583                assert_eq!(bo.outcome.unwrap().as_str(), "Yes");
1584                assert_eq!(bo.description.unwrap().as_str(), "Daily settlement");
1585            }
1586            other => panic!("Expected BinaryOption, was {other:?}"),
1587        }
1588    }
1589
1590    #[rstest]
1591    fn test_parse_fill_report_outcome_round_trip() {
1592        let meta = OutcomeMeta {
1593            outcomes: vec![OutcomeMarket {
1594                outcome: 42,
1595                name: "BTC daily".to_string(),
1596                description: "BTC settles above strike at 06:00 UTC".to_string(),
1597                side_specs: vec![
1598                    OutcomeSideSpec {
1599                        name: "Yes".to_string(),
1600                    },
1601                    OutcomeSideSpec {
1602                        name: "No".to_string(),
1603                    },
1604                ],
1605            }],
1606            questions: vec![],
1607        };
1608
1609        let defs = parse_outcome_instruments(&meta).unwrap();
1610        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1611        assert_eq!(yes.id().symbol.as_str(), "+420");
1612
1613        let fill = HyperliquidFill {
1614            coin: Ustr::from("#420"),
1615            px: "0.5500".to_string(),
1616            sz: "1000.00".to_string(),
1617            side: HyperliquidSide::Buy,
1618            time: 1_704_470_400_000,
1619            start_position: "0.00".to_string(),
1620            dir: HyperliquidFillDirection::OpenLong,
1621            closed_pnl: "0.0".to_string(),
1622            hash: "0xfeed".to_string(),
1623            oid: 99_001,
1624            crossed: true,
1625            fee: "0.0".to_string(),
1626            fee_token: Ustr::from("+420"),
1627        };
1628
1629        let account_id = AccountId::from("HYPERLIQUID-001");
1630        let report = parse_fill_report(&fill, &yes, account_id, UnixNanos::default()).unwrap();
1631
1632        // Zero-fee outcome fills resolve commission to the instrument's quote
1633        // currency (USDH) rather than the side token, so downstream OrderFilled
1634        // events and persistence carry a registered currency.
1635        assert_eq!(report.commission.currency.code.as_str(), "USDH");
1636        assert!(report.commission.as_decimal().is_zero());
1637        assert_eq!(report.order_side, OrderSide::Buy);
1638        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1639        assert_eq!(report.last_qty.as_decimal(), dec!(1000));
1640        assert_eq!(report.last_px.as_decimal(), dec!(0.55));
1641    }
1642
1643    #[rstest]
1644    fn test_resolve_fee_currency_outcome_token_returns_quote_even_when_registered() {
1645        let meta = OutcomeMeta {
1646            outcomes: vec![OutcomeMarket {
1647                outcome: 88,
1648                name: "Edge".to_string(),
1649                description: String::new(),
1650                side_specs: vec![],
1651            }],
1652            questions: vec![],
1653        };
1654        let defs = parse_outcome_instruments(&meta).unwrap();
1655        let yes = create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1656
1657        // Simulate another adapter path (e.g. spot balance parsing) having already
1658        // registered the side token in the global currency registry.
1659        let _ = get_currency("+880");
1660        assert!(Currency::try_from_str("+880").is_some());
1661
1662        let currency = resolve_fee_currency("+880", Decimal::ZERO, &yes)
1663            .expect("zero-fee outcome side token must resolve to quote currency");
1664        assert_eq!(currency.code.as_str(), "USDH");
1665
1666        let err = resolve_fee_currency("+880", dec!(0.01), &yes).unwrap_err();
1667        let err_msg = err.to_string();
1668        assert!(err_msg.contains("Outcome side token '+880'"));
1669        assert!(err_msg.contains("non-zero fee"));
1670    }
1671
1672    #[rstest]
1673    #[case("+50", true)]
1674    #[case("+0", true)]
1675    #[case("+880", true)]
1676    #[case("", false)]
1677    #[case("+", false)]
1678    #[case("+abc", false)]
1679    #[case("+50a", false)]
1680    #[case("#50", false)]
1681    #[case("USDC", false)]
1682    #[case("-50", false)]
1683    fn test_is_outcome_side_token(#[case] input: &str, #[case] expected: bool) {
1684        assert_eq!(is_outcome_side_token(input), expected);
1685    }
1686
1687    #[rstest]
1688    fn test_resolve_fee_currency_falls_back_to_quote_when_unregistered_and_zero_fee() {
1689        let meta = OutcomeMeta {
1690            outcomes: vec![OutcomeMarket {
1691                outcome: 77,
1692                name: "Edge".to_string(),
1693                description: String::new(),
1694                side_specs: vec![],
1695            }],
1696            questions: vec![],
1697        };
1698
1699        let defs = parse_outcome_instruments(&meta).unwrap();
1700        let no = create_instrument_from_def(&defs[1], UnixNanos::default()).unwrap();
1701
1702        // Use a token that the venue would not normally emit; the helper must still
1703        // return the instrument's quote currency on a zero-fee fill.
1704        let currency = resolve_fee_currency("+UNREGISTERED-TOKEN", Decimal::ZERO, &no)
1705            .expect("zero-fee fallback should succeed");
1706        assert_eq!(currency.code.as_str(), "USDH");
1707
1708        let err = resolve_fee_currency("+UNREGISTERED-TOKEN", dec!(0.01), &no).unwrap_err();
1709        assert!(err.to_string().contains("non-zero fee"));
1710    }
1711
1712    #[rstest]
1713    fn test_parse_outcome_expiry_ns_round_trip() {
1714        // 2026-05-08 06:00:00 UTC == 1778652000 seconds since epoch
1715        let ns = parse_outcome_expiry_ns("20260508-0600").unwrap();
1716        assert_eq!(ns.as_u64(), 1_778_220_000_000_000_000);
1717    }
1718
1719    #[rstest]
1720    #[case("")]
1721    #[case("20260508")]
1722    #[case("20260508-")]
1723    #[case("20260508-0600 ")]
1724    #[case("2026-05-08-06-00")]
1725    #[case("20261308-0600")]
1726    fn test_parse_outcome_expiry_ns_rejects_bad_input(#[case] input: &str) {
1727        assert!(parse_outcome_expiry_ns(input).is_none());
1728    }
1729
1730    #[rstest]
1731    fn test_parse_outcome_instruments_pulls_expiry_from_price_binary() {
1732        let meta = OutcomeMeta {
1733            outcomes: vec![OutcomeMarket {
1734                outcome: 5,
1735                name: "Recurring".to_string(),
1736                description:
1737                    "class:priceBinary|underlying:BTC|expiry:20260508-0600|targetPrice:81041|period:1d"
1738                        .to_string(),
1739                side_specs: vec![
1740                    OutcomeSideSpec {
1741                        name: "Yes".to_string(),
1742                    },
1743                    OutcomeSideSpec {
1744                        name: "No".to_string(),
1745                    },
1746                ],
1747            }],
1748            questions: vec![],
1749        };
1750
1751        let defs = parse_outcome_instruments(&meta).unwrap();
1752        let yes_meta = defs[0].outcome.as_ref().unwrap();
1753        assert_eq!(yes_meta.expiration_ns.as_u64(), 1_778_220_000_000_000_000);
1754    }
1755
1756    #[rstest]
1757    fn test_parse_outcome_instruments_inherits_expiry_from_parent_question() {
1758        // outcome=7 has `index:0` description and is referenced by question 0's
1759        // `named_outcomes`. outcome=6 has `other` description and is the
1760        // `fallback_outcome`. Both should pick up the question's expiry.
1761        let meta = OutcomeMeta {
1762            outcomes: vec![
1763                OutcomeMarket {
1764                    outcome: 6,
1765                    name: "Recurring Fallback".to_string(),
1766                    description: "other".to_string(),
1767                    side_specs: vec![],
1768                },
1769                OutcomeMarket {
1770                    outcome: 7,
1771                    name: "Recurring Named Outcome".to_string(),
1772                    description: "index:0".to_string(),
1773                    side_specs: vec![],
1774                },
1775            ],
1776            questions: vec![OutcomeQuestion {
1777                question: 0,
1778                name: "Recurring".to_string(),
1779                description:
1780                    "class:priceBucket|underlying:BTC|expiry:20260508-0600|priceThresholds:79303,82540|period:1d"
1781                        .to_string(),
1782                fallback_outcome: Some(6),
1783                named_outcomes: vec![7, 8, 9],
1784                settled_named_outcomes: vec![],
1785            }],
1786        };
1787
1788        let defs = parse_outcome_instruments(&meta).unwrap();
1789        let expected_ns: u64 = 1_778_220_000_000_000_000;
1790
1791        for def in &defs {
1792            let outcome = def.outcome.as_ref().unwrap();
1793            assert_eq!(
1794                outcome.expiration_ns.as_u64(),
1795                expected_ns,
1796                "outcome {} side {} should inherit expiry",
1797                outcome.outcome_index,
1798                outcome.outcome_side,
1799            );
1800        }
1801    }
1802
1803    #[rstest]
1804    fn test_derive_outcome_settlements_returns_empty_when_no_questions() {
1805        let meta = OutcomeMeta {
1806            outcomes: vec![],
1807            questions: vec![],
1808        };
1809        assert!(derive_outcome_settlements(&meta).is_empty());
1810    }
1811
1812    #[rstest]
1813    fn test_derive_outcome_settlements_returns_empty_when_no_questions_settled() {
1814        let meta = OutcomeMeta {
1815            outcomes: vec![],
1816            questions: vec![OutcomeQuestion {
1817                question: 0,
1818                name: "Recurring".to_string(),
1819                description: "class:priceBucket|expiry:20260508-0600".to_string(),
1820                fallback_outcome: Some(6),
1821                named_outcomes: vec![7, 8, 9],
1822                settled_named_outcomes: vec![],
1823            }],
1824        };
1825
1826        assert!(derive_outcome_settlements(&meta).is_empty());
1827    }
1828
1829    #[rstest]
1830    fn test_derive_outcome_settlements_marks_winners_losers_and_fallback() {
1831        let meta = OutcomeMeta {
1832            outcomes: vec![],
1833            questions: vec![OutcomeQuestion {
1834                question: 0,
1835                name: "Recurring".to_string(),
1836                description: "class:priceBucket|expiry:20260508-0600".to_string(),
1837                fallback_outcome: Some(6),
1838                named_outcomes: vec![7, 8, 9],
1839                settled_named_outcomes: vec![8],
1840            }],
1841        };
1842
1843        let settlements = derive_outcome_settlements(&meta);
1844        let lookup: ahash::AHashMap<(u32, u8), u8> = settlements
1845            .into_iter()
1846            .map(|s| ((s.outcome_index, s.outcome_side), s.final_value))
1847            .collect();
1848
1849        // Winning named outcome 8: Yes -> 1, No -> 0
1850        assert_eq!(lookup[&(8, 0)], 1);
1851        assert_eq!(lookup[&(8, 1)], 0);
1852
1853        // Losing named outcomes 7, 9 and fallback 6: Yes -> 0, No -> 1
1854        for losing in [7, 9, 6] {
1855            assert_eq!(lookup[&(losing, 0)], 0, "outcome {losing} Yes side");
1856            assert_eq!(lookup[&(losing, 1)], 1, "outcome {losing} No side");
1857        }
1858
1859        assert_eq!(lookup.len(), 8);
1860    }
1861
1862    #[rstest]
1863    fn test_parse_outcome_meta_question_settlement_round_trip() {
1864        let json = r#"{
1865            "outcomes": [{"outcome": 5, "name": "Recurring", "description": "class:priceBinary|expiry:20260508-0600", "sideSpecs": []}],
1866            "questions": [{
1867                "question": 0,
1868                "name": "Recurring",
1869                "description": "class:priceBucket|expiry:20260508-0600",
1870                "fallbackOutcome": 6,
1871                "namedOutcomes": [7, 8, 9],
1872                "settledNamedOutcomes": [8]
1873            }]
1874        }"#;
1875
1876        let meta: OutcomeMeta = serde_json::from_str(json).unwrap();
1877        assert_eq!(meta.questions.len(), 1);
1878        let q = &meta.questions[0];
1879        assert_eq!(q.fallback_outcome, Some(6));
1880        assert_eq!(q.named_outcomes, vec![7, 8, 9]);
1881        assert_eq!(q.settled_named_outcomes, vec![8]);
1882
1883        assert!(meta.parent_question(7).is_some());
1884        assert!(meta.parent_question(6).is_some());
1885        assert!(meta.parent_question(99).is_none());
1886    }
1887}