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