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//!
22//! ## Supported Order Types
23//!
24//! ### Standard Orders
25//! - **Market**: Implemented as IOC (Immediate-or-Cancel) limit orders.
26//! - **Limit**: Standard limit orders with GTC/IOC/ALO time-in-force.
27//!
28//! ### Conditional/Trigger Orders
29//! - **StopMarket**: Protective stop that triggers at specified price and executes at market.
30//! - **StopLimit**: Protective stop that triggers at specified price and executes at limit.
31//! - **MarketIfTouched**: Profit-taking/entry order that triggers and executes at market.
32//! - **LimitIfTouched**: Profit-taking/entry order that triggers and executes at limit.
33//!
34//! ## Order Semantics
35//!
36//! ### Stop Orders (StopMarket/StopLimit)
37//! - Used for protective stops and risk management.
38//! - Mapped to Hyperliquid's trigger orders with `tpsl: Sl`.
39//! - Trigger when price reaches the stop level.
40//! - Execute immediately (market) or at limit price.
41//!
42//! ### If Touched Orders (MarketIfTouched/LimitIfTouched)
43//! - Used for profit-taking or entry orders.
44//! - Mapped to Hyperliquid's trigger orders with `tpsl: Tp`.
45//! - Trigger when price reaches the target level.
46//! - Execute immediately (market) or at limit price.
47//!
48//! ## Trigger Price Logic
49//!
50//! The `tpsl` field (Take Profit / Stop Loss) is determined by:
51//! 1. **Order Type**: Stop orders → SL, If Touched orders → TP
52//! 2. **Price Relationship** (if available):
53//!    - For BUY orders: trigger above market → SL, below → TP
54//!    - For SELL orders: trigger below market → SL, above → TP
55//!
56//! ## Trigger Type Support
57//!
58//! Hyperliquid uses **mark price** for all trigger evaluations (TP/SL orders).
59
60use anyhow::Context;
61use nautilus_core::UnixNanos;
62pub use nautilus_core::serialization::{
63    deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
64    deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
65    serialize_vec_decimal_as_str,
66};
67use nautilus_model::{
68    data::{bar::BarType, quote::QuoteTick},
69    enums::{
70        AggregationSource, BarAggregation, ContingencyType, OrderSide, OrderStatus, OrderType,
71        TimeInForce,
72    },
73    identifiers::{ClientOrderId, TradeId},
74    orders::{Order, any::OrderAny},
75    types::{AccountBalance, Currency, MarginBalance, Money},
76};
77use rust_decimal::Decimal;
78
79use crate::{
80    common::{
81        enums::{
82            HyperliquidAccountAbstraction,
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/// Parses trigger semantics from a REST `orderType` label.
894///
895/// REST order rows (`frontendOpenOrders`, `historicalOrders`) describe conditional orders with
896/// labels such as `"Stop Market"` or `"Take Profit Limit"`, rather than the `tpsl` and
897/// `isMarket` fields carried by WebSocket order updates.
898///
899/// # Returns
900///
901/// The trigger kind and whether the order executes as market once triggered, or `None` when
902/// the label does not describe a trigger order (for example `"Limit"` or `"Market"`).
903#[must_use]
904pub fn parse_trigger_order_type_label(label: &str) -> Option<(HyperliquidTpSl, bool)> {
905    let tpsl = if label.starts_with("Take Profit") {
906        HyperliquidTpSl::Tp
907    } else if label.starts_with("Stop") {
908        HyperliquidTpSl::Sl
909    } else {
910        return None;
911    };
912    Some((tpsl, label.ends_with("Market")))
913}
914
915/// Extracts order status from WebSocket order data.
916///
917/// # Returns
918///
919/// A tuple of (OrderStatus, optional trigger status string).
920pub fn parse_order_status_with_trigger(
921    status: HyperliquidOrderStatus,
922    trigger_activated: Option<bool>,
923) -> (OrderStatus, Option<String>) {
924    let base_status = OrderStatus::from(status);
925
926    // For conditional orders, add trigger status information
927    if let Some(activated) = trigger_activated {
928        let trigger_status = if activated {
929            Some("activated".to_string())
930        } else {
931            Some("pending".to_string())
932        };
933        (base_status, trigger_status)
934    } else {
935        (base_status, None)
936    }
937}
938
939/// Converts WebSocket trailing stop data to description string.
940pub fn format_trailing_stop_info(
941    offset: &str,
942    offset_type: TrailingOffsetType,
943    callback_price: Option<&str>,
944) -> String {
945    let offset_desc = offset_type.format_offset(offset);
946
947    if let Some(callback) = callback_price {
948        format!("Trailing stop: {offset_desc} offset, callback at {callback}")
949    } else {
950        format!("Trailing stop: {offset_desc} offset")
951    }
952}
953
954/// Validates conditional order parameters from WebSocket data.
955///
956/// # Returns
957///
958/// `Ok(())` if parameters are valid, `Err` with description otherwise.
959pub fn validate_conditional_order_params(
960    trigger_px: Option<&str>,
961    tpsl: Option<&HyperliquidTpSl>,
962    is_market: Option<bool>,
963) -> anyhow::Result<()> {
964    if trigger_px.is_none() {
965        anyhow::bail!("Conditional order missing trigger price");
966    }
967
968    if tpsl.is_none() {
969        anyhow::bail!("Conditional order missing tpsl indicator");
970    }
971
972    // No need to validate tpsl value - the enum type guarantees it's either Tp or Sl
973
974    if is_market.is_none() {
975        anyhow::bail!("Conditional order missing is_market flag");
976    }
977
978    Ok(())
979}
980
981/// Parses trigger price from string to Decimal.
982///
983/// # Returns
984///
985/// Parsed Decimal value or error.
986pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
987    Decimal::from_str_exact(trigger_px)
988        .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
989}
990
991/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
992///
993/// Uses the same field selection as the HTTP account-state path
994/// (`cross_margin_summary.total_raw_usd` for total, top-level `state.withdrawable`
995/// for free) so the execution adapter and the HTTP client emit consistent balances
996/// for the same clearinghouse snapshot.
997///
998/// # Errors
999///
1000/// Returns an error if the data cannot be parsed.
1001pub fn parse_account_balances_and_margins(
1002    state: &ClearinghouseState,
1003) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1004    let mut balances = Vec::new();
1005    let mut margins = Vec::new();
1006
1007    let currency = Currency::USDC();
1008
1009    let cross_margin_summary = match &state.cross_margin_summary {
1010        Some(summary) => summary,
1011        None => return Ok((balances, margins)),
1012    };
1013
1014    let mut total_value = cross_margin_summary.total_raw_usd;
1015    let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
1016
1017    // Withdrawable may include spot balances that sit outside a positive margin
1018    // account value; raise total so those funds are not silently clamped away.
1019    if total_value >= Decimal::ZERO && free_value > total_value {
1020        total_value = free_value;
1021    }
1022
1023    balances.push(AccountBalance::from_total_and_free(
1024        total_value,
1025        free_value,
1026        currency,
1027    )?);
1028
1029    let margin_used = cross_margin_summary.total_margin_used;
1030
1031    if margin_used > Decimal::ZERO {
1032        // Hyperliquid perps use a single-collateral (USDC) cross-margin model, so the
1033        // reserved margin is emitted as an account-wide entry keyed by USDC.
1034        let initial_margin = Money::from_decimal(margin_used, currency)?;
1035        let maintenance_margin = Money::from_decimal(margin_used, currency)?;
1036        margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1037    }
1038
1039    Ok((balances, margins))
1040}
1041
1042/// Merges perp clearinghouse balances with spot balances into a unified set.
1043///
1044/// Unified and portfolio margin accounts report every balance and hold in the spot
1045/// clearinghouse state, so balances come from spot alone and the spot USDC `hold` becomes the
1046/// account-wide margin entry. That hold covers margin on every USDC-collateralized perp dex plus
1047/// USDC reserved by resting spot orders; margin on dexes collateralized in another token shows
1048/// up as that token's locked balance. The perp summary describes only the default dex in these
1049/// modes: its `totalRawUsd` goes negative while longs are open and its `withdrawable` is per-dex.
1050///
1051/// Otherwise the perp parser already reflects combined USDC when its cross-margin summary
1052/// carries collateral or margin state, so this parser appends only non-USDC spot
1053/// tokens in that case. If the perp state has no margin summary, or the summary
1054/// is present but zeroed, spot USDC is used verbatim.
1055///
1056/// Account updates keep any currency an update omits, so spot tokens the venue lists at zero are
1057/// reported at zero, clearing a previous balance, and USDC is always reported, at zero when there
1058/// is no USDC balance. On accounts without spot collateral this needs a perp summary; with none,
1059/// no zeroed entries are reported and the previous balances are kept.
1060///
1061/// # Errors
1062///
1063/// Returns an error if any balance conversion fails.
1064pub fn parse_combined_account_balances_and_margins(
1065    perp_state: &ClearinghouseState,
1066    spot_state: &SpotClearinghouseState,
1067    abstraction: HyperliquidAccountAbstraction,
1068) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1069    if abstraction.uses_spot_collateral() {
1070        let mut balances = parse_spot_account_balances(spot_state)?;
1071        push_zeroed_balances(&mut balances, spot_state);
1072
1073        let mut margins = Vec::new();
1074
1075        if let Some(usdc) = spot_state
1076            .balances
1077            .iter()
1078            .find(|balance| balance.coin.as_str() == "USDC")
1079            && usdc.hold > Decimal::ZERO
1080        {
1081            let margin_used = Money::from_decimal(usdc.hold, Currency::USDC())?;
1082            margins.push(MarginBalance::new(margin_used, margin_used, None));
1083        }
1084
1085        return Ok((balances, margins));
1086    }
1087
1088    let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
1089
1090    let perp_reflects_usdc = perp_state
1091        .cross_margin_summary
1092        .as_ref()
1093        .is_some_and(|summary| {
1094            summary.total_raw_usd != Decimal::ZERO
1095                || summary.total_margin_used > Decimal::ZERO
1096                || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
1097        });
1098
1099    if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
1100        balances.retain(|balance| balance.currency.code != "USDC");
1101    }
1102
1103    let spot_balances = parse_spot_account_balances(spot_state)?;
1104
1105    for balance in spot_balances {
1106        let is_usdc = balance.currency.code == "USDC";
1107        if perp_reflects_usdc && is_usdc {
1108            continue;
1109        }
1110        balances.push(balance);
1111    }
1112
1113    // Without a perp summary the response is not a full account reading, so report no zeroed
1114    // entries and keep the previous balances and margins rather than clear them
1115    if perp_state.cross_margin_summary.is_some() {
1116        push_zeroed_balances(&mut balances, spot_state);
1117    }
1118
1119    Ok((balances, margins))
1120}
1121
1122// Account updates keep any currency an update omits, so a balance that reached zero must still be
1123// reported to clear the previous one: spot tokens the venue lists at zero, and USDC whenever the
1124// update would otherwise carry none. The venue keeps every token an
1125// account has held as a zero row, so these entries recur on every update; a token dropped from
1126// the spot state entirely still keeps its last balance
1127fn push_zeroed_balances(balances: &mut Vec<AccountBalance>, spot_state: &SpotClearinghouseState) {
1128    let zeroed_spot = spot_state
1129        .balances
1130        .iter()
1131        .filter(|balance| balance.total.is_zero())
1132        .map(|balance| crate::http::parse::get_currency(balance.coin.as_str()));
1133
1134    for currency in zeroed_spot.chain(std::iter::once(Currency::USDC())) {
1135        if !balances.iter().any(|balance| balance.currency == currency) {
1136            let zero = Money::zero(currency);
1137            balances.push(AccountBalance::new(zero, zero, zero));
1138        }
1139    }
1140}
1141
1142/// Parses Hyperliquid spot clearinghouse state into Nautilus account balances.
1143///
1144/// Emits one [`AccountBalance`] per non-zero spot token, deriving free from
1145/// `total - hold`. Tokens unknown to the global currency registry are registered
1146/// on the fly with 8-decimal precision (matches Hyperliquid's `sz_decimals` cap).
1147///
1148/// # Errors
1149///
1150/// Returns an error if any balance cannot be converted to a Nautilus `Money`.
1151pub fn parse_spot_account_balances(
1152    state: &SpotClearinghouseState,
1153) -> anyhow::Result<Vec<AccountBalance>> {
1154    let mut balances = Vec::with_capacity(state.balances.len());
1155
1156    for balance in &state.balances {
1157        if balance.total.is_zero() {
1158            continue;
1159        }
1160
1161        let currency = crate::http::parse::get_currency(balance.coin.as_str());
1162
1163        // Let `from_total_and_locked` do the clamping and derivation at currency
1164        // precision so the `total == locked + free` invariant holds without
1165        // bespoke rounding here.
1166        balances.push(AccountBalance::from_total_and_locked(
1167            balance.total,
1168            balance.hold,
1169            currency,
1170        )?);
1171    }
1172
1173    Ok(balances)
1174}
1175
1176/// Determine the Hyperliquid grouping strategy for an order list.
1177///
1178/// Contingency type, reduce-only flags, structural shape, and parent/child
1179/// linkage must all agree to avoid misclassifying generic contingent lists
1180/// as Hyperliquid TP/SL groups.
1181///
1182/// - `NormalTpsl` (OTOCO bracket): entry order is OTO and not reduce-only,
1183///   all child orders are OCO or OUO, reduce-only, and reference the entry as parent.
1184/// - `PositionTpsl` (linked exit pair): every order is OCO or OUO, reduce-only,
1185///   and linked to the same sibling set.
1186/// - `Na`: everything else (independent batch).
1187pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExchangeGrouping {
1188    if orders.len() >= 2 {
1189        let entry = &orders[0];
1190        let children = &orders[1..];
1191        let entry_id = entry.client_order_id();
1192        let entry_is_oto =
1193            entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1194        let children_are_linked = children.iter().all(|o| {
1195            matches!(
1196                o.contingency_type(),
1197                Some(ContingencyType::Oco | ContingencyType::Ouo)
1198            ) && o.is_reduce_only()
1199                && o.parent_order_id() == Some(entry_id)
1200        });
1201
1202        if entry_is_oto && children_are_linked {
1203            return HyperliquidExchangeGrouping::NormalTpsl;
1204        }
1205    }
1206
1207    let all_oco_linked = orders.len() >= 2
1208        && orders.iter().all(|o| {
1209            matches!(
1210                o.contingency_type(),
1211                Some(ContingencyType::Oco | ContingencyType::Ouo)
1212            ) && o.is_reduce_only()
1213        })
1214        && orders.iter().all(|o| {
1215            o.linked_order_ids().is_some_and(|ids| {
1216                ids.iter()
1217                    .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1218            })
1219        });
1220
1221    if all_oco_linked {
1222        HyperliquidExchangeGrouping::PositionTpsl
1223    } else {
1224        HyperliquidExchangeGrouping::Na
1225    }
1226}
1227
1228pub(crate) fn is_false_or_none(value: &Option<bool>) -> bool {
1229    // Hyperliquid rejects cancel actions hashed with an explicit false fast flag
1230    *value != Some(true)
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use std::str::FromStr;
1236
1237    use nautilus_model::{
1238        enums::{OrderSide, TimeInForce, TriggerType},
1239        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1240        orders::{LimitOrder, OrderAny, StopMarketOrder},
1241        types::{Price, Quantity},
1242    };
1243    use rstest::rstest;
1244    use rust_decimal::Decimal;
1245    use rust_decimal_macros::dec;
1246    use serde::{Deserialize, Serialize};
1247
1248    use super::*;
1249
1250    #[rstest]
1251    fn test_make_fill_trade_id_is_stable() {
1252        // Pins the deterministic FNV output so the Decimal `Display` hashing
1253        // stays stable for reconciliation dedup across the String->Decimal change.
1254        let id = make_fill_trade_id(
1255            "0xabc123",
1256            12345,
1257            dec!(50000.0),
1258            dec!(0.1),
1259            1704470400000,
1260            dec!(0.0),
1261        );
1262        assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1263    }
1264
1265    #[derive(Serialize, Deserialize)]
1266    struct TestStruct {
1267        #[serde(
1268            serialize_with = "serialize_decimal_as_str",
1269            deserialize_with = "deserialize_decimal_from_str"
1270        )]
1271        value: Decimal,
1272        #[serde(
1273            serialize_with = "serialize_optional_decimal_as_str",
1274            deserialize_with = "deserialize_optional_decimal_from_str"
1275        )]
1276        optional_value: Option<Decimal>,
1277    }
1278
1279    #[rstest]
1280    #[case("#10", 100_000_010, 1, 0)]
1281    #[case("+10", 100_000_010, 1, 0)]
1282    #[case("#31", 100_000_031, 3, 1)]
1283    #[case("+31", 100_000_031, 3, 1)]
1284    fn test_parse_outcome_symbol(
1285        #[case] symbol: &str,
1286        #[case] raw_asset_id: u32,
1287        #[case] outcome: u32,
1288        #[case] side: u8,
1289    ) {
1290        let asset_id = parse_outcome_symbol(symbol).unwrap();
1291        assert_eq!(asset_id.to_raw(), raw_asset_id);
1292        assert_eq!(asset_id.outcome_index(), Some(outcome));
1293        assert_eq!(asset_id.outcome_side(), Some(side));
1294    }
1295
1296    #[rstest]
1297    #[case("25-YES-OUTCOME", 25, 0)]
1298    #[case("25-NO-OUTCOME", 25, 1)]
1299    #[case("0-YES-OUTCOME", 0, 0)]
1300    #[case("999-NO-OUTCOME", 999, 1)]
1301    fn test_parse_outcome_nautilus_symbol(
1302        #[case] symbol: &str,
1303        #[case] outcome_index: u32,
1304        #[case] side: u8,
1305    ) {
1306        let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1307        assert_eq!(parsed, (outcome_index, side));
1308    }
1309
1310    #[rstest]
1311    #[case("25-OUTCOME")]
1312    #[case("25-MAYBE-OUTCOME")]
1313    #[case("25-yes-OUTCOME")]
1314    #[case("-YES-OUTCOME")]
1315    #[case("YES-25-OUTCOME")]
1316    #[case("25-YES-outcome")]
1317    #[case("25-YES")]
1318    fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1319        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1320    }
1321
1322    #[rstest]
1323    // outcome_index * 10 overflows u32.
1324    #[case("999999999-YES-OUTCOME")]
1325    // outcome_index * 10 fits but 100_000_000 + encoding overflows u32.
1326    #[case("429496729-YES-OUTCOME")]
1327    // u32::MAX itself; rejected on the multiply.
1328    #[case("4294967295-NO-OUTCOME")]
1329    fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1330        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1331    }
1332
1333    #[rstest]
1334    #[case(25, 0, "25-YES-OUTCOME")]
1335    #[case(25, 1, "25-NO-OUTCOME")]
1336    #[case(0, 0, "0-YES-OUTCOME")]
1337    fn test_format_outcome_nautilus_symbol(
1338        #[case] outcome_index: u32,
1339        #[case] side: u8,
1340        #[case] expected: &str,
1341    ) {
1342        assert_eq!(
1343            format_outcome_nautilus_symbol(outcome_index, side),
1344            expected,
1345        );
1346    }
1347
1348    #[rstest]
1349    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1350    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1351    #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1352    #[case("BTC-USD-PERP", None)]
1353    #[case("+250", None)]
1354    fn test_outcome_token_from_nautilus_symbol(
1355        #[case] symbol: &str,
1356        #[case] expected: Option<String>,
1357    ) {
1358        assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1359    }
1360
1361    #[rstest]
1362    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1363    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1364    #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1365    #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1366    #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1367    #[case("+250", Some("+250".to_string()))]
1368    #[case("#250", Some("#250".to_string()))]
1369    #[case("", None)]
1370    fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1371        assert_eq!(cache_alias_for_symbol(symbol), expected);
1372    }
1373
1374    #[rstest]
1375    #[case("10", "expected #<encoding> or +<encoding>")]
1376    #[case("#", "encoding must not be empty")]
1377    #[case("#1a", "encoding must be numeric")]
1378    #[case("#12", "side digit 0 or 1")]
1379    #[case("#4294967295", "fit u32")]
1380    fn test_parse_outcome_symbol_rejects_invalid_values(
1381        #[case] symbol: &str,
1382        #[case] expected_error: &str,
1383    ) {
1384        let err = parse_outcome_symbol(symbol).unwrap_err();
1385        assert!(
1386            err.to_string().contains(expected_error),
1387            "expected error to contain '{expected_error}', received '{err}'",
1388        );
1389    }
1390
1391    #[rstest]
1392    fn test_decimal_serialization_roundtrip() {
1393        let original = TestStruct {
1394            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1395            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1396        };
1397
1398        let json = serde_json::to_string(&original).unwrap();
1399        println!("Serialized: {json}");
1400
1401        // Check that it's serialized as strings (rust_decimal may normalize precision)
1402        assert!(json.contains("\"123.45678901234567890123456789\""));
1403        assert!(json.contains("\"0.000000001\""));
1404
1405        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1406        assert_eq!(original.value, deserialized.value);
1407        assert_eq!(original.optional_value, deserialized.optional_value);
1408    }
1409
1410    #[rstest]
1411    fn test_decimal_precision_preservation() {
1412        let test_cases = [
1413            "0",
1414            "1",
1415            "0.1",
1416            "0.01",
1417            "0.001",
1418            "123.456789012345678901234567890",
1419            "999999999999999999.999999999999999999",
1420        ];
1421
1422        for case in test_cases {
1423            let decimal = Decimal::from_str(case).unwrap();
1424            let test_struct = TestStruct {
1425                value: decimal,
1426                optional_value: Some(decimal),
1427            };
1428
1429            let json = serde_json::to_string(&test_struct).unwrap();
1430            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1431
1432            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1433            assert_eq!(
1434                Some(decimal),
1435                parsed.optional_value,
1436                "Failed for case: {case}"
1437            );
1438        }
1439    }
1440
1441    #[rstest]
1442    fn test_optional_none_handling() {
1443        let test_struct = TestStruct {
1444            value: Decimal::from_str("42.0").unwrap(),
1445            optional_value: None,
1446        };
1447
1448        let json = serde_json::to_string(&test_struct).unwrap();
1449        assert!(json.contains("null"));
1450
1451        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1452        assert_eq!(test_struct.value, parsed.value);
1453        assert_eq!(None, parsed.optional_value);
1454    }
1455
1456    #[rstest]
1457    fn test_round_down_to_tick() {
1458        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1459        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1460        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1461
1462        // Edge case: zero tick size
1463        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1464    }
1465
1466    #[rstest]
1467    fn test_round_down_to_step() {
1468        assert_eq!(
1469            round_down_to_step(dec!(0.12349), dec!(0.0001)),
1470            dec!(0.1234)
1471        );
1472        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1473        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1474
1475        // Edge case: zero step size
1476        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1477    }
1478
1479    #[rstest]
1480    fn test_min_notional_validation() {
1481        // Should pass
1482        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1483        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1484
1485        // Should fail
1486        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1487        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1488
1489        // Edge case: exactly at minimum
1490        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1491    }
1492
1493    #[rstest]
1494    fn test_round_to_sig_figs() {
1495        // BTC price ~$104,567 needs to round to 5 sig figs
1496        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1497        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1498        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1499
1500        // Smaller prices should keep decimals
1501        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1502        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1503        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1504
1505        // Sub-1 values with leading zeros must preserve 5 sig figs
1506        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1507        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
1508
1509        // Zero case
1510        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1511
1512        assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1513        assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1514        assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1515        assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1516    }
1517
1518    #[rstest]
1519    fn test_normalize_price() {
1520        // Now includes 5 sig fig rounding first
1521        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1522        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
1523        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
1524        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
1525
1526        // BTC-like prices get rounded to 5 sig figs
1527        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
1528    }
1529
1530    #[rstest]
1531    fn test_normalize_quantity() {
1532        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1533        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1534        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1535        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1536    }
1537
1538    #[rstest]
1539    fn test_normalize_order_complete() {
1540        let result = normalize_order(
1541            dec!(100.12345), // price
1542            dec!(0.123456),  // qty
1543            dec!(0.01),      // tick_size
1544            dec!(0.0001),    // step_size
1545            dec!(10),        // min_notional
1546            2,               // price_decimals
1547            4,               // size_decimals
1548        );
1549
1550        assert!(result.is_ok());
1551        let (price, qty) = result.unwrap();
1552        assert_eq!(price, dec!(100.12)); // normalized and rounded down
1553        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
1554    }
1555
1556    #[rstest]
1557    fn test_normalize_order_min_notional_fail() {
1558        let result = normalize_order(
1559            dec!(100.12345), // price
1560            dec!(0.05),      // qty (too small for min notional)
1561            dec!(0.01),      // tick_size
1562            dec!(0.0001),    // step_size
1563            dec!(10),        // min_notional
1564            2,               // price_decimals
1565            4,               // size_decimals
1566        );
1567
1568        assert!(result.is_err());
1569        assert!(result.unwrap_err().contains("Notional value"));
1570    }
1571
1572    #[rstest]
1573    fn test_edge_cases() {
1574        // Test with very small numbers
1575        assert_eq!(
1576            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1577            dec!(0.000001)
1578        );
1579
1580        // Test with large numbers
1581        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1582
1583        // Test rounding edge case
1584        assert_eq!(
1585            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1586            dec!(100.00)
1587        );
1588    }
1589
1590    #[rstest]
1591    fn test_is_conditional_order_data() {
1592        // Test with trigger price and tpsl (conditional)
1593        assert!(is_conditional_order_data(
1594            Some(dec!(50000.0)),
1595            Some(&HyperliquidTpSl::Sl)
1596        ));
1597
1598        // Test with only trigger price (not conditional - needs both)
1599        assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1600
1601        // Test with only tpsl (not conditional - needs both)
1602        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1603
1604        // Test with no conditional fields
1605        assert!(!is_conditional_order_data(None, None));
1606    }
1607
1608    #[rstest]
1609    fn test_parse_trigger_order_type() {
1610        // Stop Market
1611        assert_eq!(
1612            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1613            OrderType::StopMarket
1614        );
1615
1616        // Stop Limit
1617        assert_eq!(
1618            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1619            OrderType::StopLimit
1620        );
1621
1622        // Take Profit Market
1623        assert_eq!(
1624            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1625            OrderType::MarketIfTouched
1626        );
1627
1628        // Take Profit Limit
1629        assert_eq!(
1630            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1631            OrderType::LimitIfTouched
1632        );
1633    }
1634
1635    #[rstest]
1636    #[case("Stop Market", Some((HyperliquidTpSl::Sl, true)))]
1637    #[case("Stop Limit", Some((HyperliquidTpSl::Sl, false)))]
1638    #[case("Take Profit Market", Some((HyperliquidTpSl::Tp, true)))]
1639    #[case("Take Profit Limit", Some((HyperliquidTpSl::Tp, false)))]
1640    #[case("Limit", None)]
1641    #[case("Market", None)]
1642    #[case("", None)]
1643    fn test_parse_trigger_order_type_label(
1644        #[case] label: &str,
1645        #[case] expected: Option<(HyperliquidTpSl, bool)>,
1646    ) {
1647        assert_eq!(parse_trigger_order_type_label(label), expected);
1648    }
1649
1650    #[rstest]
1651    fn test_parse_order_status_with_trigger() {
1652        // Test with open status and activated trigger
1653        let (status, trigger_status) =
1654            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1655        assert_eq!(status, OrderStatus::Accepted);
1656        assert_eq!(trigger_status, Some("activated".to_string()));
1657
1658        // Test with open status and not activated
1659        let (status, trigger_status) =
1660            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1661        assert_eq!(status, OrderStatus::Accepted);
1662        assert_eq!(trigger_status, Some("pending".to_string()));
1663
1664        // Test without trigger info
1665        let (status, trigger_status) =
1666            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1667        assert_eq!(status, OrderStatus::Accepted);
1668        assert_eq!(trigger_status, None);
1669    }
1670
1671    #[rstest]
1672    fn test_format_trailing_stop_info() {
1673        // Price offset
1674        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1675        assert!(info.contains("100.0"));
1676        assert!(info.contains("callback at 50000.0"));
1677
1678        // Percentage offset
1679        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1680        assert!(info.contains("5.0%"));
1681        assert!(info.contains("Trailing stop"));
1682
1683        // Basis points offset
1684        let info =
1685            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1686        assert!(info.contains("250 bps"));
1687        assert!(info.contains("49000.0"));
1688    }
1689
1690    #[rstest]
1691    fn test_parse_trigger_price() {
1692        // Valid price
1693        let result = parse_trigger_price("50000.0");
1694        assert!(result.is_ok());
1695        assert_eq!(result.unwrap(), dec!(50000.0));
1696
1697        // Valid integer price
1698        let result = parse_trigger_price("49000");
1699        assert!(result.is_ok());
1700        assert_eq!(result.unwrap(), dec!(49000));
1701
1702        // Invalid price
1703        let result = parse_trigger_price("invalid");
1704        assert!(result.is_err());
1705
1706        // Empty string
1707        let result = parse_trigger_price("");
1708        assert!(result.is_err());
1709    }
1710
1711    #[rstest]
1712    #[case(dec!(0), true, dec!(0))] // Zero
1713    #[case(dec!(0), false, dec!(0))] // Zero
1714    #[case(dec!(0.001), true, dec!(0.001005))] // Small price BUY
1715    #[case(dec!(0.001), false, dec!(0.000995))] // Small price SELL
1716    #[case(dec!(100), true, dec!(100.5))] // Round price BUY
1717    #[case(dec!(100), false, dec!(99.5))] // Round price SELL
1718    #[case(dec!(2470), true, dec!(2482.35))] // ETH-like BUY
1719    #[case(dec!(2470), false, dec!(2457.65))] // ETH-like SELL
1720    #[case(dec!(104567.3), true, dec!(105090.1365))] // BTC-like BUY
1721    #[case(dec!(104567.3), false, dec!(104044.4635))] // BTC-like SELL
1722    fn test_derive_limit_from_trigger(
1723        #[case] trigger_price: Decimal,
1724        #[case] is_buy: bool,
1725        #[case] expected: Decimal,
1726    ) {
1727        let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1728        assert_eq!(result, expected);
1729
1730        // Verify invariant: BUY limit >= trigger, SELL limit <= trigger
1731        if is_buy {
1732            assert!(result >= trigger_price);
1733        } else {
1734            assert!(result <= trigger_price);
1735        }
1736    }
1737
1738    #[rstest]
1739    // BUY rounds up (ceil)
1740    #[case(dec!(2457.65), 2, true, dec!(2457.65))] // Already at precision
1741    #[case(dec!(2457.65), 1, true, dec!(2457.7))] // Ceil to 1dp
1742    #[case(dec!(2457.65), 0, true, dec!(2458))] // Ceil to integer
1743    // SELL rounds down (floor)
1744    #[case(dec!(2457.65), 2, false, dec!(2457.65))] // Already at precision
1745    #[case(dec!(2457.65), 1, false, dec!(2457.6))] // Floor to 1dp
1746    #[case(dec!(2457.65), 0, false, dec!(2457))] // Floor to integer
1747    // High precision (no-op)
1748    #[case(dec!(0.4975), 4, true, dec!(0.4975))]
1749    #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1750    // Precision forces clamping on small values
1751    #[case(dec!(0.4975), 2, true, dec!(0.50))]
1752    #[case(dec!(0.4975), 2, false, dec!(0.49))]
1753    fn test_clamp_price_to_precision(
1754        #[case] price: Decimal,
1755        #[case] decimals: u8,
1756        #[case] is_buy: bool,
1757        #[case] expected: Decimal,
1758    ) {
1759        assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1760    }
1761
1762    fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1763        OrderAny::StopMarket(StopMarketOrder::new(
1764            TraderId::from("TESTER-001"),
1765            StrategyId::from("S-001"),
1766            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1767            ClientOrderId::from("O-001"),
1768            side,
1769            Quantity::from(1),
1770            Price::from(trigger_price),
1771            TriggerType::LastPrice,
1772            TimeInForce::Gtc,
1773            None,
1774            false,
1775            false,
1776            None,
1777            None,
1778            None,
1779            None,
1780            None,
1781            None,
1782            None,
1783            None,
1784            None,
1785            None,
1786            None,
1787            Default::default(),
1788            Default::default(),
1789        ))
1790    }
1791
1792    #[rstest]
1793    // ETH-like (precision=2): clamping is a no-op
1794    #[case(OrderSide::Sell, "2470.00", 2)]
1795    #[case(OrderSide::Buy, "2470.00", 2)]
1796    // BTC-like (precision=1): clamping is a no-op
1797    #[case(OrderSide::Sell, "104567.3", 1)]
1798    #[case(OrderSide::Buy, "104567.3", 1)]
1799    // Low-price token (precision=4): clamping is a no-op
1800    #[case(OrderSide::Sell, "0.50", 4)]
1801    #[case(OrderSide::Buy, "0.50", 4)]
1802    // Clamping materially changes: ETH trigger at precision=1
1803    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(1dp) = 2457.6
1804    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(1dp) = 2482.4
1805    #[case(OrderSide::Sell, "2470.00", 1)]
1806    #[case(OrderSide::Buy, "2470.00", 1)]
1807    // Clamping materially changes: precision=0 forces integer
1808    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(0dp) = 2457
1809    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(0dp) = 2483
1810    #[case(OrderSide::Sell, "2470.00", 0)]
1811    #[case(OrderSide::Buy, "2470.00", 0)]
1812    fn test_order_to_request_stop_market_derives_limit_from_trigger(
1813        #[case] side: OrderSide,
1814        #[case] trigger_str: &str,
1815        #[case] price_decimals: u8,
1816    ) {
1817        let order = stop_market_order(side, trigger_str);
1818        let request = order_to_hyperliquid_request_with_asset(
1819            &order,
1820            0,
1821            price_decimals,
1822            true,
1823            DEFAULT_MARKET_SLIPPAGE_BPS,
1824        )
1825        .unwrap();
1826        let trigger = Decimal::from_str(trigger_str).unwrap();
1827        let is_buy = matches!(side, OrderSide::Buy);
1828
1829        // Price must satisfy Hyperliquid's directional constraint
1830        if is_buy {
1831            assert!(
1832                request.price >= trigger,
1833                "BUY limit {} must be >= trigger {trigger}",
1834                request.price,
1835            );
1836            assert!(request.is_buy);
1837        } else {
1838            assert!(
1839                request.price <= trigger,
1840                "SELL limit {} must be <= trigger {trigger}",
1841                request.price,
1842            );
1843            assert!(!request.is_buy);
1844        }
1845
1846        // Price must equal the full pipeline: derive -> sig figs -> clamp -> normalize
1847        let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1848        let sig_rounded = round_to_sig_figs(derived, 5);
1849        let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1850        assert_eq!(request.price, expected);
1851
1852        // Decimal places must not exceed instrument precision
1853        let price_str = request.price.to_string();
1854        let actual_decimals = price_str
1855            .find('.')
1856            .map_or(0, |dot| price_str.len() - dot - 1);
1857        assert!(
1858            actual_decimals <= price_decimals as usize,
1859            "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1860        );
1861
1862        // Decimal trailing zeros must be stripped (canonical form)
1863        if price_str.contains('.') {
1864            assert!(
1865                !price_str.ends_with('0'),
1866                "Price {price_str} has decimal trailing zeros",
1867            );
1868        }
1869
1870        let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1871        assert_eq!(
1872            request.kind,
1873            HyperliquidExchangeOrderKind::Trigger {
1874                trigger: HyperliquidExchangeTriggerParams {
1875                    is_market: true,
1876                    trigger_px: expected_trigger,
1877                    tpsl: HyperliquidExchangeTpSl::Sl,
1878                },
1879            },
1880        );
1881    }
1882
1883    fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1884        HyperliquidExchangeResponse::Status {
1885            status: "ok".to_string(),
1886            response: inner,
1887        }
1888    }
1889
1890    #[rstest]
1891    fn test_extract_inner_error_order_with_error() {
1892        let response = ok_response(serde_json::json!({
1893            "type": "order",
1894            "data": {"statuses": [{"error": "Order has invalid price."}]}
1895        }));
1896        assert_eq!(
1897            extract_inner_error(&response),
1898            Some("Order has invalid price.".to_string()),
1899        );
1900    }
1901
1902    #[rstest]
1903    fn test_extract_inner_error_order_resting() {
1904        let response = ok_response(serde_json::json!({
1905            "type": "order",
1906            "data": {"statuses": [{"resting": {"oid": 12345}}]}
1907        }));
1908        assert_eq!(extract_inner_error(&response), None);
1909    }
1910
1911    #[rstest]
1912    fn test_extract_inner_error_order_filled() {
1913        let response = ok_response(serde_json::json!({
1914            "type": "order",
1915            "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1916        }));
1917        assert_eq!(extract_inner_error(&response), None);
1918    }
1919
1920    #[rstest]
1921    fn test_extract_inner_error_cancel_error() {
1922        let response = ok_response(serde_json::json!({
1923            "type": "cancel",
1924            "data": {"statuses": [{"error": "Order not found"}]}
1925        }));
1926        assert_eq!(
1927            extract_inner_error(&response),
1928            Some("Order not found".to_string()),
1929        );
1930    }
1931
1932    #[rstest]
1933    fn test_extract_inner_error_cancel_success() {
1934        let response = ok_response(serde_json::json!({
1935            "type": "cancel",
1936            "data": {"statuses": ["success"]}
1937        }));
1938        assert_eq!(extract_inner_error(&response), None);
1939    }
1940
1941    #[rstest]
1942    fn test_extract_inner_error_modify_error() {
1943        let response = ok_response(serde_json::json!({
1944            "type": "modify",
1945            "data": {"statuses": [{"error": "Invalid modify"}]}
1946        }));
1947        assert_eq!(
1948            extract_inner_error(&response),
1949            Some("Invalid modify".to_string()),
1950        );
1951    }
1952
1953    #[rstest]
1954    fn test_extract_inner_error_modify_success() {
1955        let response = ok_response(serde_json::json!({
1956            "type": "modify",
1957            "data": {"statuses": ["success"]}
1958        }));
1959        assert_eq!(extract_inner_error(&response), None);
1960    }
1961
1962    #[rstest]
1963    fn test_extract_inner_error_non_status_response() {
1964        let response = HyperliquidExchangeResponse::Error {
1965            error: "top-level error".to_string(),
1966        };
1967        assert_eq!(extract_inner_error(&response), None);
1968    }
1969
1970    #[rstest]
1971    fn test_extract_inner_error_unparsable_response() {
1972        let response = ok_response(serde_json::json!({"unknown": "data"}));
1973        assert_eq!(extract_inner_error(&response), None);
1974    }
1975
1976    #[rstest]
1977    fn test_extract_inner_error_returns_first_error_in_batch() {
1978        let response = ok_response(serde_json::json!({
1979            "type": "order",
1980            "data": {"statuses": [
1981                {"resting": {"oid": 1}},
1982                {"error": "Second failed"},
1983                {"error": "Third failed"},
1984            ]}
1985        }));
1986        assert_eq!(
1987            extract_inner_error(&response),
1988            Some("Second failed".to_string()),
1989        );
1990    }
1991
1992    #[rstest]
1993    fn test_extract_inner_errors_mixed_batch() {
1994        let response = ok_response(serde_json::json!({
1995            "type": "order",
1996            "data": {"statuses": [
1997                {"resting": {"oid": 1}},
1998                {"error": "Failed order"},
1999                {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
2000            ]}
2001        }));
2002        let errors = extract_inner_errors(&response);
2003        assert_eq!(errors.len(), 3);
2004        assert_eq!(errors[0], None);
2005        assert_eq!(errors[1], Some("Failed order".to_string()));
2006        assert_eq!(errors[2], None);
2007    }
2008
2009    #[rstest]
2010    fn test_extract_inner_errors_all_success() {
2011        let response = ok_response(serde_json::json!({
2012            "type": "order",
2013            "data": {"statuses": [
2014                {"resting": {"oid": 1}},
2015                {"resting": {"oid": 2}},
2016            ]}
2017        }));
2018        let errors = extract_inner_errors(&response);
2019        assert_eq!(errors.len(), 2);
2020        assert!(errors.iter().all(|e| e.is_none()));
2021    }
2022
2023    #[rstest]
2024    fn test_extract_inner_errors_cancel_success() {
2025        let response = ok_response(serde_json::json!({
2026            "type": "cancel",
2027            "data": {"statuses": ["success"]}
2028        }));
2029        let errors = extract_inner_errors(&response);
2030        assert_eq!(errors.len(), 1);
2031        assert!(errors[0].is_none());
2032    }
2033
2034    #[rstest]
2035    fn test_extract_inner_errors_cancel_mixed() {
2036        let response = ok_response(serde_json::json!({
2037            "type": "cancel",
2038            "data": {"statuses": [
2039                "success",
2040                {"error": "Order was never placed, already canceled, or filled."},
2041                "success",
2042            ]}
2043        }));
2044        let errors = extract_inner_errors(&response);
2045        assert_eq!(errors.len(), 3);
2046        assert_eq!(errors[0], None);
2047        assert_eq!(
2048            errors[1],
2049            Some("Order was never placed, already canceled, or filled.".to_string())
2050        );
2051        assert_eq!(errors[2], None);
2052    }
2053
2054    #[rstest]
2055    fn test_extract_inner_errors_modify_mixed() {
2056        let response = ok_response(serde_json::json!({
2057            "type": "modify",
2058            "data": {"statuses": [
2059                "success",
2060                {"error": "Order does not exist"},
2061            ]}
2062        }));
2063        let errors = extract_inner_errors(&response);
2064        assert_eq!(errors.len(), 2);
2065        assert_eq!(errors[0], None);
2066        assert_eq!(errors[1], Some("Order does not exist".to_string()));
2067    }
2068
2069    #[rstest]
2070    fn test_extract_inner_errors_unparsable() {
2071        let response = ok_response(serde_json::json!({"foo": "bar"}));
2072        let errors = extract_inner_errors(&response);
2073        assert!(errors.is_empty());
2074    }
2075
2076    fn count_sig_figs(s: &str) -> usize {
2077        let s = s.trim_start_matches('-');
2078        if s.contains('.') {
2079            // Decimal: all digits excluding leading zeros are significant
2080            let digits: String = s.replace('.', "");
2081            digits.trim_start_matches('0').len()
2082        } else {
2083            // Integer: trailing zeros are place-holders, not significant
2084            let s = s.trim_start_matches('0');
2085            s.trim_end_matches('0').len()
2086        }
2087    }
2088
2089    fn make_quote(bid: &str, ask: &str) -> QuoteTick {
2090        QuoteTick::new(
2091            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
2092            Price::from(bid),
2093            Price::from(ask),
2094            Quantity::from("1"),
2095            Quantity::from("1"),
2096            Default::default(),
2097            Default::default(),
2098        )
2099    }
2100
2101    #[rstest]
2102    // BUY uses ask, SELL uses bid
2103    // Pipeline: base → +/-0.5% slippage → round 5 sig figs → clamp → normalize
2104    //
2105    // ETH-like (precision=2)
2106    // BUY: ask=2470 → 2470*1.005=2482.35 → sig5=2482.4 → clamp(2,ceil)=2482.40 → 2482.4
2107    #[case("2460.00", "2470.00", true, 2, "2482.4")]
2108    // SELL: bid=2460 → 2460*0.995=2447.70 → sig5=2447.7 → clamp(2,floor)=2447.70 → 2447.7
2109    #[case("2460.00", "2470.00", false, 2, "2447.7")]
2110    //
2111    // BTC-like (precision=1)
2112    // BUY: ask=104567.3 → 104567.3*1.005=105090.1365 → sig5=105090 → clamp(1,ceil)=105090 → 105090
2113    #[case("104500.0", "104567.3", true, 1, "105090")]
2114    // SELL: bid=104500.0 → 104500*0.995=103977.5 → sig5=103980 → clamp(1,floor)=103980 → 103980
2115    #[case("104500.0", "104567.3", false, 1, "103980")]
2116    //
2117    // Low-price token (precision=4)
2118    // BUY: ask=0.5000 → 0.5*1.005=0.5025 → sig5=0.50250 → clamp(4,ceil)=0.5025 → 0.5025
2119    #[case("0.4900", "0.5000", true, 4, "0.5025")]
2120    // SELL: bid=0.49 → 0.49*0.995=0.48755 → sig5=0.48755 → clamp(4,floor)=0.4875 → 0.4875
2121    #[case("0.4900", "0.5000", false, 4, "0.4875")]
2122    //
2123    // High-price low-precision (precision=0)
2124    // BUY: ask=50000 → 50000*1.005=50250 → sig5=50250 → clamp(0,ceil)=50250 → 50250
2125    #[case("49900", "50000", true, 0, "50250")]
2126    // SELL: bid=49900 → 49900*0.995=49650.5 → sig5=49650 → clamp(0,floor)=49650 → 49650
2127    #[case("49900", "50000", false, 0, "49650")]
2128    //
2129    // Very small price (precision=6)
2130    // BUY: ask=0.001234 → 0.001234*1.005=0.0012402 → sig5=0.0012402 → clamp(6,ceil)=0.001241
2131    #[case("0.001200", "0.001234", true, 6, "0.001241")]
2132    // SELL: bid=0.0012 → 0.0012*0.995=0.001194 → sig5=0.001194 → clamp(6,floor)=0.001194
2133    #[case("0.001200", "0.001234", false, 6, "0.001194")]
2134    fn test_derive_market_order_price(
2135        #[case] bid: &str,
2136        #[case] ask: &str,
2137        #[case] is_buy: bool,
2138        #[case] price_decimals: u8,
2139        #[case] expected: &str,
2140    ) {
2141        let quote = make_quote(bid, ask);
2142        let result =
2143            derive_market_order_price(&quote, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
2144        let expected_dec = Decimal::from_str(expected).unwrap();
2145        assert_eq!(result, expected_dec);
2146
2147        // Verify the result matches the full pipeline manually
2148        let base = if is_buy {
2149            quote.ask_price.as_decimal()
2150        } else {
2151            quote.bid_price.as_decimal()
2152        };
2153        let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2154        let sig_rounded = round_to_sig_figs(derived, 5);
2155        let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2156        assert_eq!(result, pipeline);
2157
2158        // Must not have trailing zeros after decimal point
2159        let s = result.to_string();
2160        if s.contains('.') {
2161            assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2162        }
2163
2164        // Sig figs must not exceed 5
2165        let sig_count = count_sig_figs(&s);
2166        assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2167
2168        // Decimal places must not exceed instrument precision
2169        let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2170        assert!(
2171            actual_decimals <= price_decimals as usize,
2172            "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2173        );
2174    }
2175
2176    #[rstest]
2177    #[case(50, dec!(1000), true, dec!(1005))] // default 0.5% BUY
2178    #[case(50, dec!(1000), false, dec!(995))] // default 0.5% SELL
2179    #[case(0, dec!(1000), true, dec!(1000))] // 0 bps: no adjustment
2180    #[case(100, dec!(1000), true, dec!(1010))] // 1% BUY
2181    #[case(100, dec!(1000), false, dec!(990))] // 1% SELL
2182    #[case(800, dec!(1000), true, dec!(1080))] // 8% (Hyperliquid SDK default) BUY
2183    #[case(800, dec!(1000), false, dec!(920))] // 8% SELL
2184    fn test_derive_limit_from_trigger_respects_bps(
2185        #[case] slippage_bps: u32,
2186        #[case] trigger: Decimal,
2187        #[case] is_buy: bool,
2188        #[case] expected: Decimal,
2189    ) {
2190        let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2191        assert_eq!(result, expected);
2192    }
2193
2194    #[rstest]
2195    fn test_derive_market_order_price_respects_slippage_override() {
2196        let quote = make_quote("100.00", "100.10");
2197        let tight = derive_market_order_price(&quote, true, 2, 50);
2198        let wide = derive_market_order_price(&quote, true, 2, 800);
2199        assert_eq!(tight, dec!(100.6));
2200        assert_eq!(wide, dec!(108.11));
2201        assert!(wide > tight);
2202    }
2203
2204    // Locks in the field-selection invariant; diverging from it would silently
2205    // disagree with the HTTP parser whenever `account_value != total_raw_usd`
2206    // or the nested and top-level `withdrawable` values differ.
2207    #[rstest]
2208    fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2209        let json = r#"{
2210            "assetPositions": [],
2211            "crossMarginSummary": {
2212                "accountValue": "150",
2213                "totalNtlPos": "0",
2214                "totalRawUsd": "100",
2215                "totalMarginUsed": "20",
2216                "withdrawable": "120"
2217            },
2218            "withdrawable": "80",
2219            "time": 1700000000000
2220        }"#;
2221
2222        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2223        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2224
2225        assert_eq!(balances.len(), 1);
2226        let balance = &balances[0];
2227        // Total comes from total_raw_usd (100), not account_value (150); free comes
2228        // from top-level state.withdrawable (80), not the nested summary.withdrawable (120).
2229        assert_eq!(balance.total.as_decimal(), dec!(100));
2230        assert_eq!(balance.free.as_decimal(), dec!(80));
2231        assert_eq!(balance.locked.as_decimal(), dec!(20));
2232
2233        assert_eq!(margins.len(), 1);
2234        assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2235    }
2236
2237    #[rstest]
2238    fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2239        let json =
2240            include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2241
2242        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2243        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2244
2245        assert_eq!(balances.len(), 1);
2246        let balance = &balances[0];
2247        assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2248        assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2249        assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2250
2251        assert_eq!(margins.len(), 1);
2252        assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2253    }
2254
2255    #[rstest]
2256    fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2257        let json = r#"{
2258            "assetPositions": [],
2259            "crossMarginSummary": {
2260                "accountValue": "100",
2261                "totalNtlPos": "0",
2262                "totalRawUsd": "100",
2263                "totalMarginUsed": "0",
2264                "withdrawable": "100"
2265            },
2266            "withdrawable": "150",
2267            "time": 1700000000000
2268        }"#;
2269
2270        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2271        let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2272
2273        assert_eq!(balances.len(), 1);
2274        let balance = &balances[0];
2275        assert_eq!(balance.total.as_decimal(), dec!(150));
2276        assert_eq!(balance.free.as_decimal(), dec!(150));
2277        assert_eq!(balance.locked.as_decimal(), dec!(0));
2278    }
2279
2280    #[rstest]
2281    fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2282        let json = r#"{
2283            "assetPositions": [],
2284            "withdrawable": "100",
2285            "time": 1700000000000
2286        }"#;
2287
2288        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2289        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2290        assert!(balances.is_empty());
2291        assert!(margins.is_empty());
2292    }
2293
2294    #[rstest]
2295    fn test_parse_spot_account_balances_emits_one_per_token() {
2296        let json = r#"{
2297            "balances": [
2298                {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2299                {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2300                {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2301            ]
2302        }"#;
2303
2304        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2305        let balances = parse_spot_account_balances(&state).unwrap();
2306
2307        assert_eq!(balances.len(), 2);
2308
2309        let usdc = &balances[0];
2310        assert_eq!(usdc.currency.code, "USDC");
2311        assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2312        assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2313        assert_eq!(usdc.locked.as_decimal(), dec!(10));
2314
2315        let purr = &balances[1];
2316        assert_eq!(purr.currency.code, "PURR");
2317        assert_eq!(purr.total.as_decimal(), dec!(50));
2318        assert_eq!(purr.free.as_decimal(), dec!(50));
2319    }
2320
2321    #[rstest]
2322    fn test_parse_spot_account_balances_clamps_hold_to_total() {
2323        let json = r#"{
2324            "balances": [
2325                {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2326            ]
2327        }"#;
2328
2329        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2330        let balances = parse_spot_account_balances(&state).unwrap();
2331
2332        assert_eq!(balances.len(), 1);
2333        let hype = &balances[0];
2334        assert_eq!(hype.total.as_decimal(), dec!(5));
2335        assert_eq!(hype.free.as_decimal(), dec!(0));
2336        assert_eq!(hype.locked.as_decimal(), dec!(5));
2337    }
2338
2339    #[rstest]
2340    fn test_parse_spot_account_balances_empty() {
2341        let state = SpotClearinghouseState::default();
2342        let balances = parse_spot_account_balances(&state).unwrap();
2343        assert!(balances.is_empty());
2344    }
2345
2346    #[rstest]
2347    fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2348        let perp_json = r#"{
2349            "assetPositions": [],
2350            "crossMarginSummary": {
2351                "accountValue": "500",
2352                "totalNtlPos": "0",
2353                "totalRawUsd": "500",
2354                "totalMarginUsed": "0",
2355                "withdrawable": "500"
2356            },
2357            "withdrawable": "500"
2358        }"#;
2359        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2360
2361        let spot_json = r#"{
2362            "balances": [
2363                {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2364                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2365            ]
2366        }"#;
2367        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2368
2369        let (balances, margins) = parse_combined_account_balances_and_margins(
2370            &perp_state,
2371            &spot_state,
2372            HyperliquidAccountAbstraction::Disabled,
2373        )
2374        .unwrap();
2375
2376        assert!(margins.is_empty());
2377        assert_eq!(balances.len(), 2);
2378        assert_eq!(balances[0].currency.code, "USDC");
2379        assert_eq!(balances[0].total.as_decimal(), dec!(500));
2380        assert_eq!(balances[1].currency.code, "PURR");
2381        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2382    }
2383
2384    #[rstest]
2385    fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2386        let perp_json = r#"{
2387            "assetPositions": [],
2388            "crossMarginSummary": {
2389                "accountValue": "0",
2390                "totalNtlPos": "0",
2391                "totalRawUsd": "0",
2392                "totalMarginUsed": "0",
2393                "withdrawable": "0"
2394            },
2395            "withdrawable": "0"
2396        }"#;
2397        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2398
2399        let spot_json = r#"{
2400            "balances": [
2401                {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2402                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2403            ]
2404        }"#;
2405        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2406
2407        let (balances, margins) = parse_combined_account_balances_and_margins(
2408            &perp_state,
2409            &spot_state,
2410            HyperliquidAccountAbstraction::Disabled,
2411        )
2412        .unwrap();
2413
2414        assert!(margins.is_empty());
2415        assert_eq!(balances.len(), 2);
2416        assert_eq!(balances[0].currency.code, "USDC");
2417        assert_eq!(balances[0].total.as_decimal(), dec!(75));
2418        assert_eq!(balances[0].free.as_decimal(), dec!(70));
2419        assert_eq!(balances[1].currency.code, "PURR");
2420        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2421    }
2422
2423    #[rstest]
2424    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2425        let perp_json = r#"{
2426            "assetPositions": [],
2427            "crossMarginSummary": {
2428                "accountValue": "50",
2429                "totalNtlPos": "0",
2430                "totalRawUsd": "50",
2431                "totalMarginUsed": "0",
2432                "withdrawable": "0"
2433            },
2434            "withdrawable": "0"
2435        }"#;
2436        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2437
2438        let spot_json = r#"{
2439            "balances": [
2440                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2441                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2442            ]
2443        }"#;
2444        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2445
2446        let (balances, margins) = parse_combined_account_balances_and_margins(
2447            &perp_state,
2448            &spot_state,
2449            HyperliquidAccountAbstraction::Disabled,
2450        )
2451        .unwrap();
2452
2453        assert!(margins.is_empty());
2454        assert_eq!(balances.len(), 2);
2455        assert_eq!(balances[0].currency.code, "USDC");
2456        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2457        assert_eq!(balances[1].currency.code, "PURR");
2458        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2459    }
2460
2461    #[rstest]
2462    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2463        let perp_json = r#"{
2464            "assetPositions": [],
2465            "crossMarginSummary": {
2466                "accountValue": "-50",
2467                "totalNtlPos": "0",
2468                "totalRawUsd": "-50",
2469                "totalMarginUsed": "0",
2470                "withdrawable": "0"
2471            },
2472            "withdrawable": "0"
2473        }"#;
2474        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2475
2476        let spot_json = r#"{
2477            "balances": [
2478                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2479                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2480            ]
2481        }"#;
2482        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2483
2484        let (balances, margins) = parse_combined_account_balances_and_margins(
2485            &perp_state,
2486            &spot_state,
2487            HyperliquidAccountAbstraction::Disabled,
2488        )
2489        .unwrap();
2490
2491        assert!(margins.is_empty());
2492        assert_eq!(balances.len(), 2);
2493        assert_eq!(balances[0].currency.code, "USDC");
2494        assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2495        assert_eq!(balances[1].currency.code, "PURR");
2496        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2497    }
2498
2499    #[rstest]
2500    fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2501        let perp_json = r#"{
2502            "assetPositions": [],
2503            "crossMarginSummary": {
2504                "accountValue": "0",
2505                "totalNtlPos": "0",
2506                "totalRawUsd": "0",
2507                "totalMarginUsed": "25",
2508                "withdrawable": "0"
2509            },
2510            "withdrawable": "0"
2511        }"#;
2512        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2513
2514        let spot_json = r#"{
2515            "balances": [
2516                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2517                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2518            ]
2519        }"#;
2520        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2521
2522        let (balances, margins) = parse_combined_account_balances_and_margins(
2523            &perp_state,
2524            &spot_state,
2525            HyperliquidAccountAbstraction::Disabled,
2526        )
2527        .unwrap();
2528
2529        assert_eq!(margins.len(), 1);
2530        assert_eq!(balances.len(), 2);
2531        assert_eq!(balances[0].currency.code, "USDC");
2532        assert_eq!(balances[0].total.as_decimal(), dec!(0));
2533        assert_eq!(balances[1].currency.code, "PURR");
2534        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2535    }
2536
2537    #[rstest]
2538    fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2539        let perp_json = r#"{
2540            "assetPositions": [],
2541            "crossMarginSummary": {
2542                "accountValue": "0",
2543                "totalNtlPos": "0",
2544                "totalRawUsd": "0",
2545                "totalMarginUsed": "0",
2546                "withdrawable": "50"
2547            },
2548            "withdrawable": "50"
2549        }"#;
2550        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2551
2552        let spot_json = r#"{
2553            "balances": [
2554                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2555                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2556            ]
2557        }"#;
2558        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2559
2560        let (balances, margins) = parse_combined_account_balances_and_margins(
2561            &perp_state,
2562            &spot_state,
2563            HyperliquidAccountAbstraction::Disabled,
2564        )
2565        .unwrap();
2566
2567        assert!(margins.is_empty());
2568        assert_eq!(balances.len(), 2);
2569        assert_eq!(balances[0].currency.code, "USDC");
2570        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2571        assert_eq!(balances[0].free.as_decimal(), dec!(50));
2572        assert_eq!(balances[1].currency.code, "PURR");
2573        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2574    }
2575
2576    #[rstest]
2577    fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2578        let perp_json = r#"{"assetPositions": []}"#;
2579        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2580
2581        let spot_json = r#"{
2582            "balances": [
2583                {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2584            ]
2585        }"#;
2586        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2587
2588        let (balances, _) = parse_combined_account_balances_and_margins(
2589            &perp_state,
2590            &spot_state,
2591            HyperliquidAccountAbstraction::Disabled,
2592        )
2593        .unwrap();
2594
2595        assert_eq!(balances.len(), 1);
2596        assert_eq!(balances[0].currency.code, "USDC");
2597        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2598    }
2599
2600    // Unified account holding longs on the default dex and on a HIP-3 dex: the perp summary
2601    // carries a negative `totalRawUsd` (`accountValue - totalNtlPos`) and a per-dex
2602    // `withdrawable`, while spot USDC holds the collateral and `hold` is the margin used across
2603    // every dex (180 default + 240 on a HIP-3 dex).
2604    #[rstest]
2605    fn test_parse_combined_unified_account_with_open_positions_uses_spot_usdc() {
2606        let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2607            "../../test_data/http_clearinghouse_state_unified_open_positions.json"
2608        ))
2609        .unwrap();
2610
2611        let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2612            "../../test_data/http_spot_clearinghouse_state_unified_open_positions.json"
2613        ))
2614        .unwrap();
2615
2616        let (balances, margins) = parse_combined_account_balances_and_margins(
2617            &perp_state,
2618            &spot_state,
2619            HyperliquidAccountAbstraction::UnifiedAccount,
2620        )
2621        .unwrap();
2622
2623        assert_eq!(balances.len(), 2);
2624        assert_eq!(balances[0].currency.code, "USDC");
2625        assert_eq!(balances[0].total.as_decimal(), dec!(512.25));
2626        assert_eq!(balances[0].free.as_decimal(), dec!(92.25));
2627        assert_eq!(balances[0].locked.as_decimal(), dec!(420.0));
2628        assert_eq!(balances[1].currency.code, "PURR");
2629        assert_eq!(margins.len(), 1);
2630        assert_eq!(margins[0].initial.as_decimal(), dec!(420.0));
2631        assert_eq!(margins[0].maintenance.as_decimal(), dec!(420.0));
2632    }
2633
2634    #[rstest]
2635    fn test_parse_combined_unified_account_flat_has_no_margin() {
2636        let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2637            "../../test_data/http_clearinghouse_state_unified_flat.json"
2638        ))
2639        .unwrap();
2640        let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2641            "../../test_data/http_spot_clearinghouse_state_unified_flat.json"
2642        ))
2643        .unwrap();
2644
2645        let (balances, margins) = parse_combined_account_balances_and_margins(
2646            &perp_state,
2647            &spot_state,
2648            HyperliquidAccountAbstraction::UnifiedAccount,
2649        )
2650        .unwrap();
2651
2652        assert_eq!(balances.len(), 1);
2653        assert_eq!(balances[0].total.as_decimal(), dec!(100));
2654        assert_eq!(balances[0].free.as_decimal(), dec!(100));
2655        assert!(margins.is_empty());
2656    }
2657
2658    #[rstest]
2659    fn test_parse_combined_unified_account_without_usdc_ignores_perp_summary() {
2660        let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2661            "../../test_data/http_clearinghouse_state_unified_without_usdc.json"
2662        ))
2663        .unwrap();
2664        let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2665            "../../test_data/http_spot_clearinghouse_state_unified_without_usdc.json"
2666        ))
2667        .unwrap();
2668
2669        let (balances, margins) = parse_combined_account_balances_and_margins(
2670            &perp_state,
2671            &spot_state,
2672            HyperliquidAccountAbstraction::UnifiedAccount,
2673        )
2674        .unwrap();
2675
2676        assert_eq!(balances.len(), 2);
2677        assert_eq!(balances[0].currency.code, "PURR");
2678        // USDC is reported as zero from spot rather than taken from the perp summary
2679        assert_eq!(balances[1].currency.code, "USDC");
2680        assert!(balances[1].total.is_zero());
2681        assert!(balances[1].free.is_zero());
2682        assert!(margins.is_empty());
2683    }
2684
2685    // Portfolio margin borrows against other collateral, so spot USDC can go negative.
2686    #[rstest]
2687    fn test_parse_combined_portfolio_margin_uses_spot_including_negative_usdc() {
2688        let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2689            "../../test_data/http_clearinghouse_state_portfolio_margin_negative_usdc.json"
2690        ))
2691        .unwrap();
2692        let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2693            "../../test_data/http_spot_clearinghouse_state_portfolio_margin_negative_usdc.json"
2694        ))
2695        .unwrap();
2696
2697        let (balances, margins) = parse_combined_account_balances_and_margins(
2698            &perp_state,
2699            &spot_state,
2700            HyperliquidAccountAbstraction::PortfolioMargin,
2701        )
2702        .unwrap();
2703
2704        assert_eq!(balances.len(), 2);
2705        assert_eq!(balances[0].currency.code, "USDC");
2706        assert_eq!(balances[0].total.as_decimal(), dec!(-25));
2707        assert_eq!(balances[1].currency.code, "HYPE");
2708        assert_eq!(margins.len(), 1);
2709        assert_eq!(margins[0].initial.as_decimal(), dec!(50));
2710    }
2711
2712    fn limit_order(price: &str) -> OrderAny {
2713        OrderAny::Limit(LimitOrder::new(
2714            TraderId::from("TESTER-001"),
2715            StrategyId::from("S-001"),
2716            InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2717            ClientOrderId::from("O-1"),
2718            OrderSide::Buy,
2719            Quantity::from(1),
2720            Price::from(price),
2721            TimeInForce::Gtc,
2722            None,
2723            false,
2724            false,
2725            false,
2726            None,
2727            None,
2728            None,
2729            None,
2730            None,
2731            None,
2732            None,
2733            None,
2734            None,
2735            None,
2736            None,
2737            Default::default(),
2738            Default::default(),
2739        ))
2740    }
2741
2742    #[rstest]
2743    // Venue-accepted forms pass: at the cap, integer, zero, trailing zeros
2744    #[case("78764.5", 1)]
2745    #[case("102393", 1)]
2746    #[case("0", 0)]
2747    #[case("0.11525", 5)]
2748    #[case("0.10", 1)]
2749    fn test_ensure_canonical_wire_price_accepts(#[case] price: &str, #[case] decimals: u8) {
2750        let value = Decimal::from_str(price).unwrap().normalize();
2751        ensure_canonical_wire_price("Price", value, decimals).unwrap();
2752    }
2753
2754    #[rstest]
2755    // Six significant figures with one decimal inside the cap: the venue
2756    // accepts these at signing (live-probed), so no false rejection.
2757    #[case("78764.5", 1)]
2758    #[case("102393", 1)]
2759    fn test_order_to_request_raw_price_accepts_canonical_boundary(
2760        #[case] price: &str,
2761        #[case] decimals: u8,
2762    ) {
2763        let request =
2764            order_to_hyperliquid_request_with_asset(&limit_order(price), 0, decimals, false, 50)
2765                .unwrap();
2766        assert_eq!(request.price, Decimal::from_str(price).unwrap());
2767    }
2768
2769    #[rstest]
2770    #[case("limit")]
2771    #[case("trigger")]
2772    fn test_order_conversion_matches_normalized_decimal_fixture(
2773        #[case] case_name: &str,
2774        #[values(false, true)] normalize_prices: bool,
2775    ) {
2776        let fixture: serde_json::Value =
2777            serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
2778                .unwrap();
2779        let case = fixture["cases"]
2780            .as_array()
2781            .unwrap()
2782            .iter()
2783            .find(|case| case["name"] == case_name)
2784            .unwrap();
2785        let input = &case["input"]["orders"][0];
2786        let order = if case_name == "limit" {
2787            limit_order(input["p"].as_str().unwrap())
2788        } else {
2789            stop_market_order(
2790                OrderSide::Buy,
2791                input["t"]["trigger"]["triggerPx"].as_str().unwrap(),
2792            )
2793        };
2794
2795        let converted = order_to_hyperliquid_request_with_asset_and_cloid(
2796            &order,
2797            0,
2798            0,
2799            normalize_prices,
2800            50,
2801            None,
2802        )
2803        .unwrap();
2804        let direct: HyperliquidExchangePlaceOrderRequest =
2805            serde_json::from_value(input.clone()).unwrap();
2806
2807        assert_eq!(
2808            serde_json::to_value(&converted).unwrap(),
2809            case["action"]["orders"][0]
2810        );
2811        assert_eq!(
2812            serde_json::to_value(&direct).unwrap(),
2813            serde_json::to_value(&converted).unwrap()
2814        );
2815        assert_eq!(
2816            rmp_serde::to_vec_named(&direct).unwrap(),
2817            rmp_serde::to_vec_named(&converted).unwrap()
2818        );
2819    }
2820
2821    #[rstest]
2822    fn test_order_to_request_raw_price_rejects_excess_decimals() {
2823        let err = order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, false, 50)
2824            .unwrap_err();
2825        let msg = err.to_string();
2826        assert!(msg.contains("Price 0.62201"), "unexpected message: {msg}");
2827        assert!(
2828            msg.contains("4 decimal places"),
2829            "unexpected message: {msg}"
2830        );
2831    }
2832
2833    #[rstest]
2834    fn test_order_to_request_normalize_still_accepts_excess_decimals() {
2835        let request =
2836            order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, true, 50)
2837                .unwrap();
2838        assert_eq!(request.price, dec!(0.622));
2839    }
2840
2841    #[rstest]
2842    fn test_order_to_request_raw_trigger_price_rejects_excess_decimals() {
2843        let order = stop_market_order(OrderSide::Sell, "0.62201");
2844        let err = order_to_hyperliquid_request_with_asset(&order, 0, 4, false, 50).unwrap_err();
2845        let msg = err.to_string();
2846        assert!(
2847            msg.contains("Trigger price 0.62201"),
2848            "unexpected message: {msg}"
2849        );
2850        assert!(
2851            msg.contains("4 decimal places"),
2852            "unexpected message: {msg}"
2853        );
2854    }
2855
2856    #[rstest]
2857    // Unknown instrument cap: validation is skipped and the prior raw
2858    // passthrough is preserved rather than validating against a placeholder.
2859    #[case(false)]
2860    // Unknown cap with normalization enabled: falls back to two decimals
2861    #[case(true)]
2862    fn test_order_to_request_optional_decimals_unknown_cap(#[case] normalize: bool) {
2863        let request = order_to_hyperliquid_request_with_optional_decimals(
2864            &limit_order("0.123456"),
2865            0,
2866            None,
2867            normalize,
2868            50,
2869            None,
2870        )
2871        .unwrap();
2872        let expected = if normalize {
2873            dec!(0.12)
2874        } else {
2875            dec!(0.123456)
2876        };
2877        assert_eq!(request.price, expected);
2878    }
2879}