Skip to main content

nautilus_hyperliquid/common/
parse.rs

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