Skip to main content

nautilus_hyperliquid/common/
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
16//! Parsing utilities that convert Hyperliquid payloads into Nautilus domain models.
17//!
18//! # Conditional Order Support
19//!
20//! This module implements conditional order support for Hyperliquid,
21//! following patterns established in the OKX, Bybit, and BitMEX adapters.
22//!
23//! ## Supported Order Types
24//!
25//! ### Standard Orders
26//! - **Market**: Implemented as IOC (Immediate-or-Cancel) limit orders.
27//! - **Limit**: Standard limit orders with GTC/IOC/ALO time-in-force.
28//!
29//! ### Conditional/Trigger Orders
30//! - **StopMarket**: Protective stop that triggers at specified price and executes at market.
31//! - **StopLimit**: Protective stop that triggers at specified price and executes at limit.
32//! - **MarketIfTouched**: Profit-taking/entry order that triggers and executes at market.
33//! - **LimitIfTouched**: Profit-taking/entry order that triggers and executes at limit.
34//!
35//! ## Order Semantics
36//!
37//! ### Stop Orders (StopMarket/StopLimit)
38//! - Used for protective stops and risk management.
39//! - Mapped to Hyperliquid's trigger orders with `tpsl: Sl`.
40//! - Trigger when price reaches the stop level.
41//! - Execute immediately (market) or at limit price.
42//!
43//! ### If Touched Orders (MarketIfTouched/LimitIfTouched)
44//! - Used for profit-taking or entry orders.
45//! - Mapped to Hyperliquid's trigger orders with `tpsl: Tp`.
46//! - Trigger when price reaches the target level.
47//! - Execute immediately (market) or at limit price.
48//!
49//! ## Trigger Price Logic
50//!
51//! The `tpsl` field (Take Profit / Stop Loss) is determined by:
52//! 1. **Order Type**: Stop orders → SL, If Touched orders → TP
53//! 2. **Price Relationship** (if available):
54//!    - For BUY orders: trigger above market → SL, below → TP
55//!    - For SELL orders: trigger below market → SL, above → TP
56//!
57//! ## Trigger Type Support
58//!
59//! Hyperliquid uses **mark price** for all trigger evaluations (TP/SL orders).
60
61use anyhow::Context;
62use nautilus_core::UnixNanos;
63pub use nautilus_core::serialization::{
64    deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
65    deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
66    serialize_vec_decimal_as_str,
67};
68use nautilus_model::{
69    data::{bar::BarType, quote::QuoteTick},
70    enums::{
71        AggregationSource, BarAggregation, ContingencyType, OrderSide, OrderStatus, OrderType,
72        TimeInForce,
73    },
74    identifiers::{ClientOrderId, TradeId},
75    orders::{Order, any::OrderAny},
76    types::{AccountBalance, Currency, MarginBalance, Money},
77};
78use rust_decimal::Decimal;
79
80use crate::{
81    common::{
82        enums::{
83            HyperliquidBarInterval::{self, *},
84            HyperliquidOrderStatus, HyperliquidTpSl,
85        },
86        types::HyperliquidAssetId,
87    },
88    http::models::{
89        ClearinghouseState, Cloid, HyperliquidExchangeCancelByCloidRequest,
90        HyperliquidExchangeCancelStatus, HyperliquidExchangeGrouping,
91        HyperliquidExchangeLimitParams, HyperliquidExchangeModifyStatus,
92        HyperliquidExchangeOrderKind, HyperliquidExchangeOrderStatus,
93        HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
94        HyperliquidExchangeResponseData, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
95        HyperliquidExchangeTriggerParams, RESPONSE_STATUS_OK, SpotClearinghouseState,
96    },
97    websocket::messages::TrailingOffsetType,
98};
99
100/// Creates a deterministic [`TradeId`] from fill fields common to both WS and HTTP responses.
101///
102/// Uses FNV-1a hash of `(hash, oid, px, sz, time, start_position)` to produce a unique
103/// identifier consistent across both data sources for the same physical fill.
104/// Includes `start_position` (running position before each fill) to disambiguate
105/// multiple partial fills within the same transaction at the same price/size.
106/// Format: `{fnv_hex}-{oid_hex}` (exactly 33 chars, within 36-char limit).
107pub fn make_fill_trade_id(
108    hash: &str,
109    oid: u64,
110    px: Decimal,
111    sz: Decimal,
112    time: u64,
113    start_position: Decimal,
114) -> TradeId {
115    // FNV-1a with fixed seed for deterministic output
116    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
117    for &b in hash.as_bytes() {
118        h ^= b as u64;
119        h = h.wrapping_mul(0x0100_0000_01b3);
120    }
121
122    for b in oid.to_le_bytes() {
123        h ^= b as u64;
124        h = h.wrapping_mul(0x0100_0000_01b3);
125    }
126
127    for &b in px.to_string().as_bytes() {
128        h ^= b as u64;
129        h = h.wrapping_mul(0x0100_0000_01b3);
130    }
131
132    for &b in sz.to_string().as_bytes() {
133        h ^= b as u64;
134        h = h.wrapping_mul(0x0100_0000_01b3);
135    }
136
137    for b in time.to_le_bytes() {
138        h ^= b as u64;
139        h = h.wrapping_mul(0x0100_0000_01b3);
140    }
141
142    for &b in start_position.to_string().as_bytes() {
143        h ^= b as u64;
144        h = h.wrapping_mul(0x0100_0000_01b3);
145    }
146    TradeId::new(format!("{h:016x}-{oid:016x}"))
147}
148
149/// Round price down to the nearest valid tick size.
150#[inline]
151pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
152    if tick_size.is_zero() {
153        return price;
154    }
155    (price / tick_size).floor() * tick_size
156}
157
158/// Round quantity down to the nearest valid step size.
159#[inline]
160pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
161    if step_size.is_zero() {
162        return qty;
163    }
164    (qty / step_size).floor() * step_size
165}
166
167/// Ensure the notional value meets minimum requirements.
168#[inline]
169pub fn ensure_min_notional(
170    price: Decimal,
171    qty: Decimal,
172    min_notional: Decimal,
173) -> Result<(), String> {
174    let notional = price * qty;
175    if notional < min_notional {
176        Err(format!(
177            "Notional value {notional} is less than minimum required {min_notional}"
178        ))
179    } else {
180        Ok(())
181    }
182}
183
184/// Round a decimal to at most N significant figures.
185/// Hyperliquid requires prices to have at most 5 significant figures.
186pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
187    if value.is_zero() {
188        return Decimal::ZERO;
189    }
190
191    // log10(|value|) = log10(|mantissa|) - scale; `ilog10` skips the float path
192    let mantissa = value.mantissa().unsigned_abs();
193    let magnitude = mantissa.ilog10() as i32 - value.scale() as i32;
194
195    let shift = sig_figs as i32 - 1 - magnitude;
196    let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
197
198    if shift >= 0 {
199        (value * factor).round() / factor
200    } else {
201        (value / factor).round() * factor
202    }
203}
204
205/// Normalize price to the specified number of decimal places.
206pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
207    // First round to 5 significant figures (Hyperliquid requirement)
208    let sig_fig_price = round_to_sig_figs(price, 5);
209    // Then truncate to max decimal places
210    let scale = Decimal::from(10_u64.pow(decimals as u32));
211    (sig_fig_price * scale).floor() / scale
212}
213
214/// Normalize quantity to the specified number of decimal places.
215pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
216    let scale = Decimal::from(10_u64.pow(decimals as u32));
217    (qty * scale).floor() / scale
218}
219
220/// Validates venue canonical wire form for a price submitted with price
221/// normalization disabled: at most `price_decimals` fractional digits. The
222/// venue parses prices into its canonical form before verifying the action
223/// signature, so a price with excess decimals fails signature verification
224/// and surfaces as a misleading "wallet does not exist" error instead of an
225/// order validation error.
226fn ensure_canonical_wire_price(
227    label: &str,
228    price: Decimal,
229    price_decimals: u8,
230) -> anyhow::Result<()> {
231    anyhow::ensure!(
232        price.scale() <= u32::from(price_decimals),
233        "{label} {price} exceeds the instrument maximum of {price_decimals} decimal places; \
234         enable normalize_prices or adjust the price"
235    );
236    Ok(())
237}
238
239/// Normalizes a price to the venue wire form, or validates the canonical
240/// form when normalization is disabled. Validation is skipped when the
241/// instrument decimal cap is unknown (`None`): the prior raw passthrough is
242/// preserved rather than validating against a placeholder. See
243/// [`ensure_canonical_wire_price`].
244pub(crate) fn normalize_or_validate_wire_price(
245    raw: Decimal,
246    label: &str,
247    price_decimals: Option<u8>,
248    should_normalize_prices: bool,
249) -> anyhow::Result<Decimal> {
250    if should_normalize_prices {
251        Ok(normalize_price(raw, price_decimals.unwrap_or(2)).normalize())
252    } else {
253        let value = raw.normalize();
254        if let Some(decimals) = price_decimals {
255            ensure_canonical_wire_price(label, value, decimals)?;
256        }
257        Ok(value)
258    }
259}
260
261/// Complete normalization for an order including price, quantity, and notional validation
262pub fn normalize_order(
263    price: Decimal,
264    qty: Decimal,
265    tick_size: Decimal,
266    step_size: Decimal,
267    min_notional: Decimal,
268    price_decimals: u8,
269    size_decimals: u8,
270) -> Result<(Decimal, Decimal), String> {
271    // Normalize to decimal places first
272    let normalized_price = normalize_price(price, price_decimals);
273    let normalized_qty = normalize_quantity(qty, size_decimals);
274
275    // Round down to tick/step sizes
276    let final_price = round_down_to_tick(normalized_price, tick_size);
277    let final_qty = round_down_to_step(normalized_qty, step_size);
278
279    // Validate minimum notional
280    ensure_min_notional(final_price, final_qty, min_notional)?;
281
282    Ok((final_price, final_qty))
283}
284
285/// Converts millisecond timestamp to [`UnixNanos`].
286#[inline]
287pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
288    let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
289    Ok(UnixNanos::from(value))
290}
291
292/// Parses an outcome (HIP-4) spot coin or token symbol into an asset ID.
293///
294/// Hyperliquid represents outcome spot coins as `#<encoding>` and outcome
295/// token names as `+<encoding>`, where `encoding = 10 * outcome + side`.
296///
297/// # Errors
298///
299/// Returns an error if the symbol is not an outcome symbol, the encoding is
300/// not numeric, overflows the asset id range, or carries an invalid side digit.
301pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
302    let encoding = parse_outcome_symbol_encoding(symbol)?;
303    HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
304        format!(
305            "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
306        )
307    })
308}
309
310fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
311    let encoding = symbol
312        .strip_prefix('#')
313        .or_else(|| symbol.strip_prefix('+'))
314        .with_context(|| {
315            format!(
316                "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
317            )
318        })?;
319
320    if encoding.is_empty() {
321        anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
322    }
323
324    if !encoding.bytes().all(|b| b.is_ascii_digit()) {
325        anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
326    }
327
328    encoding
329        .parse::<u32>()
330        .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
331}
332
333/// Suffix shared by every Nautilus outcome symbol, mirroring `-PERP` / `-SPOT`.
334pub const OUTCOME_SYMBOL_SUFFIX: &str = "-OUTCOME";
335/// Yes-side label on Nautilus outcome symbols.
336pub const OUTCOME_SIDE_YES: &str = "YES";
337/// No-side label on Nautilus outcome symbols.
338pub const OUTCOME_SIDE_NO: &str = "NO";
339
340/// Parses a Nautilus outcome instrument symbol of the form
341/// `{outcome_index}-{YES|NO}-OUTCOME` into `(outcome_index, side)` where side
342/// is `0` for Yes and `1` for No.
343///
344/// Returns `None` if the symbol does not match the expected shape or if the
345/// `(outcome_index, side)` pair would not encode into a valid HIP-4
346/// `HyperliquidAssetId` (i.e. `100_000_000 + 10 * outcome_index + side`
347/// would overflow `u32`). The legacy `#E` / `+E` wire parser already rejects
348/// out-of-range encodings; this keeps the two paths in parity so downstream
349/// arithmetic on the returned pair cannot overflow.
350#[must_use]
351pub fn parse_outcome_nautilus_symbol(symbol: &str) -> Option<(u32, u8)> {
352    let rest = symbol.strip_suffix(OUTCOME_SYMBOL_SUFFIX)?;
353    let (index_str, side_str) = rest.rsplit_once('-')?;
354    let outcome_index = index_str.parse::<u32>().ok()?;
355    let side = match side_str {
356        OUTCOME_SIDE_YES => 0,
357        OUTCOME_SIDE_NO => 1,
358        _ => return None,
359    };
360    let encoding = outcome_index
361        .checked_mul(10)?
362        .checked_add(u32::from(side))?;
363    HyperliquidAssetId::from_outcome_encoding(encoding)?;
364    Some((outcome_index, side))
365}
366
367/// Formats an `(outcome_index, side)` pair into the Nautilus outcome symbol
368/// form `{outcome_index}-{YES|NO}-OUTCOME`.
369#[must_use]
370pub fn format_outcome_nautilus_symbol(outcome_index: u32, side: u8) -> String {
371    let side_label = match side {
372        0 => OUTCOME_SIDE_YES,
373        _ => OUTCOME_SIDE_NO,
374    };
375    format!("{outcome_index}-{side_label}{OUTCOME_SYMBOL_SUFFIX}")
376}
377
378/// Returns the `+<encoding>` token form for the side token referenced by a
379/// Nautilus outcome symbol, or `None` if the symbol is not an outcome.
380#[must_use]
381pub fn outcome_token_from_nautilus_symbol(symbol: &str) -> Option<String> {
382    let (outcome_index, side) = parse_outcome_nautilus_symbol(symbol)?;
383    let encoding = 10 * outcome_index + u32::from(side);
384    Some(format!("+{encoding}"))
385}
386
387/// Returns the secondary cache-alias key for a Nautilus instrument symbol.
388///
389/// For outcome symbols, this is the `+<encoding>` token form (matching the
390/// `coin` field on `spotClearinghouseState` balances). For perp / spot
391/// symbols it is the leading segment before the first `-` (the base asset
392/// or sanitized base for HIP-3 perps). Returns `None` for an empty symbol.
393///
394/// Used by `cache_instrument`, order-response report builders, and the bar
395/// lookup so all three derive the same alias and stay in sync as the symbol
396/// shape evolves.
397#[must_use]
398pub fn cache_alias_for_symbol(symbol: &str) -> Option<String> {
399    if let Some(token) = outcome_token_from_nautilus_symbol(symbol) {
400        return Some(token);
401    }
402
403    let leading = symbol.split('-').next()?;
404    if leading.is_empty() {
405        None
406    } else {
407        Some(leading.to_string())
408    }
409}
410
411/// Converts a Nautilus `TimeInForce` to Hyperliquid TIF.
412///
413/// # Errors
414///
415/// Returns an error if the time in force is not supported.
416pub fn time_in_force_to_hyperliquid_tif(
417    tif: TimeInForce,
418    is_post_only: bool,
419) -> anyhow::Result<HyperliquidExchangeTif> {
420    match (tif, is_post_only) {
421        (_, true) => Ok(HyperliquidExchangeTif::Alo), // Always use ALO for post-only orders
422        (TimeInForce::Gtc, false) => Ok(HyperliquidExchangeTif::Gtc),
423        (TimeInForce::Ioc, false) => Ok(HyperliquidExchangeTif::Ioc),
424        (TimeInForce::Fok, false) => {
425            anyhow::bail!("FOK time in force is not supported by Hyperliquid")
426        }
427        _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
428    }
429}
430
431fn determine_tpsl_type(
432    order_type: OrderType,
433    order_side: OrderSide,
434    trigger_price: Decimal,
435    current_price: Option<Decimal>,
436) -> HyperliquidExchangeTpSl {
437    match order_type {
438        // Stop orders are protective - always SL
439        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
440
441        // If Touched orders are profit-taking or entry orders - always TP
442        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExchangeTpSl::Tp,
443
444        // For other trigger types, try to infer from price relationship if available
445        _ => {
446            if let Some(current) = current_price {
447                match order_side {
448                    OrderSide::Buy => {
449                        // Buy order: trigger above market = stop loss, below = take profit
450                        if trigger_price > current {
451                            HyperliquidExchangeTpSl::Sl
452                        } else {
453                            HyperliquidExchangeTpSl::Tp
454                        }
455                    }
456                    OrderSide::Sell => {
457                        // Sell order: trigger below market = stop loss, above = take profit
458                        if trigger_price < current {
459                            HyperliquidExchangeTpSl::Sl
460                        } else {
461                            HyperliquidExchangeTpSl::Tp
462                        }
463                    }
464                }
465            } else {
466                // No market price available, default to SL for safety
467                HyperliquidExchangeTpSl::Sl
468            }
469        }
470    }
471}
472
473/// Converts a Nautilus `BarType` to a Hyperliquid bar interval.
474///
475/// # Errors
476///
477/// Returns an error if the bar type uses an unsupported aggregation or step value.
478pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
479    let spec = bar_type.spec();
480    let step = spec.step.get();
481
482    anyhow::ensure!(
483        bar_type.aggregation_source() == AggregationSource::External,
484        "Only EXTERNAL aggregation is supported"
485    );
486
487    let interval = match spec.aggregation {
488        BarAggregation::Minute => match step {
489            1 => OneMinute,
490            3 => ThreeMinutes,
491            5 => FiveMinutes,
492            15 => FifteenMinutes,
493            30 => ThirtyMinutes,
494            _ => anyhow::bail!("Unsupported minute step: {step}"),
495        },
496        BarAggregation::Hour => match step {
497            1 => OneHour,
498            2 => TwoHours,
499            4 => FourHours,
500            8 => EightHours,
501            12 => TwelveHours,
502            _ => anyhow::bail!("Unsupported hour step: {step}"),
503        },
504        BarAggregation::Day => match step {
505            1 => OneDay,
506            3 => ThreeDays,
507            _ => anyhow::bail!("Unsupported day step: {step}"),
508        },
509        BarAggregation::Week if step == 1 => OneWeek,
510        BarAggregation::Month if step == 1 => OneMonth,
511        a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
512    };
513
514    Ok(interval)
515}
516
517/// Converts a Nautilus order to Hyperliquid request using a pre-resolved asset index.
518///
519/// This variant is used when the caller has already resolved the asset index
520/// from the instrument cache (e.g., for SPOT instruments where the index
521/// cannot be derived from the symbol alone). `slippage_bps` controls the
522/// buffer applied when deriving a limit from a stop trigger price.
523pub fn order_to_hyperliquid_request_with_asset(
524    order: &OrderAny,
525    asset: u32,
526    price_decimals: u8,
527    should_normalize_prices: bool,
528    slippage_bps: u32,
529) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
530    order_to_hyperliquid_request_with_asset_and_cloid(
531        order,
532        asset,
533        price_decimals,
534        should_normalize_prices,
535        slippage_bps,
536        Some(Cloid::from_client_order_id(order.client_order_id())),
537    )
538}
539
540/// Converts a Nautilus order to Hyperliquid request with an explicit CLOID.
541pub fn order_to_hyperliquid_request_with_asset_and_cloid(
542    order: &OrderAny,
543    asset: u32,
544    price_decimals: u8,
545    should_normalize_prices: bool,
546    slippage_bps: u32,
547    cloid: Option<Cloid>,
548) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
549    order_to_hyperliquid_request_with_optional_decimals(
550        order,
551        asset,
552        Some(price_decimals),
553        should_normalize_prices,
554        slippage_bps,
555        cloid,
556    )
557}
558
559/// Converts a Nautilus order to Hyperliquid request when the instrument
560/// decimal cap may be unknown. A `None` cap disables local wire-price
561/// validation and falls back to the default two-decimal normalization.
562pub(crate) fn order_to_hyperliquid_request_with_optional_decimals(
563    order: &OrderAny,
564    asset: u32,
565    price_decimals: Option<u8>,
566    should_normalize_prices: bool,
567    slippage_bps: u32,
568    cloid: Option<Cloid>,
569) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
570    let is_buy = matches!(order.order_side(), OrderSide::Buy);
571    let reduce_only = order.is_reduce_only();
572    let order_side = order.order_side();
573    let order_type = order.order_type();
574
575    let normalize_or_validate = |raw: Decimal, label: &str| {
576        normalize_or_validate_wire_price(raw, label, price_decimals, should_normalize_prices)
577    };
578
579    // Normalize decimals to strip trailing zeros, matching the server's
580    // canonical form used for EIP-712 signing hash verification.
581    let price_decimal = if let Some(price) = order.price() {
582        normalize_or_validate(price.as_decimal(), "Price")?
583    } else if matches!(order_type, OrderType::Market) {
584        Decimal::ZERO
585    } else if matches!(
586        order_type,
587        OrderType::StopMarket | OrderType::MarketIfTouched
588    ) {
589        match order.trigger_price() {
590            Some(tp) => {
591                let base = tp.as_decimal().normalize();
592                let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
593                let sig_rounded = round_to_sig_figs(derived, 5);
594                clamp_price_to_precision(sig_rounded, price_decimals.unwrap_or(2), is_buy)
595                    .normalize()
596            }
597            None => Decimal::ZERO,
598        }
599    } else {
600        anyhow::bail!("Limit orders require a price")
601    };
602
603    let size_decimal = order.quantity().as_decimal().normalize();
604
605    // Determine order kind based on order type
606    let kind = match order_type {
607        OrderType::Market => HyperliquidExchangeOrderKind::Limit {
608            limit: HyperliquidExchangeLimitParams {
609                tif: HyperliquidExchangeTif::Ioc,
610            },
611        },
612        OrderType::Limit => {
613            let tif =
614                time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
615            HyperliquidExchangeOrderKind::Limit {
616                limit: HyperliquidExchangeLimitParams { tif },
617            }
618        }
619        OrderType::StopMarket => {
620            if let Some(trigger_price) = order.trigger_price() {
621                let trigger_price_decimal =
622                    normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
623                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
624                HyperliquidExchangeOrderKind::Trigger {
625                    trigger: HyperliquidExchangeTriggerParams {
626                        is_market: true,
627                        trigger_px: trigger_price_decimal,
628                        tpsl,
629                    },
630                }
631            } else {
632                anyhow::bail!("Stop market orders require a trigger price")
633            }
634        }
635        OrderType::StopLimit => {
636            if let Some(trigger_price) = order.trigger_price() {
637                let trigger_price_decimal =
638                    normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
639                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
640                HyperliquidExchangeOrderKind::Trigger {
641                    trigger: HyperliquidExchangeTriggerParams {
642                        is_market: false,
643                        trigger_px: trigger_price_decimal,
644                        tpsl,
645                    },
646                }
647            } else {
648                anyhow::bail!("Stop limit orders require a trigger price")
649            }
650        }
651        OrderType::MarketIfTouched => {
652            if let Some(trigger_price) = order.trigger_price() {
653                let trigger_price_decimal =
654                    normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
655                HyperliquidExchangeOrderKind::Trigger {
656                    trigger: HyperliquidExchangeTriggerParams {
657                        is_market: true,
658                        trigger_px: trigger_price_decimal,
659                        tpsl: HyperliquidExchangeTpSl::Tp,
660                    },
661                }
662            } else {
663                anyhow::bail!("Market-if-touched orders require a trigger price")
664            }
665        }
666        OrderType::LimitIfTouched => {
667            if let Some(trigger_price) = order.trigger_price() {
668                let trigger_price_decimal =
669                    normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
670                HyperliquidExchangeOrderKind::Trigger {
671                    trigger: HyperliquidExchangeTriggerParams {
672                        is_market: false,
673                        trigger_px: trigger_price_decimal,
674                        tpsl: HyperliquidExchangeTpSl::Tp,
675                    },
676                }
677            } else {
678                anyhow::bail!("Limit-if-touched orders require a trigger price")
679            }
680        }
681        _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
682    };
683
684    Ok(HyperliquidExchangePlaceOrderRequest {
685        asset,
686        is_buy,
687        price: price_decimal,
688        size: size_decimal,
689        reduce_only,
690        kind,
691        cloid,
692    })
693}
694
695/// Default slippage buffer in basis points for MARKET orders.
696pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
697
698/// Derives a market order limit price from a quote with a configurable
699/// slippage buffer in basis points, rounded to 5 significant figures and
700/// clamped to the instrument's price precision.
701pub fn derive_market_order_price(
702    quote: &QuoteTick,
703    is_buy: bool,
704    price_decimals: u8,
705    slippage_bps: u32,
706) -> Decimal {
707    let base = if is_buy {
708        quote.ask_price.as_decimal()
709    } else {
710        quote.bid_price.as_decimal()
711    };
712    let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
713    let sig_rounded = round_to_sig_figs(derived, 5);
714    clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
715}
716
717/// Derives a limit price from a trigger price with a configurable
718/// slippage buffer in basis points, widening the limit so BUY satisfies
719/// `limit_px >= trigger_px` and SELL satisfies `limit_px <= trigger_px`.
720pub fn derive_limit_from_trigger(
721    trigger_price: Decimal,
722    is_buy: bool,
723    slippage_bps: u32,
724) -> Decimal {
725    // bps -> Decimal: e.g. 50 bps -> 0.005
726    let slippage = Decimal::new(slippage_bps as i64, 4);
727    let price = if is_buy {
728        trigger_price * (Decimal::ONE + slippage)
729    } else {
730        trigger_price * (Decimal::ONE - slippage)
731    };
732
733    // Strip trailing zeros for EIP-712 signing hash verification
734    price.normalize()
735}
736
737/// Clamp a price to the instrument's decimal precision,
738/// rounding in the direction that preserves the slippage buffer.
739pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
740    let scale = Decimal::from(10_u64.pow(decimals as u32));
741
742    if is_buy {
743        (price * scale).ceil() / scale
744    } else {
745        (price * scale).floor() / scale
746    }
747}
748
749/// Converts a client order ID to a Hyperliquid cancel request using a pre-resolved asset index.
750pub fn client_order_id_to_cancel_request_with_asset(
751    client_order_id: &str,
752    asset: u32,
753) -> HyperliquidExchangeCancelByCloidRequest {
754    let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
755    HyperliquidExchangeCancelByCloidRequest { asset, cloid }
756}
757
758/// Extracts per-item error from a successful Hyperliquid exchange response.
759///
760/// When the top-level status is "ok", individual items in the `statuses`
761/// array may still contain errors. Returns the first error found, or
762/// `None` if all items succeeded or the response cannot be parsed.
763pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
764    let HyperliquidExchangeResponse::Status { response, .. } = response else {
765        return None;
766    };
767    let data: HyperliquidExchangeResponseData = serde_json::from_value(response.clone()).ok()?;
768    match data {
769        HyperliquidExchangeResponseData::Order { data } => {
770            for status in &data.statuses {
771                if let HyperliquidExchangeOrderStatus::Error { error } = status {
772                    return Some(error.clone());
773                }
774            }
775            None
776        }
777        HyperliquidExchangeResponseData::Cancel { data } => {
778            for status in &data.statuses {
779                if let HyperliquidExchangeCancelStatus::Error { error } = status {
780                    return Some(error.clone());
781                }
782            }
783            None
784        }
785        HyperliquidExchangeResponseData::Modify { data } => {
786            for status in &data.statuses {
787                if let HyperliquidExchangeModifyStatus::Error { error } = status {
788                    return Some(error.clone());
789                }
790            }
791            None
792        }
793        _ => None,
794    }
795}
796
797/// Extracts per-item errors from a successful batch response.
798///
799/// Returns a `Vec` with one `Option<String>` per item in the `statuses`
800/// array: `Some(error)` for failed items, `None` for successful ones.
801/// Returns an empty vec if the response cannot be parsed.
802pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
803    let HyperliquidExchangeResponse::Status { response, .. } = response else {
804        return Vec::new();
805    };
806    let Ok(data) = serde_json::from_value::<HyperliquidExchangeResponseData>(response.clone())
807    else {
808        return Vec::new();
809    };
810
811    match data {
812        HyperliquidExchangeResponseData::Order { data } => data
813            .statuses
814            .into_iter()
815            .map(|s| match s {
816                HyperliquidExchangeOrderStatus::Error { error } => Some(error),
817                _ => None,
818            })
819            .collect(),
820        HyperliquidExchangeResponseData::Cancel { data } => data
821            .statuses
822            .into_iter()
823            .map(|s| match s {
824                HyperliquidExchangeCancelStatus::Error { error } => Some(error),
825                HyperliquidExchangeCancelStatus::Success(_) => None,
826            })
827            .collect(),
828        HyperliquidExchangeResponseData::Modify { data } => data
829            .statuses
830            .into_iter()
831            .map(|s| match s {
832                HyperliquidExchangeModifyStatus::Error { error } => Some(error),
833                HyperliquidExchangeModifyStatus::Success(_) => None,
834            })
835            .collect(),
836        _ => Vec::new(),
837    }
838}
839
840/// Extracts error message from a Hyperliquid exchange response.
841pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
842    match response {
843        HyperliquidExchangeResponse::Status { status, response } => {
844            if status == RESPONSE_STATUS_OK {
845                "Operation successful".to_string()
846            } else {
847                // Try to extract error message from response data
848                if let Some(error_msg) = response
849                    .as_str()
850                    .or_else(|| response.get("error").and_then(|v| v.as_str()))
851                    .or_else(|| {
852                        (response.get("type").and_then(|v| v.as_str()) == Some("error"))
853                            .then(|| response.get("data").and_then(|v| v.as_str()))
854                            .flatten()
855                    })
856                {
857                    error_msg.to_string()
858                } else {
859                    format!("Request failed with status: {status}")
860                }
861            }
862        }
863        HyperliquidExchangeResponse::Error { error } => error.clone(),
864    }
865}
866
867/// Determines if an order is a conditional/trigger order based on order data.
868///
869/// # Returns
870///
871/// `true` if the order is a conditional order, `false` otherwise.
872pub fn is_conditional_order_data(
873    trigger_px: Option<Decimal>,
874    tpsl: Option<&HyperliquidTpSl>,
875) -> bool {
876    trigger_px.is_some() && tpsl.is_some()
877}
878
879/// Parses trigger order type from Hyperliquid order data.
880///
881/// # Returns
882///
883/// The corresponding Nautilus `OrderType`.
884pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
885    match (is_market, tpsl) {
886        (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
887        (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
888        (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
889        (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
890    }
891}
892
893/// Extracts order status from WebSocket order data.
894///
895/// # Returns
896///
897/// A tuple of (OrderStatus, optional trigger status string).
898pub fn parse_order_status_with_trigger(
899    status: HyperliquidOrderStatus,
900    trigger_activated: Option<bool>,
901) -> (OrderStatus, Option<String>) {
902    let base_status = OrderStatus::from(status);
903
904    // For conditional orders, add trigger status information
905    if let Some(activated) = trigger_activated {
906        let trigger_status = if activated {
907            Some("activated".to_string())
908        } else {
909            Some("pending".to_string())
910        };
911        (base_status, trigger_status)
912    } else {
913        (base_status, None)
914    }
915}
916
917/// Converts WebSocket trailing stop data to description string.
918pub fn format_trailing_stop_info(
919    offset: &str,
920    offset_type: TrailingOffsetType,
921    callback_price: Option<&str>,
922) -> String {
923    let offset_desc = offset_type.format_offset(offset);
924
925    if let Some(callback) = callback_price {
926        format!("Trailing stop: {offset_desc} offset, callback at {callback}")
927    } else {
928        format!("Trailing stop: {offset_desc} offset")
929    }
930}
931
932/// Validates conditional order parameters from WebSocket data.
933///
934/// # Returns
935///
936/// `Ok(())` if parameters are valid, `Err` with description otherwise.
937pub fn validate_conditional_order_params(
938    trigger_px: Option<&str>,
939    tpsl: Option<&HyperliquidTpSl>,
940    is_market: Option<bool>,
941) -> anyhow::Result<()> {
942    if trigger_px.is_none() {
943        anyhow::bail!("Conditional order missing trigger price");
944    }
945
946    if tpsl.is_none() {
947        anyhow::bail!("Conditional order missing tpsl indicator");
948    }
949
950    // No need to validate tpsl value - the enum type guarantees it's either Tp or Sl
951
952    if is_market.is_none() {
953        anyhow::bail!("Conditional order missing is_market flag");
954    }
955
956    Ok(())
957}
958
959/// Parses trigger price from string to Decimal.
960///
961/// # Returns
962///
963/// Parsed Decimal value or error.
964pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
965    Decimal::from_str_exact(trigger_px)
966        .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
967}
968
969/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
970///
971/// Uses the same field selection as the HTTP account-state path
972/// (`cross_margin_summary.total_raw_usd` for total, top-level `state.withdrawable`
973/// for free) so the execution adapter and the HTTP client emit consistent balances
974/// for the same clearinghouse snapshot.
975///
976/// # Errors
977///
978/// Returns an error if the data cannot be parsed.
979pub fn parse_account_balances_and_margins(
980    state: &ClearinghouseState,
981) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
982    let mut balances = Vec::new();
983    let mut margins = Vec::new();
984
985    let currency = Currency::USDC();
986
987    let cross_margin_summary = match &state.cross_margin_summary {
988        Some(summary) => summary,
989        None => return Ok((balances, margins)),
990    };
991
992    let mut total_value = cross_margin_summary.total_raw_usd;
993    let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
994
995    // Withdrawable may include spot balances that sit outside a positive margin
996    // account value; raise total so those funds are not silently clamped away.
997    if total_value >= Decimal::ZERO && free_value > total_value {
998        total_value = free_value;
999    }
1000
1001    balances.push(AccountBalance::from_total_and_free(
1002        total_value,
1003        free_value,
1004        currency,
1005    )?);
1006
1007    let margin_used = cross_margin_summary.total_margin_used;
1008
1009    if margin_used > Decimal::ZERO {
1010        // Hyperliquid perps use a single-collateral (USDC) cross-margin model, so the
1011        // reserved margin is emitted as an account-wide entry keyed by USDC.
1012        let initial_margin = Money::from_decimal(margin_used, currency)?;
1013        let maintenance_margin = Money::from_decimal(margin_used, currency)?;
1014        margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1015    }
1016
1017    Ok((balances, margins))
1018}
1019
1020/// Merges perp clearinghouse balances with spot balances into a unified set.
1021///
1022/// The perp parser already reflects combined USDC when its cross-margin summary
1023/// carries collateral or margin state, so this parser appends only non-USDC spot
1024/// tokens in that case. If the perp state has no margin summary, or the summary
1025/// is present but zeroed, spot USDC is used verbatim.
1026///
1027/// # Errors
1028///
1029/// Returns an error if any balance conversion fails.
1030pub fn parse_combined_account_balances_and_margins(
1031    perp_state: &ClearinghouseState,
1032    spot_state: &SpotClearinghouseState,
1033) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1034    let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
1035
1036    let perp_reflects_usdc = perp_state
1037        .cross_margin_summary
1038        .as_ref()
1039        .is_some_and(|summary| {
1040            summary.total_raw_usd != Decimal::ZERO
1041                || summary.total_margin_used > Decimal::ZERO
1042                || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
1043        });
1044
1045    if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
1046        balances.retain(|balance| balance.currency.code != "USDC");
1047    }
1048
1049    let spot_balances = parse_spot_account_balances(spot_state)?;
1050
1051    for balance in spot_balances {
1052        let is_usdc = balance.currency.code == "USDC";
1053        if perp_reflects_usdc && is_usdc {
1054            continue;
1055        }
1056        balances.push(balance);
1057    }
1058
1059    Ok((balances, margins))
1060}
1061
1062/// Parses Hyperliquid spot clearinghouse state into Nautilus account balances.
1063///
1064/// Emits one [`AccountBalance`] per non-zero spot token, deriving free from
1065/// `total - hold`. Tokens unknown to the global currency registry are registered
1066/// on the fly with 8-decimal precision (matches Hyperliquid's `sz_decimals` cap).
1067///
1068/// # Errors
1069///
1070/// Returns an error if any balance cannot be converted to a Nautilus `Money`.
1071pub fn parse_spot_account_balances(
1072    state: &SpotClearinghouseState,
1073) -> anyhow::Result<Vec<AccountBalance>> {
1074    let mut balances = Vec::with_capacity(state.balances.len());
1075
1076    for balance in &state.balances {
1077        if balance.total.is_zero() {
1078            continue;
1079        }
1080
1081        let currency = crate::http::parse::get_currency(balance.coin.as_str());
1082
1083        // Let `from_total_and_locked` do the clamping and derivation at currency
1084        // precision so the `total == locked + free` invariant holds without
1085        // bespoke rounding here.
1086        balances.push(AccountBalance::from_total_and_locked(
1087            balance.total,
1088            balance.hold,
1089            currency,
1090        )?);
1091    }
1092
1093    Ok(balances)
1094}
1095
1096/// Determine the Hyperliquid grouping strategy for an order list.
1097///
1098/// Contingency type, reduce-only flags, structural shape, and parent/child
1099/// linkage must all agree to avoid misclassifying generic contingent lists
1100/// as Hyperliquid TP/SL groups.
1101///
1102/// - `NormalTpsl` (OTOCO bracket): entry order is OTO and not reduce-only,
1103///   all child orders are OCO or OUO, reduce-only, and reference the entry as parent.
1104/// - `PositionTpsl` (linked exit pair): every order is OCO or OUO, reduce-only,
1105///   and linked to the same sibling set.
1106/// - `Na`: everything else (independent batch).
1107pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExchangeGrouping {
1108    if orders.len() >= 2 {
1109        let entry = &orders[0];
1110        let children = &orders[1..];
1111        let entry_id = entry.client_order_id();
1112        let entry_is_oto =
1113            entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1114        let children_are_linked = children.iter().all(|o| {
1115            matches!(
1116                o.contingency_type(),
1117                Some(ContingencyType::Oco | ContingencyType::Ouo)
1118            ) && o.is_reduce_only()
1119                && o.parent_order_id() == Some(entry_id)
1120        });
1121
1122        if entry_is_oto && children_are_linked {
1123            return HyperliquidExchangeGrouping::NormalTpsl;
1124        }
1125    }
1126
1127    let all_oco_linked = orders.len() >= 2
1128        && orders.iter().all(|o| {
1129            matches!(
1130                o.contingency_type(),
1131                Some(ContingencyType::Oco | ContingencyType::Ouo)
1132            ) && o.is_reduce_only()
1133        })
1134        && orders.iter().all(|o| {
1135            o.linked_order_ids().is_some_and(|ids| {
1136                ids.iter()
1137                    .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1138            })
1139        });
1140
1141    if all_oco_linked {
1142        HyperliquidExchangeGrouping::PositionTpsl
1143    } else {
1144        HyperliquidExchangeGrouping::Na
1145    }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use std::str::FromStr;
1151
1152    use nautilus_model::{
1153        enums::{OrderSide, TimeInForce, TriggerType},
1154        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1155        orders::{LimitOrder, OrderAny, StopMarketOrder},
1156        types::{Price, Quantity},
1157    };
1158    use rstest::rstest;
1159    use rust_decimal::Decimal;
1160    use rust_decimal_macros::dec;
1161    use serde::{Deserialize, Serialize};
1162
1163    use super::*;
1164
1165    #[rstest]
1166    fn test_make_fill_trade_id_is_stable() {
1167        // Pins the deterministic FNV output so the Decimal `Display` hashing
1168        // stays stable for reconciliation dedup across the String->Decimal change.
1169        let id = make_fill_trade_id(
1170            "0xabc123",
1171            12345,
1172            dec!(50000.0),
1173            dec!(0.1),
1174            1704470400000,
1175            dec!(0.0),
1176        );
1177        assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1178    }
1179
1180    #[derive(Serialize, Deserialize)]
1181    struct TestStruct {
1182        #[serde(
1183            serialize_with = "serialize_decimal_as_str",
1184            deserialize_with = "deserialize_decimal_from_str"
1185        )]
1186        value: Decimal,
1187        #[serde(
1188            serialize_with = "serialize_optional_decimal_as_str",
1189            deserialize_with = "deserialize_optional_decimal_from_str"
1190        )]
1191        optional_value: Option<Decimal>,
1192    }
1193
1194    #[rstest]
1195    #[case("#10", 100_000_010, 1, 0)]
1196    #[case("+10", 100_000_010, 1, 0)]
1197    #[case("#31", 100_000_031, 3, 1)]
1198    #[case("+31", 100_000_031, 3, 1)]
1199    fn test_parse_outcome_symbol(
1200        #[case] symbol: &str,
1201        #[case] raw_asset_id: u32,
1202        #[case] outcome: u32,
1203        #[case] side: u8,
1204    ) {
1205        let asset_id = parse_outcome_symbol(symbol).unwrap();
1206        assert_eq!(asset_id.to_raw(), raw_asset_id);
1207        assert_eq!(asset_id.outcome_index(), Some(outcome));
1208        assert_eq!(asset_id.outcome_side(), Some(side));
1209    }
1210
1211    #[rstest]
1212    #[case("25-YES-OUTCOME", 25, 0)]
1213    #[case("25-NO-OUTCOME", 25, 1)]
1214    #[case("0-YES-OUTCOME", 0, 0)]
1215    #[case("999-NO-OUTCOME", 999, 1)]
1216    fn test_parse_outcome_nautilus_symbol(
1217        #[case] symbol: &str,
1218        #[case] outcome_index: u32,
1219        #[case] side: u8,
1220    ) {
1221        let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1222        assert_eq!(parsed, (outcome_index, side));
1223    }
1224
1225    #[rstest]
1226    #[case("25-OUTCOME")]
1227    #[case("25-MAYBE-OUTCOME")]
1228    #[case("25-yes-OUTCOME")]
1229    #[case("-YES-OUTCOME")]
1230    #[case("YES-25-OUTCOME")]
1231    #[case("25-YES-outcome")]
1232    #[case("25-YES")]
1233    fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1234        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1235    }
1236
1237    #[rstest]
1238    // outcome_index * 10 overflows u32.
1239    #[case("999999999-YES-OUTCOME")]
1240    // outcome_index * 10 fits but 100_000_000 + encoding overflows u32.
1241    #[case("429496729-YES-OUTCOME")]
1242    // u32::MAX itself; rejected on the multiply.
1243    #[case("4294967295-NO-OUTCOME")]
1244    fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1245        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1246    }
1247
1248    #[rstest]
1249    #[case(25, 0, "25-YES-OUTCOME")]
1250    #[case(25, 1, "25-NO-OUTCOME")]
1251    #[case(0, 0, "0-YES-OUTCOME")]
1252    fn test_format_outcome_nautilus_symbol(
1253        #[case] outcome_index: u32,
1254        #[case] side: u8,
1255        #[case] expected: &str,
1256    ) {
1257        assert_eq!(
1258            format_outcome_nautilus_symbol(outcome_index, side),
1259            expected,
1260        );
1261    }
1262
1263    #[rstest]
1264    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1265    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1266    #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1267    #[case("BTC-USD-PERP", None)]
1268    #[case("+250", None)]
1269    fn test_outcome_token_from_nautilus_symbol(
1270        #[case] symbol: &str,
1271        #[case] expected: Option<String>,
1272    ) {
1273        assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1274    }
1275
1276    #[rstest]
1277    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1278    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1279    #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1280    #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1281    #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1282    #[case("+250", Some("+250".to_string()))]
1283    #[case("#250", Some("#250".to_string()))]
1284    #[case("", None)]
1285    fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1286        assert_eq!(cache_alias_for_symbol(symbol), expected);
1287    }
1288
1289    #[rstest]
1290    #[case("10", "expected #<encoding> or +<encoding>")]
1291    #[case("#", "encoding must not be empty")]
1292    #[case("#1a", "encoding must be numeric")]
1293    #[case("#12", "side digit 0 or 1")]
1294    #[case("#4294967295", "fit u32")]
1295    fn test_parse_outcome_symbol_rejects_invalid_values(
1296        #[case] symbol: &str,
1297        #[case] expected_error: &str,
1298    ) {
1299        let err = parse_outcome_symbol(symbol).unwrap_err();
1300        assert!(
1301            err.to_string().contains(expected_error),
1302            "expected error to contain '{expected_error}', received '{err}'",
1303        );
1304    }
1305
1306    #[rstest]
1307    fn test_decimal_serialization_roundtrip() {
1308        let original = TestStruct {
1309            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1310            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1311        };
1312
1313        let json = serde_json::to_string(&original).unwrap();
1314        println!("Serialized: {json}");
1315
1316        // Check that it's serialized as strings (rust_decimal may normalize precision)
1317        assert!(json.contains("\"123.45678901234567890123456789\""));
1318        assert!(json.contains("\"0.000000001\""));
1319
1320        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1321        assert_eq!(original.value, deserialized.value);
1322        assert_eq!(original.optional_value, deserialized.optional_value);
1323    }
1324
1325    #[rstest]
1326    fn test_decimal_precision_preservation() {
1327        let test_cases = [
1328            "0",
1329            "1",
1330            "0.1",
1331            "0.01",
1332            "0.001",
1333            "123.456789012345678901234567890",
1334            "999999999999999999.999999999999999999",
1335        ];
1336
1337        for case in test_cases {
1338            let decimal = Decimal::from_str(case).unwrap();
1339            let test_struct = TestStruct {
1340                value: decimal,
1341                optional_value: Some(decimal),
1342            };
1343
1344            let json = serde_json::to_string(&test_struct).unwrap();
1345            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1346
1347            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1348            assert_eq!(
1349                Some(decimal),
1350                parsed.optional_value,
1351                "Failed for case: {case}"
1352            );
1353        }
1354    }
1355
1356    #[rstest]
1357    fn test_optional_none_handling() {
1358        let test_struct = TestStruct {
1359            value: Decimal::from_str("42.0").unwrap(),
1360            optional_value: None,
1361        };
1362
1363        let json = serde_json::to_string(&test_struct).unwrap();
1364        assert!(json.contains("null"));
1365
1366        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1367        assert_eq!(test_struct.value, parsed.value);
1368        assert_eq!(None, parsed.optional_value);
1369    }
1370
1371    #[rstest]
1372    fn test_round_down_to_tick() {
1373        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1374        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1375        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1376
1377        // Edge case: zero tick size
1378        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1379    }
1380
1381    #[rstest]
1382    fn test_round_down_to_step() {
1383        assert_eq!(
1384            round_down_to_step(dec!(0.12349), dec!(0.0001)),
1385            dec!(0.1234)
1386        );
1387        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1388        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1389
1390        // Edge case: zero step size
1391        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1392    }
1393
1394    #[rstest]
1395    fn test_min_notional_validation() {
1396        // Should pass
1397        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1398        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1399
1400        // Should fail
1401        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1402        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1403
1404        // Edge case: exactly at minimum
1405        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1406    }
1407
1408    #[rstest]
1409    fn test_round_to_sig_figs() {
1410        // BTC price ~$104,567 needs to round to 5 sig figs
1411        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1412        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1413        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1414
1415        // Smaller prices should keep decimals
1416        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1417        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1418        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1419
1420        // Sub-1 values with leading zeros must preserve 5 sig figs
1421        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1422        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
1423
1424        // Zero case
1425        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1426
1427        assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1428        assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1429        assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1430        assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1431    }
1432
1433    #[rstest]
1434    fn test_normalize_price() {
1435        // Now includes 5 sig fig rounding first
1436        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1437        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
1438        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
1439        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
1440
1441        // BTC-like prices get rounded to 5 sig figs
1442        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
1443    }
1444
1445    #[rstest]
1446    fn test_normalize_quantity() {
1447        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1448        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1449        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1450        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1451    }
1452
1453    #[rstest]
1454    fn test_normalize_order_complete() {
1455        let result = normalize_order(
1456            dec!(100.12345), // price
1457            dec!(0.123456),  // qty
1458            dec!(0.01),      // tick_size
1459            dec!(0.0001),    // step_size
1460            dec!(10),        // min_notional
1461            2,               // price_decimals
1462            4,               // size_decimals
1463        );
1464
1465        assert!(result.is_ok());
1466        let (price, qty) = result.unwrap();
1467        assert_eq!(price, dec!(100.12)); // normalized and rounded down
1468        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
1469    }
1470
1471    #[rstest]
1472    fn test_normalize_order_min_notional_fail() {
1473        let result = normalize_order(
1474            dec!(100.12345), // price
1475            dec!(0.05),      // qty (too small for min notional)
1476            dec!(0.01),      // tick_size
1477            dec!(0.0001),    // step_size
1478            dec!(10),        // min_notional
1479            2,               // price_decimals
1480            4,               // size_decimals
1481        );
1482
1483        assert!(result.is_err());
1484        assert!(result.unwrap_err().contains("Notional value"));
1485    }
1486
1487    #[rstest]
1488    fn test_edge_cases() {
1489        // Test with very small numbers
1490        assert_eq!(
1491            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1492            dec!(0.000001)
1493        );
1494
1495        // Test with large numbers
1496        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1497
1498        // Test rounding edge case
1499        assert_eq!(
1500            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1501            dec!(100.00)
1502        );
1503    }
1504
1505    #[rstest]
1506    fn test_is_conditional_order_data() {
1507        // Test with trigger price and tpsl (conditional)
1508        assert!(is_conditional_order_data(
1509            Some(dec!(50000.0)),
1510            Some(&HyperliquidTpSl::Sl)
1511        ));
1512
1513        // Test with only trigger price (not conditional - needs both)
1514        assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1515
1516        // Test with only tpsl (not conditional - needs both)
1517        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1518
1519        // Test with no conditional fields
1520        assert!(!is_conditional_order_data(None, None));
1521    }
1522
1523    #[rstest]
1524    fn test_parse_trigger_order_type() {
1525        // Stop Market
1526        assert_eq!(
1527            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1528            OrderType::StopMarket
1529        );
1530
1531        // Stop Limit
1532        assert_eq!(
1533            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1534            OrderType::StopLimit
1535        );
1536
1537        // Take Profit Market
1538        assert_eq!(
1539            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1540            OrderType::MarketIfTouched
1541        );
1542
1543        // Take Profit Limit
1544        assert_eq!(
1545            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1546            OrderType::LimitIfTouched
1547        );
1548    }
1549
1550    #[rstest]
1551    fn test_parse_order_status_with_trigger() {
1552        // Test with open status and activated trigger
1553        let (status, trigger_status) =
1554            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1555        assert_eq!(status, OrderStatus::Accepted);
1556        assert_eq!(trigger_status, Some("activated".to_string()));
1557
1558        // Test with open status and not activated
1559        let (status, trigger_status) =
1560            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1561        assert_eq!(status, OrderStatus::Accepted);
1562        assert_eq!(trigger_status, Some("pending".to_string()));
1563
1564        // Test without trigger info
1565        let (status, trigger_status) =
1566            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1567        assert_eq!(status, OrderStatus::Accepted);
1568        assert_eq!(trigger_status, None);
1569    }
1570
1571    #[rstest]
1572    fn test_format_trailing_stop_info() {
1573        // Price offset
1574        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1575        assert!(info.contains("100.0"));
1576        assert!(info.contains("callback at 50000.0"));
1577
1578        // Percentage offset
1579        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1580        assert!(info.contains("5.0%"));
1581        assert!(info.contains("Trailing stop"));
1582
1583        // Basis points offset
1584        let info =
1585            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1586        assert!(info.contains("250 bps"));
1587        assert!(info.contains("49000.0"));
1588    }
1589
1590    #[rstest]
1591    fn test_parse_trigger_price() {
1592        // Valid price
1593        let result = parse_trigger_price("50000.0");
1594        assert!(result.is_ok());
1595        assert_eq!(result.unwrap(), dec!(50000.0));
1596
1597        // Valid integer price
1598        let result = parse_trigger_price("49000");
1599        assert!(result.is_ok());
1600        assert_eq!(result.unwrap(), dec!(49000));
1601
1602        // Invalid price
1603        let result = parse_trigger_price("invalid");
1604        assert!(result.is_err());
1605
1606        // Empty string
1607        let result = parse_trigger_price("");
1608        assert!(result.is_err());
1609    }
1610
1611    #[rstest]
1612    #[case(dec!(0), true, dec!(0))] // Zero
1613    #[case(dec!(0), false, dec!(0))] // Zero
1614    #[case(dec!(0.001), true, dec!(0.001005))] // Small price BUY
1615    #[case(dec!(0.001), false, dec!(0.000995))] // Small price SELL
1616    #[case(dec!(100), true, dec!(100.5))] // Round price BUY
1617    #[case(dec!(100), false, dec!(99.5))] // Round price SELL
1618    #[case(dec!(2470), true, dec!(2482.35))] // ETH-like BUY
1619    #[case(dec!(2470), false, dec!(2457.65))] // ETH-like SELL
1620    #[case(dec!(104567.3), true, dec!(105090.1365))] // BTC-like BUY
1621    #[case(dec!(104567.3), false, dec!(104044.4635))] // BTC-like SELL
1622    fn test_derive_limit_from_trigger(
1623        #[case] trigger_price: Decimal,
1624        #[case] is_buy: bool,
1625        #[case] expected: Decimal,
1626    ) {
1627        let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1628        assert_eq!(result, expected);
1629
1630        // Verify invariant: BUY limit >= trigger, SELL limit <= trigger
1631        if is_buy {
1632            assert!(result >= trigger_price);
1633        } else {
1634            assert!(result <= trigger_price);
1635        }
1636    }
1637
1638    #[rstest]
1639    // BUY rounds up (ceil)
1640    #[case(dec!(2457.65), 2, true, dec!(2457.65))] // Already at precision
1641    #[case(dec!(2457.65), 1, true, dec!(2457.7))] // Ceil to 1dp
1642    #[case(dec!(2457.65), 0, true, dec!(2458))] // Ceil to integer
1643    // SELL rounds down (floor)
1644    #[case(dec!(2457.65), 2, false, dec!(2457.65))] // Already at precision
1645    #[case(dec!(2457.65), 1, false, dec!(2457.6))] // Floor to 1dp
1646    #[case(dec!(2457.65), 0, false, dec!(2457))] // Floor to integer
1647    // High precision (no-op)
1648    #[case(dec!(0.4975), 4, true, dec!(0.4975))]
1649    #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1650    // Precision forces clamping on small values
1651    #[case(dec!(0.4975), 2, true, dec!(0.50))]
1652    #[case(dec!(0.4975), 2, false, dec!(0.49))]
1653    fn test_clamp_price_to_precision(
1654        #[case] price: Decimal,
1655        #[case] decimals: u8,
1656        #[case] is_buy: bool,
1657        #[case] expected: Decimal,
1658    ) {
1659        assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1660    }
1661
1662    fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1663        OrderAny::StopMarket(StopMarketOrder::new(
1664            TraderId::from("TESTER-001"),
1665            StrategyId::from("S-001"),
1666            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1667            ClientOrderId::from("O-001"),
1668            side,
1669            Quantity::from(1),
1670            Price::from(trigger_price),
1671            TriggerType::LastPrice,
1672            TimeInForce::Gtc,
1673            None,
1674            false,
1675            false,
1676            None,
1677            None,
1678            None,
1679            None,
1680            None,
1681            None,
1682            None,
1683            None,
1684            None,
1685            None,
1686            None,
1687            Default::default(),
1688            Default::default(),
1689        ))
1690    }
1691
1692    #[rstest]
1693    // ETH-like (precision=2): clamping is a no-op
1694    #[case(OrderSide::Sell, "2470.00", 2)]
1695    #[case(OrderSide::Buy, "2470.00", 2)]
1696    // BTC-like (precision=1): clamping is a no-op
1697    #[case(OrderSide::Sell, "104567.3", 1)]
1698    #[case(OrderSide::Buy, "104567.3", 1)]
1699    // Low-price token (precision=4): clamping is a no-op
1700    #[case(OrderSide::Sell, "0.50", 4)]
1701    #[case(OrderSide::Buy, "0.50", 4)]
1702    // Clamping materially changes: ETH trigger at precision=1
1703    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(1dp) = 2457.6
1704    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(1dp) = 2482.4
1705    #[case(OrderSide::Sell, "2470.00", 1)]
1706    #[case(OrderSide::Buy, "2470.00", 1)]
1707    // Clamping materially changes: precision=0 forces integer
1708    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(0dp) = 2457
1709    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(0dp) = 2483
1710    #[case(OrderSide::Sell, "2470.00", 0)]
1711    #[case(OrderSide::Buy, "2470.00", 0)]
1712    fn test_order_to_request_stop_market_derives_limit_from_trigger(
1713        #[case] side: OrderSide,
1714        #[case] trigger_str: &str,
1715        #[case] price_decimals: u8,
1716    ) {
1717        let order = stop_market_order(side, trigger_str);
1718        let request = order_to_hyperliquid_request_with_asset(
1719            &order,
1720            0,
1721            price_decimals,
1722            true,
1723            DEFAULT_MARKET_SLIPPAGE_BPS,
1724        )
1725        .unwrap();
1726        let trigger = Decimal::from_str(trigger_str).unwrap();
1727        let is_buy = matches!(side, OrderSide::Buy);
1728
1729        // Price must satisfy Hyperliquid's directional constraint
1730        if is_buy {
1731            assert!(
1732                request.price >= trigger,
1733                "BUY limit {} must be >= trigger {trigger}",
1734                request.price,
1735            );
1736            assert!(request.is_buy);
1737        } else {
1738            assert!(
1739                request.price <= trigger,
1740                "SELL limit {} must be <= trigger {trigger}",
1741                request.price,
1742            );
1743            assert!(!request.is_buy);
1744        }
1745
1746        // Price must equal the full pipeline: derive -> sig figs -> clamp -> normalize
1747        let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1748        let sig_rounded = round_to_sig_figs(derived, 5);
1749        let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1750        assert_eq!(request.price, expected);
1751
1752        // Decimal places must not exceed instrument precision
1753        let price_str = request.price.to_string();
1754        let actual_decimals = price_str
1755            .find('.')
1756            .map_or(0, |dot| price_str.len() - dot - 1);
1757        assert!(
1758            actual_decimals <= price_decimals as usize,
1759            "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1760        );
1761
1762        // Decimal trailing zeros must be stripped (canonical form)
1763        if price_str.contains('.') {
1764            assert!(
1765                !price_str.ends_with('0'),
1766                "Price {price_str} has decimal trailing zeros",
1767            );
1768        }
1769
1770        let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1771        assert_eq!(
1772            request.kind,
1773            HyperliquidExchangeOrderKind::Trigger {
1774                trigger: HyperliquidExchangeTriggerParams {
1775                    is_market: true,
1776                    trigger_px: expected_trigger,
1777                    tpsl: HyperliquidExchangeTpSl::Sl,
1778                },
1779            },
1780        );
1781    }
1782
1783    fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1784        HyperliquidExchangeResponse::Status {
1785            status: "ok".to_string(),
1786            response: inner,
1787        }
1788    }
1789
1790    #[rstest]
1791    fn test_extract_inner_error_order_with_error() {
1792        let response = ok_response(serde_json::json!({
1793            "type": "order",
1794            "data": {"statuses": [{"error": "Order has invalid price."}]}
1795        }));
1796        assert_eq!(
1797            extract_inner_error(&response),
1798            Some("Order has invalid price.".to_string()),
1799        );
1800    }
1801
1802    #[rstest]
1803    fn test_extract_inner_error_order_resting() {
1804        let response = ok_response(serde_json::json!({
1805            "type": "order",
1806            "data": {"statuses": [{"resting": {"oid": 12345}}]}
1807        }));
1808        assert_eq!(extract_inner_error(&response), None);
1809    }
1810
1811    #[rstest]
1812    fn test_extract_inner_error_order_filled() {
1813        let response = ok_response(serde_json::json!({
1814            "type": "order",
1815            "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1816        }));
1817        assert_eq!(extract_inner_error(&response), None);
1818    }
1819
1820    #[rstest]
1821    fn test_extract_inner_error_cancel_error() {
1822        let response = ok_response(serde_json::json!({
1823            "type": "cancel",
1824            "data": {"statuses": [{"error": "Order not found"}]}
1825        }));
1826        assert_eq!(
1827            extract_inner_error(&response),
1828            Some("Order not found".to_string()),
1829        );
1830    }
1831
1832    #[rstest]
1833    fn test_extract_inner_error_cancel_success() {
1834        let response = ok_response(serde_json::json!({
1835            "type": "cancel",
1836            "data": {"statuses": ["success"]}
1837        }));
1838        assert_eq!(extract_inner_error(&response), None);
1839    }
1840
1841    #[rstest]
1842    fn test_extract_inner_error_modify_error() {
1843        let response = ok_response(serde_json::json!({
1844            "type": "modify",
1845            "data": {"statuses": [{"error": "Invalid modify"}]}
1846        }));
1847        assert_eq!(
1848            extract_inner_error(&response),
1849            Some("Invalid modify".to_string()),
1850        );
1851    }
1852
1853    #[rstest]
1854    fn test_extract_inner_error_modify_success() {
1855        let response = ok_response(serde_json::json!({
1856            "type": "modify",
1857            "data": {"statuses": ["success"]}
1858        }));
1859        assert_eq!(extract_inner_error(&response), None);
1860    }
1861
1862    #[rstest]
1863    fn test_extract_inner_error_non_status_response() {
1864        let response = HyperliquidExchangeResponse::Error {
1865            error: "top-level error".to_string(),
1866        };
1867        assert_eq!(extract_inner_error(&response), None);
1868    }
1869
1870    #[rstest]
1871    fn test_extract_inner_error_unparsable_response() {
1872        let response = ok_response(serde_json::json!({"unknown": "data"}));
1873        assert_eq!(extract_inner_error(&response), None);
1874    }
1875
1876    #[rstest]
1877    fn test_extract_inner_error_returns_first_error_in_batch() {
1878        let response = ok_response(serde_json::json!({
1879            "type": "order",
1880            "data": {"statuses": [
1881                {"resting": {"oid": 1}},
1882                {"error": "Second failed"},
1883                {"error": "Third failed"},
1884            ]}
1885        }));
1886        assert_eq!(
1887            extract_inner_error(&response),
1888            Some("Second failed".to_string()),
1889        );
1890    }
1891
1892    #[rstest]
1893    fn test_extract_inner_errors_mixed_batch() {
1894        let response = ok_response(serde_json::json!({
1895            "type": "order",
1896            "data": {"statuses": [
1897                {"resting": {"oid": 1}},
1898                {"error": "Failed order"},
1899                {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1900            ]}
1901        }));
1902        let errors = extract_inner_errors(&response);
1903        assert_eq!(errors.len(), 3);
1904        assert_eq!(errors[0], None);
1905        assert_eq!(errors[1], Some("Failed order".to_string()));
1906        assert_eq!(errors[2], None);
1907    }
1908
1909    #[rstest]
1910    fn test_extract_inner_errors_all_success() {
1911        let response = ok_response(serde_json::json!({
1912            "type": "order",
1913            "data": {"statuses": [
1914                {"resting": {"oid": 1}},
1915                {"resting": {"oid": 2}},
1916            ]}
1917        }));
1918        let errors = extract_inner_errors(&response);
1919        assert_eq!(errors.len(), 2);
1920        assert!(errors.iter().all(|e| e.is_none()));
1921    }
1922
1923    #[rstest]
1924    fn test_extract_inner_errors_cancel_success() {
1925        let response = ok_response(serde_json::json!({
1926            "type": "cancel",
1927            "data": {"statuses": ["success"]}
1928        }));
1929        let errors = extract_inner_errors(&response);
1930        assert_eq!(errors.len(), 1);
1931        assert!(errors[0].is_none());
1932    }
1933
1934    #[rstest]
1935    fn test_extract_inner_errors_cancel_mixed() {
1936        let response = ok_response(serde_json::json!({
1937            "type": "cancel",
1938            "data": {"statuses": [
1939                "success",
1940                {"error": "Order was never placed, already canceled, or filled."},
1941                "success",
1942            ]}
1943        }));
1944        let errors = extract_inner_errors(&response);
1945        assert_eq!(errors.len(), 3);
1946        assert_eq!(errors[0], None);
1947        assert_eq!(
1948            errors[1],
1949            Some("Order was never placed, already canceled, or filled.".to_string())
1950        );
1951        assert_eq!(errors[2], None);
1952    }
1953
1954    #[rstest]
1955    fn test_extract_inner_errors_modify_mixed() {
1956        let response = ok_response(serde_json::json!({
1957            "type": "modify",
1958            "data": {"statuses": [
1959                "success",
1960                {"error": "Order does not exist"},
1961            ]}
1962        }));
1963        let errors = extract_inner_errors(&response);
1964        assert_eq!(errors.len(), 2);
1965        assert_eq!(errors[0], None);
1966        assert_eq!(errors[1], Some("Order does not exist".to_string()));
1967    }
1968
1969    #[rstest]
1970    fn test_extract_inner_errors_unparsable() {
1971        let response = ok_response(serde_json::json!({"foo": "bar"}));
1972        let errors = extract_inner_errors(&response);
1973        assert!(errors.is_empty());
1974    }
1975
1976    fn count_sig_figs(s: &str) -> usize {
1977        let s = s.trim_start_matches('-');
1978        if s.contains('.') {
1979            // Decimal: all digits excluding leading zeros are significant
1980            let digits: String = s.replace('.', "");
1981            digits.trim_start_matches('0').len()
1982        } else {
1983            // Integer: trailing zeros are place-holders, not significant
1984            let s = s.trim_start_matches('0');
1985            s.trim_end_matches('0').len()
1986        }
1987    }
1988
1989    fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1990        QuoteTick::new(
1991            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1992            Price::from(bid),
1993            Price::from(ask),
1994            Quantity::from("1"),
1995            Quantity::from("1"),
1996            Default::default(),
1997            Default::default(),
1998        )
1999    }
2000
2001    #[rstest]
2002    // BUY uses ask, SELL uses bid
2003    // Pipeline: base → +/-0.5% slippage → round 5 sig figs → clamp → normalize
2004    //
2005    // ETH-like (precision=2)
2006    // BUY: ask=2470 → 2470*1.005=2482.35 → sig5=2482.4 → clamp(2,ceil)=2482.40 → 2482.4
2007    #[case("2460.00", "2470.00", true, 2, "2482.4")]
2008    // SELL: bid=2460 → 2460*0.995=2447.70 → sig5=2447.7 → clamp(2,floor)=2447.70 → 2447.7
2009    #[case("2460.00", "2470.00", false, 2, "2447.7")]
2010    //
2011    // BTC-like (precision=1)
2012    // BUY: ask=104567.3 → 104567.3*1.005=105090.1365 → sig5=105090 → clamp(1,ceil)=105090 → 105090
2013    #[case("104500.0", "104567.3", true, 1, "105090")]
2014    // SELL: bid=104500.0 → 104500*0.995=103977.5 → sig5=103980 → clamp(1,floor)=103980 → 103980
2015    #[case("104500.0", "104567.3", false, 1, "103980")]
2016    //
2017    // Low-price token (precision=4)
2018    // BUY: ask=0.5000 → 0.5*1.005=0.5025 → sig5=0.50250 → clamp(4,ceil)=0.5025 → 0.5025
2019    #[case("0.4900", "0.5000", true, 4, "0.5025")]
2020    // SELL: bid=0.49 → 0.49*0.995=0.48755 → sig5=0.48755 → clamp(4,floor)=0.4875 → 0.4875
2021    #[case("0.4900", "0.5000", false, 4, "0.4875")]
2022    //
2023    // High-price low-precision (precision=0)
2024    // BUY: ask=50000 → 50000*1.005=50250 → sig5=50250 → clamp(0,ceil)=50250 → 50250
2025    #[case("49900", "50000", true, 0, "50250")]
2026    // SELL: bid=49900 → 49900*0.995=49650.5 → sig5=49650 → clamp(0,floor)=49650 → 49650
2027    #[case("49900", "50000", false, 0, "49650")]
2028    //
2029    // Very small price (precision=6)
2030    // BUY: ask=0.001234 → 0.001234*1.005=0.0012402 → sig5=0.0012402 → clamp(6,ceil)=0.001241
2031    #[case("0.001200", "0.001234", true, 6, "0.001241")]
2032    // SELL: bid=0.0012 → 0.0012*0.995=0.001194 → sig5=0.001194 → clamp(6,floor)=0.001194
2033    #[case("0.001200", "0.001234", false, 6, "0.001194")]
2034    fn test_derive_market_order_price(
2035        #[case] bid: &str,
2036        #[case] ask: &str,
2037        #[case] is_buy: bool,
2038        #[case] price_decimals: u8,
2039        #[case] expected: &str,
2040    ) {
2041        let quote = make_quote(bid, ask);
2042        let result =
2043            derive_market_order_price(&quote, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
2044        let expected_dec = Decimal::from_str(expected).unwrap();
2045        assert_eq!(result, expected_dec);
2046
2047        // Verify the result matches the full pipeline manually
2048        let base = if is_buy {
2049            quote.ask_price.as_decimal()
2050        } else {
2051            quote.bid_price.as_decimal()
2052        };
2053        let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2054        let sig_rounded = round_to_sig_figs(derived, 5);
2055        let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2056        assert_eq!(result, pipeline);
2057
2058        // Must not have trailing zeros after decimal point
2059        let s = result.to_string();
2060        if s.contains('.') {
2061            assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2062        }
2063
2064        // Sig figs must not exceed 5
2065        let sig_count = count_sig_figs(&s);
2066        assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2067
2068        // Decimal places must not exceed instrument precision
2069        let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2070        assert!(
2071            actual_decimals <= price_decimals as usize,
2072            "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2073        );
2074    }
2075
2076    #[rstest]
2077    #[case(50, dec!(1000), true, dec!(1005))] // default 0.5% BUY
2078    #[case(50, dec!(1000), false, dec!(995))] // default 0.5% SELL
2079    #[case(0, dec!(1000), true, dec!(1000))] // 0 bps: no adjustment
2080    #[case(100, dec!(1000), true, dec!(1010))] // 1% BUY
2081    #[case(100, dec!(1000), false, dec!(990))] // 1% SELL
2082    #[case(800, dec!(1000), true, dec!(1080))] // 8% (Hyperliquid SDK default) BUY
2083    #[case(800, dec!(1000), false, dec!(920))] // 8% SELL
2084    fn test_derive_limit_from_trigger_respects_bps(
2085        #[case] slippage_bps: u32,
2086        #[case] trigger: Decimal,
2087        #[case] is_buy: bool,
2088        #[case] expected: Decimal,
2089    ) {
2090        let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2091        assert_eq!(result, expected);
2092    }
2093
2094    #[rstest]
2095    fn test_derive_market_order_price_respects_slippage_override() {
2096        let quote = make_quote("100.00", "100.10");
2097        let tight = derive_market_order_price(&quote, true, 2, 50);
2098        let wide = derive_market_order_price(&quote, true, 2, 800);
2099        assert_eq!(tight, dec!(100.6));
2100        assert_eq!(wide, dec!(108.11));
2101        assert!(wide > tight);
2102    }
2103
2104    // Locks in the field-selection invariant; diverging from it would silently
2105    // disagree with the HTTP parser whenever `account_value != total_raw_usd`
2106    // or the nested and top-level `withdrawable` values differ.
2107    #[rstest]
2108    fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2109        let json = r#"{
2110            "assetPositions": [],
2111            "crossMarginSummary": {
2112                "accountValue": "150",
2113                "totalNtlPos": "0",
2114                "totalRawUsd": "100",
2115                "totalMarginUsed": "20",
2116                "withdrawable": "120"
2117            },
2118            "withdrawable": "80",
2119            "time": 1700000000000
2120        }"#;
2121
2122        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2123        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2124
2125        assert_eq!(balances.len(), 1);
2126        let balance = &balances[0];
2127        // Total comes from total_raw_usd (100), not account_value (150); free comes
2128        // from top-level state.withdrawable (80), not the nested summary.withdrawable (120).
2129        assert_eq!(balance.total.as_decimal(), dec!(100));
2130        assert_eq!(balance.free.as_decimal(), dec!(80));
2131        assert_eq!(balance.locked.as_decimal(), dec!(20));
2132
2133        assert_eq!(margins.len(), 1);
2134        assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2135    }
2136
2137    #[rstest]
2138    fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2139        let json =
2140            include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2141
2142        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2143        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2144
2145        assert_eq!(balances.len(), 1);
2146        let balance = &balances[0];
2147        assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2148        assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2149        assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2150
2151        assert_eq!(margins.len(), 1);
2152        assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2153    }
2154
2155    #[rstest]
2156    fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2157        let json = r#"{
2158            "assetPositions": [],
2159            "crossMarginSummary": {
2160                "accountValue": "100",
2161                "totalNtlPos": "0",
2162                "totalRawUsd": "100",
2163                "totalMarginUsed": "0",
2164                "withdrawable": "100"
2165            },
2166            "withdrawable": "150",
2167            "time": 1700000000000
2168        }"#;
2169
2170        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2171        let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2172
2173        assert_eq!(balances.len(), 1);
2174        let balance = &balances[0];
2175        assert_eq!(balance.total.as_decimal(), dec!(150));
2176        assert_eq!(balance.free.as_decimal(), dec!(150));
2177        assert_eq!(balance.locked.as_decimal(), dec!(0));
2178    }
2179
2180    #[rstest]
2181    fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2182        let json = r#"{
2183            "assetPositions": [],
2184            "withdrawable": "100",
2185            "time": 1700000000000
2186        }"#;
2187
2188        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2189        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2190        assert!(balances.is_empty());
2191        assert!(margins.is_empty());
2192    }
2193
2194    #[rstest]
2195    fn test_parse_spot_account_balances_emits_one_per_token() {
2196        let json = r#"{
2197            "balances": [
2198                {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2199                {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2200                {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2201            ]
2202        }"#;
2203
2204        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2205        let balances = parse_spot_account_balances(&state).unwrap();
2206
2207        assert_eq!(balances.len(), 2);
2208
2209        let usdc = &balances[0];
2210        assert_eq!(usdc.currency.code, "USDC");
2211        assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2212        assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2213        assert_eq!(usdc.locked.as_decimal(), dec!(10));
2214
2215        let purr = &balances[1];
2216        assert_eq!(purr.currency.code, "PURR");
2217        assert_eq!(purr.total.as_decimal(), dec!(50));
2218        assert_eq!(purr.free.as_decimal(), dec!(50));
2219    }
2220
2221    #[rstest]
2222    fn test_parse_spot_account_balances_clamps_hold_to_total() {
2223        let json = r#"{
2224            "balances": [
2225                {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2226            ]
2227        }"#;
2228
2229        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2230        let balances = parse_spot_account_balances(&state).unwrap();
2231
2232        assert_eq!(balances.len(), 1);
2233        let hype = &balances[0];
2234        assert_eq!(hype.total.as_decimal(), dec!(5));
2235        assert_eq!(hype.free.as_decimal(), dec!(0));
2236        assert_eq!(hype.locked.as_decimal(), dec!(5));
2237    }
2238
2239    #[rstest]
2240    fn test_parse_spot_account_balances_empty() {
2241        let state = SpotClearinghouseState::default();
2242        let balances = parse_spot_account_balances(&state).unwrap();
2243        assert!(balances.is_empty());
2244    }
2245
2246    #[rstest]
2247    fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2248        let perp_json = r#"{
2249            "assetPositions": [],
2250            "crossMarginSummary": {
2251                "accountValue": "500",
2252                "totalNtlPos": "0",
2253                "totalRawUsd": "500",
2254                "totalMarginUsed": "0",
2255                "withdrawable": "500"
2256            },
2257            "withdrawable": "500"
2258        }"#;
2259        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2260
2261        let spot_json = r#"{
2262            "balances": [
2263                {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2264                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2265            ]
2266        }"#;
2267        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2268
2269        let (balances, margins) =
2270            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2271
2272        assert!(margins.is_empty());
2273        assert_eq!(balances.len(), 2);
2274        assert_eq!(balances[0].currency.code, "USDC");
2275        assert_eq!(balances[0].total.as_decimal(), dec!(500));
2276        assert_eq!(balances[1].currency.code, "PURR");
2277        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2278    }
2279
2280    #[rstest]
2281    fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2282        let perp_json = r#"{
2283            "assetPositions": [],
2284            "crossMarginSummary": {
2285                "accountValue": "0",
2286                "totalNtlPos": "0",
2287                "totalRawUsd": "0",
2288                "totalMarginUsed": "0",
2289                "withdrawable": "0"
2290            },
2291            "withdrawable": "0"
2292        }"#;
2293        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2294
2295        let spot_json = r#"{
2296            "balances": [
2297                {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2298                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2299            ]
2300        }"#;
2301        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2302
2303        let (balances, margins) =
2304            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2305
2306        assert!(margins.is_empty());
2307        assert_eq!(balances.len(), 2);
2308        assert_eq!(balances[0].currency.code, "USDC");
2309        assert_eq!(balances[0].total.as_decimal(), dec!(75));
2310        assert_eq!(balances[0].free.as_decimal(), dec!(70));
2311        assert_eq!(balances[1].currency.code, "PURR");
2312        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2313    }
2314
2315    #[rstest]
2316    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2317        let perp_json = r#"{
2318            "assetPositions": [],
2319            "crossMarginSummary": {
2320                "accountValue": "50",
2321                "totalNtlPos": "0",
2322                "totalRawUsd": "50",
2323                "totalMarginUsed": "0",
2324                "withdrawable": "0"
2325            },
2326            "withdrawable": "0"
2327        }"#;
2328        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2329
2330        let spot_json = r#"{
2331            "balances": [
2332                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2333                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2334            ]
2335        }"#;
2336        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2337
2338        let (balances, margins) =
2339            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2340
2341        assert!(margins.is_empty());
2342        assert_eq!(balances.len(), 2);
2343        assert_eq!(balances[0].currency.code, "USDC");
2344        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2345        assert_eq!(balances[1].currency.code, "PURR");
2346        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2347    }
2348
2349    #[rstest]
2350    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2351        let perp_json = r#"{
2352            "assetPositions": [],
2353            "crossMarginSummary": {
2354                "accountValue": "-50",
2355                "totalNtlPos": "0",
2356                "totalRawUsd": "-50",
2357                "totalMarginUsed": "0",
2358                "withdrawable": "0"
2359            },
2360            "withdrawable": "0"
2361        }"#;
2362        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2363
2364        let spot_json = r#"{
2365            "balances": [
2366                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2367                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2368            ]
2369        }"#;
2370        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2371
2372        let (balances, margins) =
2373            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2374
2375        assert!(margins.is_empty());
2376        assert_eq!(balances.len(), 2);
2377        assert_eq!(balances[0].currency.code, "USDC");
2378        assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2379        assert_eq!(balances[1].currency.code, "PURR");
2380        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2381    }
2382
2383    #[rstest]
2384    fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2385        let perp_json = r#"{
2386            "assetPositions": [],
2387            "crossMarginSummary": {
2388                "accountValue": "0",
2389                "totalNtlPos": "0",
2390                "totalRawUsd": "0",
2391                "totalMarginUsed": "25",
2392                "withdrawable": "0"
2393            },
2394            "withdrawable": "0"
2395        }"#;
2396        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2397
2398        let spot_json = r#"{
2399            "balances": [
2400                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2401                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2402            ]
2403        }"#;
2404        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2405
2406        let (balances, margins) =
2407            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2408
2409        assert_eq!(margins.len(), 1);
2410        assert_eq!(balances.len(), 2);
2411        assert_eq!(balances[0].currency.code, "USDC");
2412        assert_eq!(balances[0].total.as_decimal(), dec!(0));
2413        assert_eq!(balances[1].currency.code, "PURR");
2414        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2415    }
2416
2417    #[rstest]
2418    fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2419        let perp_json = r#"{
2420            "assetPositions": [],
2421            "crossMarginSummary": {
2422                "accountValue": "0",
2423                "totalNtlPos": "0",
2424                "totalRawUsd": "0",
2425                "totalMarginUsed": "0",
2426                "withdrawable": "50"
2427            },
2428            "withdrawable": "50"
2429        }"#;
2430        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2431
2432        let spot_json = r#"{
2433            "balances": [
2434                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2435                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2436            ]
2437        }"#;
2438        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2439
2440        let (balances, margins) =
2441            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2442
2443        assert!(margins.is_empty());
2444        assert_eq!(balances.len(), 2);
2445        assert_eq!(balances[0].currency.code, "USDC");
2446        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2447        assert_eq!(balances[0].free.as_decimal(), dec!(50));
2448        assert_eq!(balances[1].currency.code, "PURR");
2449        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2450    }
2451
2452    #[rstest]
2453    fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2454        let perp_json = r#"{"assetPositions": []}"#;
2455        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2456
2457        let spot_json = r#"{
2458            "balances": [
2459                {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2460            ]
2461        }"#;
2462        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2463
2464        let (balances, _) =
2465            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2466
2467        assert_eq!(balances.len(), 1);
2468        assert_eq!(balances[0].currency.code, "USDC");
2469        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2470    }
2471
2472    fn limit_order(price: &str) -> OrderAny {
2473        OrderAny::Limit(LimitOrder::new(
2474            TraderId::from("TESTER-001"),
2475            StrategyId::from("S-001"),
2476            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2477            ClientOrderId::from("O-1"),
2478            OrderSide::Buy,
2479            Quantity::from(1),
2480            Price::from(price),
2481            TimeInForce::Gtc,
2482            None,
2483            false,
2484            false,
2485            false,
2486            None,
2487            None,
2488            None,
2489            None,
2490            None,
2491            None,
2492            None,
2493            None,
2494            None,
2495            None,
2496            None,
2497            Default::default(),
2498            Default::default(),
2499        ))
2500    }
2501
2502    #[rstest]
2503    // Venue-accepted forms pass: at the cap, integer, zero, trailing zeros
2504    #[case("78764.5", 1)]
2505    #[case("102393", 1)]
2506    #[case("0", 0)]
2507    #[case("0.11525", 5)]
2508    #[case("0.10", 1)]
2509    fn test_ensure_canonical_wire_price_accepts(#[case] price: &str, #[case] decimals: u8) {
2510        let value = Decimal::from_str(price).unwrap().normalize();
2511        ensure_canonical_wire_price("Price", value, decimals).unwrap();
2512    }
2513
2514    #[rstest]
2515    // Six significant figures with one decimal inside the cap: the venue
2516    // accepts these at signing (live-probed), so no false rejection.
2517    #[case("78764.5", 1)]
2518    #[case("102393", 1)]
2519    fn test_order_to_request_raw_price_accepts_canonical_boundary(
2520        #[case] price: &str,
2521        #[case] decimals: u8,
2522    ) {
2523        let request =
2524            order_to_hyperliquid_request_with_asset(&limit_order(price), 0, decimals, false, 50)
2525                .unwrap();
2526        assert_eq!(request.price, Decimal::from_str(price).unwrap());
2527    }
2528
2529    #[rstest]
2530    fn test_order_to_request_raw_price_rejects_excess_decimals() {
2531        let err = order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, false, 50)
2532            .unwrap_err();
2533        let msg = err.to_string();
2534        assert!(msg.contains("Price 0.62201"), "unexpected message: {msg}");
2535        assert!(
2536            msg.contains("4 decimal places"),
2537            "unexpected message: {msg}"
2538        );
2539    }
2540
2541    #[rstest]
2542    fn test_order_to_request_normalize_still_accepts_excess_decimals() {
2543        let request =
2544            order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, true, 50)
2545                .unwrap();
2546        assert_eq!(request.price, dec!(0.622));
2547    }
2548
2549    #[rstest]
2550    fn test_order_to_request_raw_trigger_price_rejects_excess_decimals() {
2551        let order = stop_market_order(OrderSide::Sell, "0.62201");
2552        let err = order_to_hyperliquid_request_with_asset(&order, 0, 4, false, 50).unwrap_err();
2553        let msg = err.to_string();
2554        assert!(
2555            msg.contains("Trigger price 0.62201"),
2556            "unexpected message: {msg}"
2557        );
2558        assert!(
2559            msg.contains("4 decimal places"),
2560            "unexpected message: {msg}"
2561        );
2562    }
2563
2564    #[rstest]
2565    // Unknown instrument cap: validation is skipped and the prior raw
2566    // passthrough is preserved rather than validating against a placeholder.
2567    #[case(false)]
2568    // Unknown cap with normalization enabled: falls back to two decimals
2569    #[case(true)]
2570    fn test_order_to_request_optional_decimals_unknown_cap(#[case] normalize: bool) {
2571        let request = order_to_hyperliquid_request_with_optional_decimals(
2572            &limit_order("0.123456"),
2573            0,
2574            None,
2575            normalize,
2576            50,
2577            None,
2578        )
2579        .unwrap();
2580        let expected = if normalize {
2581            dec!(0.12)
2582        } else {
2583            dec!(0.123456)
2584        };
2585        assert_eq!(request.price, expected);
2586    }
2587}