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 or OUO, reduce-only, and reference the entry as parent.
1050/// - `PositionTpsl` (linked exit pair): every order is OCO or OUO, reduce-only,
1051///   and linked 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            matches!(
1062                o.contingency_type(),
1063                Some(ContingencyType::Oco | ContingencyType::Ouo)
1064            ) && o.is_reduce_only()
1065                && o.parent_order_id() == Some(entry_id)
1066        });
1067
1068        if entry_is_oto && children_are_linked {
1069            return HyperliquidExecGrouping::NormalTpsl;
1070        }
1071    }
1072
1073    let all_oco_linked = orders.len() >= 2
1074        && orders.iter().all(|o| {
1075            matches!(
1076                o.contingency_type(),
1077                Some(ContingencyType::Oco | ContingencyType::Ouo)
1078            ) && o.is_reduce_only()
1079        })
1080        && orders.iter().all(|o| {
1081            o.linked_order_ids().is_some_and(|ids| {
1082                ids.iter()
1083                    .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1084            })
1085        });
1086
1087    if all_oco_linked {
1088        HyperliquidExecGrouping::PositionTpsl
1089    } else {
1090        HyperliquidExecGrouping::Na
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use std::str::FromStr;
1097
1098    use nautilus_model::{
1099        enums::{OrderSide, TimeInForce, TriggerType},
1100        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1101        orders::{OrderAny, StopMarketOrder},
1102        types::{Price, Quantity},
1103    };
1104    use rstest::rstest;
1105    use rust_decimal::Decimal;
1106    use rust_decimal_macros::dec;
1107    use serde::{Deserialize, Serialize};
1108
1109    use super::*;
1110
1111    #[rstest]
1112    fn test_make_fill_trade_id_is_stable() {
1113        // Pins the deterministic FNV output so the Decimal `Display` hashing
1114        // stays stable for reconciliation dedup across the String->Decimal change.
1115        let id = make_fill_trade_id(
1116            "0xabc123",
1117            12345,
1118            dec!(50000.0),
1119            dec!(0.1),
1120            1704470400000,
1121            dec!(0.0),
1122        );
1123        assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1124    }
1125
1126    #[derive(Serialize, Deserialize)]
1127    struct TestStruct {
1128        #[serde(
1129            serialize_with = "serialize_decimal_as_str",
1130            deserialize_with = "deserialize_decimal_from_str"
1131        )]
1132        value: Decimal,
1133        #[serde(
1134            serialize_with = "serialize_optional_decimal_as_str",
1135            deserialize_with = "deserialize_optional_decimal_from_str"
1136        )]
1137        optional_value: Option<Decimal>,
1138    }
1139
1140    #[rstest]
1141    #[case("#10", 100_000_010, 1, 0)]
1142    #[case("+10", 100_000_010, 1, 0)]
1143    #[case("#31", 100_000_031, 3, 1)]
1144    #[case("+31", 100_000_031, 3, 1)]
1145    fn test_parse_outcome_symbol(
1146        #[case] symbol: &str,
1147        #[case] raw_asset_id: u32,
1148        #[case] outcome: u32,
1149        #[case] side: u8,
1150    ) {
1151        let asset_id = parse_outcome_symbol(symbol).unwrap();
1152        assert_eq!(asset_id.to_raw(), raw_asset_id);
1153        assert_eq!(asset_id.outcome_index(), Some(outcome));
1154        assert_eq!(asset_id.outcome_side(), Some(side));
1155    }
1156
1157    #[rstest]
1158    #[case("25-YES-OUTCOME", 25, 0)]
1159    #[case("25-NO-OUTCOME", 25, 1)]
1160    #[case("0-YES-OUTCOME", 0, 0)]
1161    #[case("999-NO-OUTCOME", 999, 1)]
1162    fn test_parse_outcome_nautilus_symbol(
1163        #[case] symbol: &str,
1164        #[case] outcome_index: u32,
1165        #[case] side: u8,
1166    ) {
1167        let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1168        assert_eq!(parsed, (outcome_index, side));
1169    }
1170
1171    #[rstest]
1172    #[case("25-OUTCOME")]
1173    #[case("25-MAYBE-OUTCOME")]
1174    #[case("25-yes-OUTCOME")]
1175    #[case("-YES-OUTCOME")]
1176    #[case("YES-25-OUTCOME")]
1177    #[case("25-YES-outcome")]
1178    #[case("25-YES")]
1179    fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1180        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1181    }
1182
1183    #[rstest]
1184    // outcome_index * 10 overflows u32.
1185    #[case("999999999-YES-OUTCOME")]
1186    // outcome_index * 10 fits but 100_000_000 + encoding overflows u32.
1187    #[case("429496729-YES-OUTCOME")]
1188    // u32::MAX itself; rejected on the multiply.
1189    #[case("4294967295-NO-OUTCOME")]
1190    fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1191        assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1192    }
1193
1194    #[rstest]
1195    #[case(25, 0, "25-YES-OUTCOME")]
1196    #[case(25, 1, "25-NO-OUTCOME")]
1197    #[case(0, 0, "0-YES-OUTCOME")]
1198    fn test_format_outcome_nautilus_symbol(
1199        #[case] outcome_index: u32,
1200        #[case] side: u8,
1201        #[case] expected: &str,
1202    ) {
1203        assert_eq!(
1204            format_outcome_nautilus_symbol(outcome_index, side),
1205            expected,
1206        );
1207    }
1208
1209    #[rstest]
1210    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1211    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1212    #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1213    #[case("BTC-USD-PERP", None)]
1214    #[case("+250", None)]
1215    fn test_outcome_token_from_nautilus_symbol(
1216        #[case] symbol: &str,
1217        #[case] expected: Option<String>,
1218    ) {
1219        assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1220    }
1221
1222    #[rstest]
1223    #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1224    #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1225    #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1226    #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1227    #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1228    #[case("+250", Some("+250".to_string()))]
1229    #[case("#250", Some("#250".to_string()))]
1230    #[case("", None)]
1231    fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1232        assert_eq!(cache_alias_for_symbol(symbol), expected);
1233    }
1234
1235    #[rstest]
1236    #[case("10", "expected #<encoding> or +<encoding>")]
1237    #[case("#", "encoding must not be empty")]
1238    #[case("#1a", "encoding must be numeric")]
1239    #[case("#12", "side digit 0 or 1")]
1240    #[case("#4294967295", "fit u32")]
1241    fn test_parse_outcome_symbol_rejects_invalid_values(
1242        #[case] symbol: &str,
1243        #[case] expected_error: &str,
1244    ) {
1245        let err = parse_outcome_symbol(symbol).unwrap_err();
1246        assert!(
1247            err.to_string().contains(expected_error),
1248            "expected error to contain '{expected_error}', received '{err}'",
1249        );
1250    }
1251
1252    #[rstest]
1253    fn test_decimal_serialization_roundtrip() {
1254        let original = TestStruct {
1255            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1256            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1257        };
1258
1259        let json = serde_json::to_string(&original).unwrap();
1260        println!("Serialized: {json}");
1261
1262        // Check that it's serialized as strings (rust_decimal may normalize precision)
1263        assert!(json.contains("\"123.45678901234567890123456789\""));
1264        assert!(json.contains("\"0.000000001\""));
1265
1266        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1267        assert_eq!(original.value, deserialized.value);
1268        assert_eq!(original.optional_value, deserialized.optional_value);
1269    }
1270
1271    #[rstest]
1272    fn test_decimal_precision_preservation() {
1273        let test_cases = [
1274            "0",
1275            "1",
1276            "0.1",
1277            "0.01",
1278            "0.001",
1279            "123.456789012345678901234567890",
1280            "999999999999999999.999999999999999999",
1281        ];
1282
1283        for case in test_cases {
1284            let decimal = Decimal::from_str(case).unwrap();
1285            let test_struct = TestStruct {
1286                value: decimal,
1287                optional_value: Some(decimal),
1288            };
1289
1290            let json = serde_json::to_string(&test_struct).unwrap();
1291            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1292
1293            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1294            assert_eq!(
1295                Some(decimal),
1296                parsed.optional_value,
1297                "Failed for case: {case}"
1298            );
1299        }
1300    }
1301
1302    #[rstest]
1303    fn test_optional_none_handling() {
1304        let test_struct = TestStruct {
1305            value: Decimal::from_str("42.0").unwrap(),
1306            optional_value: None,
1307        };
1308
1309        let json = serde_json::to_string(&test_struct).unwrap();
1310        assert!(json.contains("null"));
1311
1312        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1313        assert_eq!(test_struct.value, parsed.value);
1314        assert_eq!(None, parsed.optional_value);
1315    }
1316
1317    #[rstest]
1318    fn test_round_down_to_tick() {
1319        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1320        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1321        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1322
1323        // Edge case: zero tick size
1324        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1325    }
1326
1327    #[rstest]
1328    fn test_round_down_to_step() {
1329        assert_eq!(
1330            round_down_to_step(dec!(0.12349), dec!(0.0001)),
1331            dec!(0.1234)
1332        );
1333        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1334        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1335
1336        // Edge case: zero step size
1337        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1338    }
1339
1340    #[rstest]
1341    fn test_min_notional_validation() {
1342        // Should pass
1343        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1344        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1345
1346        // Should fail
1347        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1348        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1349
1350        // Edge case: exactly at minimum
1351        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1352    }
1353
1354    #[rstest]
1355    fn test_round_to_sig_figs() {
1356        // BTC price ~$104,567 needs to round to 5 sig figs
1357        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1358        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1359        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1360
1361        // Smaller prices should keep decimals
1362        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1363        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1364        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1365
1366        // Sub-1 values with leading zeros must preserve 5 sig figs
1367        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1368        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
1369
1370        // Zero case
1371        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1372
1373        assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1374        assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1375        assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1376        assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1377    }
1378
1379    #[rstest]
1380    fn test_normalize_price() {
1381        // Now includes 5 sig fig rounding first
1382        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1383        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
1384        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
1385        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
1386
1387        // BTC-like prices get rounded to 5 sig figs
1388        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
1389    }
1390
1391    #[rstest]
1392    fn test_normalize_quantity() {
1393        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1394        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1395        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1396        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1397    }
1398
1399    #[rstest]
1400    fn test_normalize_order_complete() {
1401        let result = normalize_order(
1402            dec!(100.12345), // price
1403            dec!(0.123456),  // qty
1404            dec!(0.01),      // tick_size
1405            dec!(0.0001),    // step_size
1406            dec!(10),        // min_notional
1407            2,               // price_decimals
1408            4,               // size_decimals
1409        );
1410
1411        assert!(result.is_ok());
1412        let (price, qty) = result.unwrap();
1413        assert_eq!(price, dec!(100.12)); // normalized and rounded down
1414        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
1415    }
1416
1417    #[rstest]
1418    fn test_normalize_order_min_notional_fail() {
1419        let result = normalize_order(
1420            dec!(100.12345), // price
1421            dec!(0.05),      // qty (too small for min notional)
1422            dec!(0.01),      // tick_size
1423            dec!(0.0001),    // step_size
1424            dec!(10),        // min_notional
1425            2,               // price_decimals
1426            4,               // size_decimals
1427        );
1428
1429        assert!(result.is_err());
1430        assert!(result.unwrap_err().contains("Notional value"));
1431    }
1432
1433    #[rstest]
1434    fn test_edge_cases() {
1435        // Test with very small numbers
1436        assert_eq!(
1437            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1438            dec!(0.000001)
1439        );
1440
1441        // Test with large numbers
1442        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1443
1444        // Test rounding edge case
1445        assert_eq!(
1446            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1447            dec!(100.00)
1448        );
1449    }
1450
1451    #[rstest]
1452    fn test_is_conditional_order_data() {
1453        // Test with trigger price and tpsl (conditional)
1454        assert!(is_conditional_order_data(
1455            Some(dec!(50000.0)),
1456            Some(&HyperliquidTpSl::Sl)
1457        ));
1458
1459        // Test with only trigger price (not conditional - needs both)
1460        assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1461
1462        // Test with only tpsl (not conditional - needs both)
1463        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1464
1465        // Test with no conditional fields
1466        assert!(!is_conditional_order_data(None, None));
1467    }
1468
1469    #[rstest]
1470    fn test_parse_trigger_order_type() {
1471        // Stop Market
1472        assert_eq!(
1473            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1474            OrderType::StopMarket
1475        );
1476
1477        // Stop Limit
1478        assert_eq!(
1479            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1480            OrderType::StopLimit
1481        );
1482
1483        // Take Profit Market
1484        assert_eq!(
1485            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1486            OrderType::MarketIfTouched
1487        );
1488
1489        // Take Profit Limit
1490        assert_eq!(
1491            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1492            OrderType::LimitIfTouched
1493        );
1494    }
1495
1496    #[rstest]
1497    fn test_parse_order_status_with_trigger() {
1498        // Test with open status and activated trigger
1499        let (status, trigger_status) =
1500            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1501        assert_eq!(status, OrderStatus::Accepted);
1502        assert_eq!(trigger_status, Some("activated".to_string()));
1503
1504        // Test with open status and not activated
1505        let (status, trigger_status) =
1506            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1507        assert_eq!(status, OrderStatus::Accepted);
1508        assert_eq!(trigger_status, Some("pending".to_string()));
1509
1510        // Test without trigger info
1511        let (status, trigger_status) =
1512            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1513        assert_eq!(status, OrderStatus::Accepted);
1514        assert_eq!(trigger_status, None);
1515    }
1516
1517    #[rstest]
1518    fn test_format_trailing_stop_info() {
1519        // Price offset
1520        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1521        assert!(info.contains("100.0"));
1522        assert!(info.contains("callback at 50000.0"));
1523
1524        // Percentage offset
1525        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1526        assert!(info.contains("5.0%"));
1527        assert!(info.contains("Trailing stop"));
1528
1529        // Basis points offset
1530        let info =
1531            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1532        assert!(info.contains("250 bps"));
1533        assert!(info.contains("49000.0"));
1534    }
1535
1536    #[rstest]
1537    fn test_parse_trigger_price() {
1538        // Valid price
1539        let result = parse_trigger_price("50000.0");
1540        assert!(result.is_ok());
1541        assert_eq!(result.unwrap(), dec!(50000.0));
1542
1543        // Valid integer price
1544        let result = parse_trigger_price("49000");
1545        assert!(result.is_ok());
1546        assert_eq!(result.unwrap(), dec!(49000));
1547
1548        // Invalid price
1549        let result = parse_trigger_price("invalid");
1550        assert!(result.is_err());
1551
1552        // Empty string
1553        let result = parse_trigger_price("");
1554        assert!(result.is_err());
1555    }
1556
1557    #[rstest]
1558    #[case(dec!(0), true, dec!(0))] // Zero
1559    #[case(dec!(0), false, dec!(0))] // Zero
1560    #[case(dec!(0.001), true, dec!(0.001005))] // Small price BUY
1561    #[case(dec!(0.001), false, dec!(0.000995))] // Small price SELL
1562    #[case(dec!(100), true, dec!(100.5))] // Round price BUY
1563    #[case(dec!(100), false, dec!(99.5))] // Round price SELL
1564    #[case(dec!(2470), true, dec!(2482.35))] // ETH-like BUY
1565    #[case(dec!(2470), false, dec!(2457.65))] // ETH-like SELL
1566    #[case(dec!(104567.3), true, dec!(105090.1365))] // BTC-like BUY
1567    #[case(dec!(104567.3), false, dec!(104044.4635))] // BTC-like SELL
1568    fn test_derive_limit_from_trigger(
1569        #[case] trigger_price: Decimal,
1570        #[case] is_buy: bool,
1571        #[case] expected: Decimal,
1572    ) {
1573        let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1574        assert_eq!(result, expected);
1575
1576        // Verify invariant: BUY limit >= trigger, SELL limit <= trigger
1577        if is_buy {
1578            assert!(result >= trigger_price);
1579        } else {
1580            assert!(result <= trigger_price);
1581        }
1582    }
1583
1584    #[rstest]
1585    // BUY rounds up (ceil)
1586    #[case(dec!(2457.65), 2, true, dec!(2457.65))] // Already at precision
1587    #[case(dec!(2457.65), 1, true, dec!(2457.7))] // Ceil to 1dp
1588    #[case(dec!(2457.65), 0, true, dec!(2458))] // Ceil to integer
1589    // SELL rounds down (floor)
1590    #[case(dec!(2457.65), 2, false, dec!(2457.65))] // Already at precision
1591    #[case(dec!(2457.65), 1, false, dec!(2457.6))] // Floor to 1dp
1592    #[case(dec!(2457.65), 0, false, dec!(2457))] // Floor to integer
1593    // High precision (no-op)
1594    #[case(dec!(0.4975), 4, true, dec!(0.4975))]
1595    #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1596    // Precision forces clamping on small values
1597    #[case(dec!(0.4975), 2, true, dec!(0.50))]
1598    #[case(dec!(0.4975), 2, false, dec!(0.49))]
1599    fn test_clamp_price_to_precision(
1600        #[case] price: Decimal,
1601        #[case] decimals: u8,
1602        #[case] is_buy: bool,
1603        #[case] expected: Decimal,
1604    ) {
1605        assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1606    }
1607
1608    fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1609        OrderAny::StopMarket(StopMarketOrder::new(
1610            TraderId::from("TESTER-001"),
1611            StrategyId::from("S-001"),
1612            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1613            ClientOrderId::from("O-001"),
1614            side,
1615            Quantity::from(1),
1616            Price::from(trigger_price),
1617            TriggerType::LastPrice,
1618            TimeInForce::Gtc,
1619            None,
1620            false,
1621            false,
1622            None,
1623            None,
1624            None,
1625            None,
1626            None,
1627            None,
1628            None,
1629            None,
1630            None,
1631            None,
1632            None,
1633            Default::default(),
1634            Default::default(),
1635        ))
1636    }
1637
1638    #[rstest]
1639    // ETH-like (precision=2): clamping is a no-op
1640    #[case(OrderSide::Sell, "2470.00", 2)]
1641    #[case(OrderSide::Buy, "2470.00", 2)]
1642    // BTC-like (precision=1): clamping is a no-op
1643    #[case(OrderSide::Sell, "104567.3", 1)]
1644    #[case(OrderSide::Buy, "104567.3", 1)]
1645    // Low-price token (precision=4): clamping is a no-op
1646    #[case(OrderSide::Sell, "0.50", 4)]
1647    #[case(OrderSide::Buy, "0.50", 4)]
1648    // Clamping materially changes: ETH trigger at precision=1
1649    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(1dp) = 2457.6
1650    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(1dp) = 2482.4
1651    #[case(OrderSide::Sell, "2470.00", 1)]
1652    #[case(OrderSide::Buy, "2470.00", 1)]
1653    // Clamping materially changes: precision=0 forces integer
1654    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(0dp) = 2457
1655    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(0dp) = 2483
1656    #[case(OrderSide::Sell, "2470.00", 0)]
1657    #[case(OrderSide::Buy, "2470.00", 0)]
1658    fn test_order_to_request_stop_market_derives_limit_from_trigger(
1659        #[case] side: OrderSide,
1660        #[case] trigger_str: &str,
1661        #[case] price_decimals: u8,
1662    ) {
1663        let order = stop_market_order(side, trigger_str);
1664        let request = order_to_hyperliquid_request_with_asset(
1665            &order,
1666            0,
1667            price_decimals,
1668            true,
1669            DEFAULT_MARKET_SLIPPAGE_BPS,
1670        )
1671        .unwrap();
1672        let trigger = Decimal::from_str(trigger_str).unwrap();
1673        let is_buy = matches!(side, OrderSide::Buy);
1674
1675        // Price must satisfy Hyperliquid's directional constraint
1676        if is_buy {
1677            assert!(
1678                request.price >= trigger,
1679                "BUY limit {} must be >= trigger {trigger}",
1680                request.price,
1681            );
1682            assert!(request.is_buy);
1683        } else {
1684            assert!(
1685                request.price <= trigger,
1686                "SELL limit {} must be <= trigger {trigger}",
1687                request.price,
1688            );
1689            assert!(!request.is_buy);
1690        }
1691
1692        // Price must equal the full pipeline: derive -> sig figs -> clamp -> normalize
1693        let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1694        let sig_rounded = round_to_sig_figs(derived, 5);
1695        let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1696        assert_eq!(request.price, expected);
1697
1698        // Decimal places must not exceed instrument precision
1699        let price_str = request.price.to_string();
1700        let actual_decimals = price_str
1701            .find('.')
1702            .map_or(0, |dot| price_str.len() - dot - 1);
1703        assert!(
1704            actual_decimals <= price_decimals as usize,
1705            "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1706        );
1707
1708        // Decimal trailing zeros must be stripped (canonical form)
1709        if price_str.contains('.') {
1710            assert!(
1711                !price_str.ends_with('0'),
1712                "Price {price_str} has decimal trailing zeros",
1713            );
1714        }
1715
1716        let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1717        assert_eq!(
1718            request.kind,
1719            HyperliquidExecOrderKind::Trigger {
1720                trigger: HyperliquidExecTriggerParams {
1721                    is_market: true,
1722                    trigger_px: expected_trigger,
1723                    tpsl: HyperliquidExecTpSl::Sl,
1724                },
1725            },
1726        );
1727    }
1728
1729    fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1730        HyperliquidExchangeResponse::Status {
1731            status: "ok".to_string(),
1732            response: inner,
1733        }
1734    }
1735
1736    #[rstest]
1737    fn test_extract_inner_error_order_with_error() {
1738        let response = ok_response(serde_json::json!({
1739            "type": "order",
1740            "data": {"statuses": [{"error": "Order has invalid price."}]}
1741        }));
1742        assert_eq!(
1743            extract_inner_error(&response),
1744            Some("Order has invalid price.".to_string()),
1745        );
1746    }
1747
1748    #[rstest]
1749    fn test_extract_inner_error_order_resting() {
1750        let response = ok_response(serde_json::json!({
1751            "type": "order",
1752            "data": {"statuses": [{"resting": {"oid": 12345}}]}
1753        }));
1754        assert_eq!(extract_inner_error(&response), None);
1755    }
1756
1757    #[rstest]
1758    fn test_extract_inner_error_order_filled() {
1759        let response = ok_response(serde_json::json!({
1760            "type": "order",
1761            "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1762        }));
1763        assert_eq!(extract_inner_error(&response), None);
1764    }
1765
1766    #[rstest]
1767    fn test_extract_inner_error_cancel_error() {
1768        let response = ok_response(serde_json::json!({
1769            "type": "cancel",
1770            "data": {"statuses": [{"error": "Order not found"}]}
1771        }));
1772        assert_eq!(
1773            extract_inner_error(&response),
1774            Some("Order not found".to_string()),
1775        );
1776    }
1777
1778    #[rstest]
1779    fn test_extract_inner_error_cancel_success() {
1780        let response = ok_response(serde_json::json!({
1781            "type": "cancel",
1782            "data": {"statuses": ["success"]}
1783        }));
1784        assert_eq!(extract_inner_error(&response), None);
1785    }
1786
1787    #[rstest]
1788    fn test_extract_inner_error_modify_error() {
1789        let response = ok_response(serde_json::json!({
1790            "type": "modify",
1791            "data": {"statuses": [{"error": "Invalid modify"}]}
1792        }));
1793        assert_eq!(
1794            extract_inner_error(&response),
1795            Some("Invalid modify".to_string()),
1796        );
1797    }
1798
1799    #[rstest]
1800    fn test_extract_inner_error_modify_success() {
1801        let response = ok_response(serde_json::json!({
1802            "type": "modify",
1803            "data": {"statuses": ["success"]}
1804        }));
1805        assert_eq!(extract_inner_error(&response), None);
1806    }
1807
1808    #[rstest]
1809    fn test_extract_inner_error_non_status_response() {
1810        let response = HyperliquidExchangeResponse::Error {
1811            error: "top-level error".to_string(),
1812        };
1813        assert_eq!(extract_inner_error(&response), None);
1814    }
1815
1816    #[rstest]
1817    fn test_extract_inner_error_unparsable_response() {
1818        let response = ok_response(serde_json::json!({"unknown": "data"}));
1819        assert_eq!(extract_inner_error(&response), None);
1820    }
1821
1822    #[rstest]
1823    fn test_extract_inner_error_returns_first_error_in_batch() {
1824        let response = ok_response(serde_json::json!({
1825            "type": "order",
1826            "data": {"statuses": [
1827                {"resting": {"oid": 1}},
1828                {"error": "Second failed"},
1829                {"error": "Third failed"},
1830            ]}
1831        }));
1832        assert_eq!(
1833            extract_inner_error(&response),
1834            Some("Second failed".to_string()),
1835        );
1836    }
1837
1838    #[rstest]
1839    fn test_extract_inner_errors_mixed_batch() {
1840        let response = ok_response(serde_json::json!({
1841            "type": "order",
1842            "data": {"statuses": [
1843                {"resting": {"oid": 1}},
1844                {"error": "Failed order"},
1845                {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1846            ]}
1847        }));
1848        let errors = extract_inner_errors(&response);
1849        assert_eq!(errors.len(), 3);
1850        assert_eq!(errors[0], None);
1851        assert_eq!(errors[1], Some("Failed order".to_string()));
1852        assert_eq!(errors[2], None);
1853    }
1854
1855    #[rstest]
1856    fn test_extract_inner_errors_all_success() {
1857        let response = ok_response(serde_json::json!({
1858            "type": "order",
1859            "data": {"statuses": [
1860                {"resting": {"oid": 1}},
1861                {"resting": {"oid": 2}},
1862            ]}
1863        }));
1864        let errors = extract_inner_errors(&response);
1865        assert_eq!(errors.len(), 2);
1866        assert!(errors.iter().all(|e| e.is_none()));
1867    }
1868
1869    #[rstest]
1870    fn test_extract_inner_errors_cancel_success() {
1871        let response = ok_response(serde_json::json!({
1872            "type": "cancel",
1873            "data": {"statuses": ["success"]}
1874        }));
1875        let errors = extract_inner_errors(&response);
1876        assert_eq!(errors.len(), 1);
1877        assert!(errors[0].is_none());
1878    }
1879
1880    #[rstest]
1881    fn test_extract_inner_errors_cancel_mixed() {
1882        let response = ok_response(serde_json::json!({
1883            "type": "cancel",
1884            "data": {"statuses": [
1885                "success",
1886                {"error": "Order was never placed, already canceled, or filled."},
1887                "success",
1888            ]}
1889        }));
1890        let errors = extract_inner_errors(&response);
1891        assert_eq!(errors.len(), 3);
1892        assert_eq!(errors[0], None);
1893        assert_eq!(
1894            errors[1],
1895            Some("Order was never placed, already canceled, or filled.".to_string())
1896        );
1897        assert_eq!(errors[2], None);
1898    }
1899
1900    #[rstest]
1901    fn test_extract_inner_errors_modify_mixed() {
1902        let response = ok_response(serde_json::json!({
1903            "type": "modify",
1904            "data": {"statuses": [
1905                "success",
1906                {"error": "Order does not exist"},
1907            ]}
1908        }));
1909        let errors = extract_inner_errors(&response);
1910        assert_eq!(errors.len(), 2);
1911        assert_eq!(errors[0], None);
1912        assert_eq!(errors[1], Some("Order does not exist".to_string()));
1913    }
1914
1915    #[rstest]
1916    fn test_extract_inner_errors_unparsable() {
1917        let response = ok_response(serde_json::json!({"foo": "bar"}));
1918        let errors = extract_inner_errors(&response);
1919        assert!(errors.is_empty());
1920    }
1921
1922    fn count_sig_figs(s: &str) -> usize {
1923        let s = s.trim_start_matches('-');
1924        if s.contains('.') {
1925            // Decimal: all digits excluding leading zeros are significant
1926            let digits: String = s.replace('.', "");
1927            digits.trim_start_matches('0').len()
1928        } else {
1929            // Integer: trailing zeros are place-holders, not significant
1930            let s = s.trim_start_matches('0');
1931            s.trim_end_matches('0').len()
1932        }
1933    }
1934
1935    fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1936        QuoteTick::new(
1937            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1938            Price::from(bid),
1939            Price::from(ask),
1940            Quantity::from("1"),
1941            Quantity::from("1"),
1942            Default::default(),
1943            Default::default(),
1944        )
1945    }
1946
1947    #[rstest]
1948    // BUY uses ask, SELL uses bid
1949    // Pipeline: base → +/-0.5% slippage → round 5 sig figs → clamp → normalize
1950    //
1951    // ETH-like (precision=2)
1952    // BUY: ask=2470 → 2470*1.005=2482.35 → sig5=2482.4 → clamp(2,ceil)=2482.40 → 2482.4
1953    #[case("2460.00", "2470.00", true, 2, "2482.4")]
1954    // SELL: bid=2460 → 2460*0.995=2447.70 → sig5=2447.7 → clamp(2,floor)=2447.70 → 2447.7
1955    #[case("2460.00", "2470.00", false, 2, "2447.7")]
1956    //
1957    // BTC-like (precision=1)
1958    // BUY: ask=104567.3 → 104567.3*1.005=105090.1365 → sig5=105090 → clamp(1,ceil)=105090 → 105090
1959    #[case("104500.0", "104567.3", true, 1, "105090")]
1960    // SELL: bid=104500.0 → 104500*0.995=103977.5 → sig5=103980 → clamp(1,floor)=103980 → 103980
1961    #[case("104500.0", "104567.3", false, 1, "103980")]
1962    //
1963    // Low-price token (precision=4)
1964    // BUY: ask=0.5000 → 0.5*1.005=0.5025 → sig5=0.50250 → clamp(4,ceil)=0.5025 → 0.5025
1965    #[case("0.4900", "0.5000", true, 4, "0.5025")]
1966    // SELL: bid=0.49 → 0.49*0.995=0.48755 → sig5=0.48755 → clamp(4,floor)=0.4875 → 0.4875
1967    #[case("0.4900", "0.5000", false, 4, "0.4875")]
1968    //
1969    // High-price low-precision (precision=0)
1970    // BUY: ask=50000 → 50000*1.005=50250 → sig5=50250 → clamp(0,ceil)=50250 → 50250
1971    #[case("49900", "50000", true, 0, "50250")]
1972    // SELL: bid=49900 → 49900*0.995=49650.5 → sig5=49650 → clamp(0,floor)=49650 → 49650
1973    #[case("49900", "50000", false, 0, "49650")]
1974    //
1975    // Very small price (precision=6)
1976    // BUY: ask=0.001234 → 0.001234*1.005=0.0012402 → sig5=0.0012402 → clamp(6,ceil)=0.001241
1977    #[case("0.001200", "0.001234", true, 6, "0.001241")]
1978    // SELL: bid=0.0012 → 0.0012*0.995=0.001194 → sig5=0.001194 → clamp(6,floor)=0.001194
1979    #[case("0.001200", "0.001234", false, 6, "0.001194")]
1980    fn test_derive_market_order_price(
1981        #[case] bid: &str,
1982        #[case] ask: &str,
1983        #[case] is_buy: bool,
1984        #[case] price_decimals: u8,
1985        #[case] expected: &str,
1986    ) {
1987        let quote = make_quote(bid, ask);
1988        let result =
1989            derive_market_order_price(&quote, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1990        let expected_dec = Decimal::from_str(expected).unwrap();
1991        assert_eq!(result, expected_dec);
1992
1993        // Verify the result matches the full pipeline manually
1994        let base = if is_buy {
1995            quote.ask_price.as_decimal()
1996        } else {
1997            quote.bid_price.as_decimal()
1998        };
1999        let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2000        let sig_rounded = round_to_sig_figs(derived, 5);
2001        let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2002        assert_eq!(result, pipeline);
2003
2004        // Must not have trailing zeros after decimal point
2005        let s = result.to_string();
2006        if s.contains('.') {
2007            assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2008        }
2009
2010        // Sig figs must not exceed 5
2011        let sig_count = count_sig_figs(&s);
2012        assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2013
2014        // Decimal places must not exceed instrument precision
2015        let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2016        assert!(
2017            actual_decimals <= price_decimals as usize,
2018            "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2019        );
2020    }
2021
2022    #[rstest]
2023    #[case(50, dec!(1000), true, dec!(1005))] // default 0.5% BUY
2024    #[case(50, dec!(1000), false, dec!(995))] // default 0.5% SELL
2025    #[case(0, dec!(1000), true, dec!(1000))] // 0 bps: no adjustment
2026    #[case(100, dec!(1000), true, dec!(1010))] // 1% BUY
2027    #[case(100, dec!(1000), false, dec!(990))] // 1% SELL
2028    #[case(800, dec!(1000), true, dec!(1080))] // 8% (Hyperliquid SDK default) BUY
2029    #[case(800, dec!(1000), false, dec!(920))] // 8% SELL
2030    fn test_derive_limit_from_trigger_respects_bps(
2031        #[case] slippage_bps: u32,
2032        #[case] trigger: Decimal,
2033        #[case] is_buy: bool,
2034        #[case] expected: Decimal,
2035    ) {
2036        let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2037        assert_eq!(result, expected);
2038    }
2039
2040    #[rstest]
2041    fn test_derive_market_order_price_respects_slippage_override() {
2042        let quote = make_quote("100.00", "100.10");
2043        let tight = derive_market_order_price(&quote, true, 2, 50);
2044        let wide = derive_market_order_price(&quote, true, 2, 800);
2045        assert_eq!(tight, dec!(100.6));
2046        assert_eq!(wide, dec!(108.11));
2047        assert!(wide > tight);
2048    }
2049
2050    // Locks in the field-selection invariant; diverging from it would silently
2051    // disagree with the HTTP parser whenever `account_value != total_raw_usd`
2052    // or the nested and top-level `withdrawable` values differ.
2053    #[rstest]
2054    fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2055        let json = r#"{
2056            "assetPositions": [],
2057            "crossMarginSummary": {
2058                "accountValue": "150",
2059                "totalNtlPos": "0",
2060                "totalRawUsd": "100",
2061                "totalMarginUsed": "20",
2062                "withdrawable": "120"
2063            },
2064            "withdrawable": "80",
2065            "time": 1700000000000
2066        }"#;
2067
2068        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2069        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2070
2071        assert_eq!(balances.len(), 1);
2072        let balance = &balances[0];
2073        // Total comes from total_raw_usd (100), not account_value (150); free comes
2074        // from top-level state.withdrawable (80), not the nested summary.withdrawable (120).
2075        assert_eq!(balance.total.as_decimal(), dec!(100));
2076        assert_eq!(balance.free.as_decimal(), dec!(80));
2077        assert_eq!(balance.locked.as_decimal(), dec!(20));
2078
2079        assert_eq!(margins.len(), 1);
2080        assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2081    }
2082
2083    #[rstest]
2084    fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2085        let json =
2086            include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2087
2088        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2089        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2090
2091        assert_eq!(balances.len(), 1);
2092        let balance = &balances[0];
2093        assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2094        assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2095        assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2096
2097        assert_eq!(margins.len(), 1);
2098        assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2099    }
2100
2101    #[rstest]
2102    fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2103        let json = r#"{
2104            "assetPositions": [],
2105            "crossMarginSummary": {
2106                "accountValue": "100",
2107                "totalNtlPos": "0",
2108                "totalRawUsd": "100",
2109                "totalMarginUsed": "0",
2110                "withdrawable": "100"
2111            },
2112            "withdrawable": "150",
2113            "time": 1700000000000
2114        }"#;
2115
2116        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2117        let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2118
2119        assert_eq!(balances.len(), 1);
2120        let balance = &balances[0];
2121        assert_eq!(balance.total.as_decimal(), dec!(150));
2122        assert_eq!(balance.free.as_decimal(), dec!(150));
2123        assert_eq!(balance.locked.as_decimal(), dec!(0));
2124    }
2125
2126    #[rstest]
2127    fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2128        let json = r#"{
2129            "assetPositions": [],
2130            "withdrawable": "100",
2131            "time": 1700000000000
2132        }"#;
2133
2134        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2135        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2136        assert!(balances.is_empty());
2137        assert!(margins.is_empty());
2138    }
2139
2140    #[rstest]
2141    fn test_parse_spot_account_balances_emits_one_per_token() {
2142        let json = r#"{
2143            "balances": [
2144                {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2145                {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2146                {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2147            ]
2148        }"#;
2149
2150        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2151        let balances = parse_spot_account_balances(&state).unwrap();
2152
2153        assert_eq!(balances.len(), 2);
2154
2155        let usdc = &balances[0];
2156        assert_eq!(usdc.currency.code.as_str(), "USDC");
2157        assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2158        assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2159        assert_eq!(usdc.locked.as_decimal(), dec!(10));
2160
2161        let purr = &balances[1];
2162        assert_eq!(purr.currency.code.as_str(), "PURR");
2163        assert_eq!(purr.total.as_decimal(), dec!(50));
2164        assert_eq!(purr.free.as_decimal(), dec!(50));
2165    }
2166
2167    #[rstest]
2168    fn test_parse_spot_account_balances_clamps_hold_to_total() {
2169        let json = r#"{
2170            "balances": [
2171                {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2172            ]
2173        }"#;
2174
2175        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2176        let balances = parse_spot_account_balances(&state).unwrap();
2177
2178        assert_eq!(balances.len(), 1);
2179        let hype = &balances[0];
2180        assert_eq!(hype.total.as_decimal(), dec!(5));
2181        assert_eq!(hype.free.as_decimal(), dec!(0));
2182        assert_eq!(hype.locked.as_decimal(), dec!(5));
2183    }
2184
2185    #[rstest]
2186    fn test_parse_spot_account_balances_empty() {
2187        let state = SpotClearinghouseState::default();
2188        let balances = parse_spot_account_balances(&state).unwrap();
2189        assert!(balances.is_empty());
2190    }
2191
2192    #[rstest]
2193    fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2194        let perp_json = r#"{
2195            "assetPositions": [],
2196            "crossMarginSummary": {
2197                "accountValue": "500",
2198                "totalNtlPos": "0",
2199                "totalRawUsd": "500",
2200                "totalMarginUsed": "0",
2201                "withdrawable": "500"
2202            },
2203            "withdrawable": "500"
2204        }"#;
2205        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2206
2207        let spot_json = r#"{
2208            "balances": [
2209                {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2210                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2211            ]
2212        }"#;
2213        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2214
2215        let (balances, margins) =
2216            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2217
2218        assert!(margins.is_empty());
2219        assert_eq!(balances.len(), 2);
2220        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2221        assert_eq!(balances[0].total.as_decimal(), dec!(500));
2222        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2223        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2224    }
2225
2226    #[rstest]
2227    fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2228        let perp_json = r#"{
2229            "assetPositions": [],
2230            "crossMarginSummary": {
2231                "accountValue": "0",
2232                "totalNtlPos": "0",
2233                "totalRawUsd": "0",
2234                "totalMarginUsed": "0",
2235                "withdrawable": "0"
2236            },
2237            "withdrawable": "0"
2238        }"#;
2239        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2240
2241        let spot_json = r#"{
2242            "balances": [
2243                {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2244                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2245            ]
2246        }"#;
2247        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2248
2249        let (balances, margins) =
2250            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2251
2252        assert!(margins.is_empty());
2253        assert_eq!(balances.len(), 2);
2254        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2255        assert_eq!(balances[0].total.as_decimal(), dec!(75));
2256        assert_eq!(balances[0].free.as_decimal(), dec!(70));
2257        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2258        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2259    }
2260
2261    #[rstest]
2262    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2263        let perp_json = r#"{
2264            "assetPositions": [],
2265            "crossMarginSummary": {
2266                "accountValue": "50",
2267                "totalNtlPos": "0",
2268                "totalRawUsd": "50",
2269                "totalMarginUsed": "0",
2270                "withdrawable": "0"
2271            },
2272            "withdrawable": "0"
2273        }"#;
2274        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2275
2276        let spot_json = r#"{
2277            "balances": [
2278                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2279                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2280            ]
2281        }"#;
2282        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2283
2284        let (balances, margins) =
2285            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2286
2287        assert!(margins.is_empty());
2288        assert_eq!(balances.len(), 2);
2289        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2290        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2291        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2292        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2293    }
2294
2295    #[rstest]
2296    fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2297        let perp_json = r#"{
2298            "assetPositions": [],
2299            "crossMarginSummary": {
2300                "accountValue": "-50",
2301                "totalNtlPos": "0",
2302                "totalRawUsd": "-50",
2303                "totalMarginUsed": "0",
2304                "withdrawable": "0"
2305            },
2306            "withdrawable": "0"
2307        }"#;
2308        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2309
2310        let spot_json = r#"{
2311            "balances": [
2312                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2313                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2314            ]
2315        }"#;
2316        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2317
2318        let (balances, margins) =
2319            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2320
2321        assert!(margins.is_empty());
2322        assert_eq!(balances.len(), 2);
2323        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2324        assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2325        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2326        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2327    }
2328
2329    #[rstest]
2330    fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2331        let perp_json = r#"{
2332            "assetPositions": [],
2333            "crossMarginSummary": {
2334                "accountValue": "0",
2335                "totalNtlPos": "0",
2336                "totalRawUsd": "0",
2337                "totalMarginUsed": "25",
2338                "withdrawable": "0"
2339            },
2340            "withdrawable": "0"
2341        }"#;
2342        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2343
2344        let spot_json = r#"{
2345            "balances": [
2346                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2347                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2348            ]
2349        }"#;
2350        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2351
2352        let (balances, margins) =
2353            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2354
2355        assert_eq!(margins.len(), 1);
2356        assert_eq!(balances.len(), 2);
2357        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2358        assert_eq!(balances[0].total.as_decimal(), dec!(0));
2359        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2360        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2361    }
2362
2363    #[rstest]
2364    fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2365        let perp_json = r#"{
2366            "assetPositions": [],
2367            "crossMarginSummary": {
2368                "accountValue": "0",
2369                "totalNtlPos": "0",
2370                "totalRawUsd": "0",
2371                "totalMarginUsed": "0",
2372                "withdrawable": "50"
2373            },
2374            "withdrawable": "50"
2375        }"#;
2376        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2377
2378        let spot_json = r#"{
2379            "balances": [
2380                {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2381                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2382            ]
2383        }"#;
2384        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2385
2386        let (balances, margins) =
2387            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2388
2389        assert!(margins.is_empty());
2390        assert_eq!(balances.len(), 2);
2391        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2392        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2393        assert_eq!(balances[0].free.as_decimal(), dec!(50));
2394        assert_eq!(balances[1].currency.code.as_str(), "PURR");
2395        assert_eq!(balances[1].total.as_decimal(), dec!(10));
2396    }
2397
2398    #[rstest]
2399    fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2400        let perp_json = r#"{"assetPositions": []}"#;
2401        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2402
2403        let spot_json = r#"{
2404            "balances": [
2405                {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2406            ]
2407        }"#;
2408        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2409
2410        let (balances, _) =
2411            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2412
2413        assert_eq!(balances.len(), 1);
2414        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2415        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2416    }
2417}