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: &str,
110    sz: &str,
111    time: u64,
112    start_position: &str,
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.as_bytes() {
127        h ^= b as u64;
128        h = h.wrapping_mul(0x0100_0000_01b3);
129    }
130
131    for &b in sz.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.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    // Find order of magnitude using log10
191    let abs_val = value.abs();
192    let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
193    let magnitude = float_val.log10().floor() as i32;
194
195    // Calculate shift to round to sig_figs
196    let shift = sig_figs as i32 - 1 - magnitude;
197    let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
198
199    if shift >= 0 {
200        (value * factor).round() / factor
201    } else {
202        (value / factor).round() * factor
203    }
204}
205
206/// Normalize price to the specified number of decimal places.
207pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
208    // First round to 5 significant figures (Hyperliquid requirement)
209    let sig_fig_price = round_to_sig_figs(price, 5);
210    // Then truncate to max decimal places
211    let scale = Decimal::from(10_u64.pow(decimals as u32));
212    (sig_fig_price * scale).floor() / scale
213}
214
215/// Normalize quantity to the specified number of decimal places.
216pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
217    let scale = Decimal::from(10_u64.pow(decimals as u32));
218    (qty * scale).floor() / scale
219}
220
221/// Complete normalization for an order including price, quantity, and notional validation
222pub fn normalize_order(
223    price: Decimal,
224    qty: Decimal,
225    tick_size: Decimal,
226    step_size: Decimal,
227    min_notional: Decimal,
228    price_decimals: u8,
229    size_decimals: u8,
230) -> Result<(Decimal, Decimal), String> {
231    // Normalize to decimal places first
232    let normalized_price = normalize_price(price, price_decimals);
233    let normalized_qty = normalize_quantity(qty, size_decimals);
234
235    // Round down to tick/step sizes
236    let final_price = round_down_to_tick(normalized_price, tick_size);
237    let final_qty = round_down_to_step(normalized_qty, step_size);
238
239    // Validate minimum notional
240    ensure_min_notional(final_price, final_qty, min_notional)?;
241
242    Ok((final_price, final_qty))
243}
244
245/// Converts millisecond timestamp to [`UnixNanos`].
246#[inline]
247pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
248    let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
249    Ok(UnixNanos::from(value))
250}
251
252/// Parses an outcome (HIP-4) spot coin or token symbol into an asset ID.
253///
254/// Hyperliquid represents outcome spot coins as `#<encoding>` and outcome
255/// token names as `+<encoding>`, where `encoding = 10 * outcome + side`.
256///
257/// # Errors
258///
259/// Returns an error if the symbol is not an outcome symbol, the encoding is
260/// not numeric, overflows the asset id range, or carries an invalid side digit.
261pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
262    let encoding = parse_outcome_symbol_encoding(symbol)?;
263    HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
264        format!(
265            "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
266        )
267    })
268}
269
270fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
271    let encoding = symbol
272        .strip_prefix('#')
273        .or_else(|| symbol.strip_prefix('+'))
274        .with_context(|| {
275            format!(
276                "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
277            )
278        })?;
279
280    if encoding.is_empty() {
281        anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
282    }
283
284    if !encoding.bytes().all(|b| b.is_ascii_digit()) {
285        anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
286    }
287
288    encoding
289        .parse::<u32>()
290        .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
291}
292
293/// Converts a Nautilus `TimeInForce` to Hyperliquid TIF.
294///
295/// # Errors
296///
297/// Returns an error if the time in force is not supported.
298pub fn time_in_force_to_hyperliquid_tif(
299    tif: TimeInForce,
300    is_post_only: bool,
301) -> anyhow::Result<HyperliquidExecTif> {
302    match (tif, is_post_only) {
303        (_, true) => Ok(HyperliquidExecTif::Alo), // Always use ALO for post-only orders
304        (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
305        (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
306        (TimeInForce::Fok, false) => {
307            anyhow::bail!("FOK time in force is not supported by Hyperliquid")
308        }
309        _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
310    }
311}
312
313fn determine_tpsl_type(
314    order_type: OrderType,
315    order_side: OrderSide,
316    trigger_price: Decimal,
317    current_price: Option<Decimal>,
318) -> HyperliquidExecTpSl {
319    match order_type {
320        // Stop orders are protective - always SL
321        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
322
323        // If Touched orders are profit-taking or entry orders - always TP
324        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
325
326        // For other trigger types, try to infer from price relationship if available
327        _ => {
328            if let Some(current) = current_price {
329                match order_side {
330                    OrderSide::Buy => {
331                        // Buy order: trigger above market = stop loss, below = take profit
332                        if trigger_price > current {
333                            HyperliquidExecTpSl::Sl
334                        } else {
335                            HyperliquidExecTpSl::Tp
336                        }
337                    }
338                    OrderSide::Sell => {
339                        // Sell order: trigger below market = stop loss, above = take profit
340                        if trigger_price < current {
341                            HyperliquidExecTpSl::Sl
342                        } else {
343                            HyperliquidExecTpSl::Tp
344                        }
345                    }
346                    _ => HyperliquidExecTpSl::Sl, // Default to SL for safety
347                }
348            } else {
349                // No market price available, default to SL for safety
350                HyperliquidExecTpSl::Sl
351            }
352        }
353    }
354}
355
356/// Converts a Nautilus `BarType` to a Hyperliquid bar interval.
357///
358/// # Errors
359///
360/// Returns an error if the bar type uses an unsupported aggregation or step value.
361pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
362    let spec = bar_type.spec();
363    let step = spec.step.get();
364
365    anyhow::ensure!(
366        bar_type.aggregation_source() == AggregationSource::External,
367        "Only EXTERNAL aggregation is supported"
368    );
369
370    let interval = match spec.aggregation {
371        BarAggregation::Minute => match step {
372            1 => OneMinute,
373            3 => ThreeMinutes,
374            5 => FiveMinutes,
375            15 => FifteenMinutes,
376            30 => ThirtyMinutes,
377            _ => anyhow::bail!("Unsupported minute step: {step}"),
378        },
379        BarAggregation::Hour => match step {
380            1 => OneHour,
381            2 => TwoHours,
382            4 => FourHours,
383            8 => EightHours,
384            12 => TwelveHours,
385            _ => anyhow::bail!("Unsupported hour step: {step}"),
386        },
387        BarAggregation::Day => match step {
388            1 => OneDay,
389            3 => ThreeDays,
390            _ => anyhow::bail!("Unsupported day step: {step}"),
391        },
392        BarAggregation::Week if step == 1 => OneWeek,
393        BarAggregation::Month if step == 1 => OneMonth,
394        a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
395    };
396
397    Ok(interval)
398}
399
400/// Converts a Nautilus order to Hyperliquid request using a pre-resolved asset index.
401///
402/// This variant is used when the caller has already resolved the asset index
403/// from the instrument cache (e.g., for SPOT instruments where the index
404/// cannot be derived from the symbol alone). `slippage_bps` controls the
405/// buffer applied when deriving a limit from a stop trigger price.
406pub fn order_to_hyperliquid_request_with_asset(
407    order: &OrderAny,
408    asset: u32,
409    price_decimals: u8,
410    should_normalize_prices: bool,
411    slippage_bps: u32,
412) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
413    let is_buy = matches!(order.order_side(), OrderSide::Buy);
414    let reduce_only = order.is_reduce_only();
415    let order_side = order.order_side();
416    let order_type = order.order_type();
417
418    // Normalize decimals to strip trailing zeros, matching the server's
419    // canonical form used for EIP-712 signing hash verification.
420    let price_decimal = if let Some(price) = order.price() {
421        let raw = price.as_decimal();
422
423        if should_normalize_prices {
424            normalize_price(raw, price_decimals).normalize()
425        } else {
426            raw.normalize()
427        }
428    } else if matches!(order_type, OrderType::Market) {
429        Decimal::ZERO
430    } else if matches!(
431        order_type,
432        OrderType::StopMarket | OrderType::MarketIfTouched
433    ) {
434        match order.trigger_price() {
435            Some(tp) => {
436                let base = tp.as_decimal().normalize();
437                let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
438                let sig_rounded = round_to_sig_figs(derived, 5);
439                clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
440            }
441            None => Decimal::ZERO,
442        }
443    } else {
444        anyhow::bail!("Limit orders require a price")
445    };
446
447    let size_decimal = order.quantity().as_decimal().normalize();
448
449    // Determine order kind based on order type
450    let kind = match order_type {
451        OrderType::Market => HyperliquidExecOrderKind::Limit {
452            limit: HyperliquidExecLimitParams {
453                tif: HyperliquidExecTif::Ioc,
454            },
455        },
456        OrderType::Limit => {
457            let tif =
458                time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
459            HyperliquidExecOrderKind::Limit {
460                limit: HyperliquidExecLimitParams { tif },
461            }
462        }
463        OrderType::StopMarket => {
464            if let Some(trigger_price) = order.trigger_price() {
465                let raw = trigger_price.as_decimal();
466                let trigger_price_decimal = if should_normalize_prices {
467                    normalize_price(raw, price_decimals).normalize()
468                } else {
469                    raw.normalize()
470                };
471                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
472                HyperliquidExecOrderKind::Trigger {
473                    trigger: HyperliquidExecTriggerParams {
474                        is_market: true,
475                        trigger_px: trigger_price_decimal,
476                        tpsl,
477                    },
478                }
479            } else {
480                anyhow::bail!("Stop market orders require a trigger price")
481            }
482        }
483        OrderType::StopLimit => {
484            if let Some(trigger_price) = order.trigger_price() {
485                let raw = trigger_price.as_decimal();
486                let trigger_price_decimal = if should_normalize_prices {
487                    normalize_price(raw, price_decimals).normalize()
488                } else {
489                    raw.normalize()
490                };
491                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
492                HyperliquidExecOrderKind::Trigger {
493                    trigger: HyperliquidExecTriggerParams {
494                        is_market: false,
495                        trigger_px: trigger_price_decimal,
496                        tpsl,
497                    },
498                }
499            } else {
500                anyhow::bail!("Stop limit orders require a trigger price")
501            }
502        }
503        OrderType::MarketIfTouched => {
504            if let Some(trigger_price) = order.trigger_price() {
505                let raw = trigger_price.as_decimal();
506                let trigger_price_decimal = if should_normalize_prices {
507                    normalize_price(raw, price_decimals).normalize()
508                } else {
509                    raw.normalize()
510                };
511                HyperliquidExecOrderKind::Trigger {
512                    trigger: HyperliquidExecTriggerParams {
513                        is_market: true,
514                        trigger_px: trigger_price_decimal,
515                        tpsl: HyperliquidExecTpSl::Tp,
516                    },
517                }
518            } else {
519                anyhow::bail!("Market-if-touched orders require a trigger price")
520            }
521        }
522        OrderType::LimitIfTouched => {
523            if let Some(trigger_price) = order.trigger_price() {
524                let raw = trigger_price.as_decimal();
525                let trigger_price_decimal = if should_normalize_prices {
526                    normalize_price(raw, price_decimals).normalize()
527                } else {
528                    raw.normalize()
529                };
530                HyperliquidExecOrderKind::Trigger {
531                    trigger: HyperliquidExecTriggerParams {
532                        is_market: false,
533                        trigger_px: trigger_price_decimal,
534                        tpsl: HyperliquidExecTpSl::Tp,
535                    },
536                }
537            } else {
538                anyhow::bail!("Limit-if-touched orders require a trigger price")
539            }
540        }
541        _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
542    };
543
544    let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
545
546    Ok(HyperliquidExecPlaceOrderRequest {
547        asset,
548        is_buy,
549        price: price_decimal,
550        size: size_decimal,
551        reduce_only,
552        kind,
553        cloid,
554    })
555}
556
557/// Default slippage buffer in basis points for MARKET orders.
558pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
559
560/// Derives a market order limit price from a quote with a configurable
561/// slippage buffer in basis points, rounded to 5 significant figures and
562/// clamped to the instrument's price precision.
563pub fn derive_market_order_price(
564    quote: &QuoteTick,
565    is_buy: bool,
566    price_decimals: u8,
567    slippage_bps: u32,
568) -> Decimal {
569    let base = if is_buy {
570        quote.ask_price.as_decimal()
571    } else {
572        quote.bid_price.as_decimal()
573    };
574    let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
575    let sig_rounded = round_to_sig_figs(derived, 5);
576    clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
577}
578
579/// Derives a limit price from a trigger price with a configurable
580/// slippage buffer in basis points, widening the limit so BUY satisfies
581/// `limit_px >= trigger_px` and SELL satisfies `limit_px <= trigger_px`.
582pub fn derive_limit_from_trigger(
583    trigger_price: Decimal,
584    is_buy: bool,
585    slippage_bps: u32,
586) -> Decimal {
587    // bps -> Decimal: e.g. 50 bps -> 0.005
588    let slippage = Decimal::new(slippage_bps as i64, 4);
589    let price = if is_buy {
590        trigger_price * (Decimal::ONE + slippage)
591    } else {
592        trigger_price * (Decimal::ONE - slippage)
593    };
594
595    // Strip trailing zeros for EIP-712 signing hash verification
596    price.normalize()
597}
598
599/// Clamp a price to the instrument's decimal precision,
600/// rounding in the direction that preserves the slippage buffer.
601pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
602    let scale = Decimal::from(10_u64.pow(decimals as u32));
603
604    if is_buy {
605        (price * scale).ceil() / scale
606    } else {
607        (price * scale).floor() / scale
608    }
609}
610
611/// Converts a client order ID to a Hyperliquid cancel request using a pre-resolved asset index.
612pub fn client_order_id_to_cancel_request_with_asset(
613    client_order_id: &str,
614    asset: u32,
615) -> HyperliquidExecCancelByCloidRequest {
616    let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
617    HyperliquidExecCancelByCloidRequest { asset, cloid }
618}
619
620/// Extracts per-item error from a successful Hyperliquid exchange response.
621///
622/// When the top-level status is "ok", individual items in the `statuses`
623/// array may still contain errors. Returns the first error found, or
624/// `None` if all items succeeded or the response cannot be parsed.
625pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
626    let HyperliquidExchangeResponse::Status { response, .. } = response else {
627        return None;
628    };
629    let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
630    match data {
631        HyperliquidExecResponseData::Order { data } => {
632            for status in &data.statuses {
633                if let HyperliquidExecOrderStatus::Error { error } = status {
634                    return Some(error.clone());
635                }
636            }
637            None
638        }
639        HyperliquidExecResponseData::Cancel { data } => {
640            for status in &data.statuses {
641                if let HyperliquidExecCancelStatus::Error { error } = status {
642                    return Some(error.clone());
643                }
644            }
645            None
646        }
647        HyperliquidExecResponseData::Modify { data } => {
648            for status in &data.statuses {
649                if let HyperliquidExecModifyStatus::Error { error } = status {
650                    return Some(error.clone());
651                }
652            }
653            None
654        }
655        _ => None,
656    }
657}
658
659/// Extracts per-item errors from a successful batch response.
660///
661/// Returns a `Vec` with one `Option<String>` per item in the `statuses`
662/// array: `Some(error)` for failed items, `None` for successful ones.
663/// Returns an empty vec if the response cannot be parsed.
664pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
665    let HyperliquidExchangeResponse::Status { response, .. } = response else {
666        return Vec::new();
667    };
668    let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
669        return Vec::new();
670    };
671
672    match data {
673        HyperliquidExecResponseData::Order { data } => data
674            .statuses
675            .into_iter()
676            .map(|s| match s {
677                HyperliquidExecOrderStatus::Error { error } => Some(error),
678                _ => None,
679            })
680            .collect(),
681        HyperliquidExecResponseData::Cancel { data } => data
682            .statuses
683            .into_iter()
684            .map(|s| match s {
685                HyperliquidExecCancelStatus::Error { error } => Some(error),
686                HyperliquidExecCancelStatus::Success(_) => None,
687            })
688            .collect(),
689        HyperliquidExecResponseData::Modify { data } => data
690            .statuses
691            .into_iter()
692            .map(|s| match s {
693                HyperliquidExecModifyStatus::Error { error } => Some(error),
694                HyperliquidExecModifyStatus::Success(_) => None,
695            })
696            .collect(),
697        _ => Vec::new(),
698    }
699}
700
701/// Extracts error message from a Hyperliquid exchange response.
702pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
703    match response {
704        HyperliquidExchangeResponse::Status { status, response } => {
705            if status == RESPONSE_STATUS_OK {
706                "Operation successful".to_string()
707            } else {
708                // Try to extract error message from response data
709                if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
710                    error_msg.to_string()
711                } else {
712                    format!("Request failed with status: {status}")
713                }
714            }
715        }
716        HyperliquidExchangeResponse::Error { error } => error.clone(),
717    }
718}
719
720/// Determines if an order is a conditional/trigger order based on order data.
721///
722/// # Returns
723///
724/// `true` if the order is a conditional order, `false` otherwise.
725pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
726    trigger_px.is_some() && tpsl.is_some()
727}
728
729/// Parses trigger order type from Hyperliquid order data.
730///
731/// # Returns
732///
733/// The corresponding Nautilus `OrderType`.
734pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
735    match (is_market, tpsl) {
736        (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
737        (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
738        (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
739        (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
740    }
741}
742
743/// Extracts order status from WebSocket order data.
744///
745/// # Returns
746///
747/// A tuple of (OrderStatus, optional trigger status string).
748pub fn parse_order_status_with_trigger(
749    status: HyperliquidOrderStatus,
750    trigger_activated: Option<bool>,
751) -> (OrderStatus, Option<String>) {
752    let base_status = OrderStatus::from(status);
753
754    // For conditional orders, add trigger status information
755    if let Some(activated) = trigger_activated {
756        let trigger_status = if activated {
757            Some("activated".to_string())
758        } else {
759            Some("pending".to_string())
760        };
761        (base_status, trigger_status)
762    } else {
763        (base_status, None)
764    }
765}
766
767/// Converts WebSocket trailing stop data to description string.
768pub fn format_trailing_stop_info(
769    offset: &str,
770    offset_type: TrailingOffsetType,
771    callback_price: Option<&str>,
772) -> String {
773    let offset_desc = offset_type.format_offset(offset);
774
775    if let Some(callback) = callback_price {
776        format!("Trailing stop: {offset_desc} offset, callback at {callback}")
777    } else {
778        format!("Trailing stop: {offset_desc} offset")
779    }
780}
781
782/// Validates conditional order parameters from WebSocket data.
783///
784/// # Returns
785///
786/// `Ok(())` if parameters are valid, `Err` with description otherwise.
787pub fn validate_conditional_order_params(
788    trigger_px: Option<&str>,
789    tpsl: Option<&HyperliquidTpSl>,
790    is_market: Option<bool>,
791) -> anyhow::Result<()> {
792    if trigger_px.is_none() {
793        anyhow::bail!("Conditional order missing trigger price");
794    }
795
796    if tpsl.is_none() {
797        anyhow::bail!("Conditional order missing tpsl indicator");
798    }
799
800    // No need to validate tpsl value - the enum type guarantees it's either Tp or Sl
801
802    if is_market.is_none() {
803        anyhow::bail!("Conditional order missing is_market flag");
804    }
805
806    Ok(())
807}
808
809/// Parses trigger price from string to Decimal.
810///
811/// # Returns
812///
813/// Parsed Decimal value or error.
814pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
815    Decimal::from_str_exact(trigger_px)
816        .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
817}
818
819/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
820///
821/// Uses the same field selection as the HTTP account-state path
822/// (`cross_margin_summary.total_raw_usd` for total, top-level `state.withdrawable`
823/// for free) so the execution adapter and the HTTP client emit consistent balances
824/// for the same clearinghouse snapshot.
825///
826/// # Errors
827///
828/// Returns an error if the data cannot be parsed.
829pub fn parse_account_balances_and_margins(
830    state: &ClearinghouseState,
831) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
832    let mut balances = Vec::new();
833    let mut margins = Vec::new();
834
835    let currency = Currency::USDC();
836
837    let cross_margin_summary = match &state.cross_margin_summary {
838        Some(summary) => summary,
839        None => return Ok((balances, margins)),
840    };
841
842    let mut total_value = cross_margin_summary.total_raw_usd.max(Decimal::ZERO);
843    let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
844
845    // Withdrawable may include spot balances that sit outside the margin account value;
846    // raise total so those funds are not silently clamped away. Mirrors the HTTP parser.
847    if free_value > total_value {
848        total_value = free_value;
849    }
850
851    balances.push(AccountBalance::from_total_and_free(
852        total_value,
853        free_value,
854        currency,
855    )?);
856
857    let margin_used = cross_margin_summary.total_margin_used;
858
859    if margin_used > Decimal::ZERO {
860        // Hyperliquid perps use a single-collateral (USDC) cross-margin model, so the
861        // reserved margin is emitted as an account-wide entry keyed by USDC.
862        let initial_margin = Money::from_decimal(margin_used, currency)?;
863        let maintenance_margin = Money::from_decimal(margin_used, currency)?;
864        margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
865    }
866
867    Ok((balances, margins))
868}
869
870/// Merges perp clearinghouse balances with spot balances into a unified set.
871///
872/// The perp parser already reflects combined USDC (its `withdrawable` may include
873/// spot buckets). To avoid double-counting, this helper appends only non-USDC
874/// spot tokens onto the perp-derived balances. If the perp state has no margin
875/// summary, the full spot balance set is used verbatim.
876///
877/// # Errors
878///
879/// Returns an error if any balance conversion fails.
880pub fn parse_combined_account_balances_and_margins(
881    perp_state: &ClearinghouseState,
882    spot_state: &SpotClearinghouseState,
883) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
884    let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
885
886    let has_perp_summary = perp_state.cross_margin_summary.is_some();
887    let spot_balances = parse_spot_account_balances(spot_state)?;
888
889    for balance in spot_balances {
890        let is_usdc = balance.currency.code.as_str() == "USDC";
891        if has_perp_summary && is_usdc {
892            continue;
893        }
894        balances.push(balance);
895    }
896
897    Ok((balances, margins))
898}
899
900/// Parses Hyperliquid spot clearinghouse state into Nautilus account balances.
901///
902/// Emits one [`AccountBalance`] per non-zero spot token, deriving free from
903/// `total - hold`. Tokens unknown to the global currency registry are registered
904/// on the fly with 8-decimal precision (matches Hyperliquid's `sz_decimals` cap).
905///
906/// # Errors
907///
908/// Returns an error if any balance cannot be converted to a Nautilus `Money`.
909pub fn parse_spot_account_balances(
910    state: &SpotClearinghouseState,
911) -> anyhow::Result<Vec<AccountBalance>> {
912    let mut balances = Vec::with_capacity(state.balances.len());
913
914    for balance in &state.balances {
915        if balance.total.is_zero() {
916            continue;
917        }
918
919        let currency = crate::http::parse::get_currency(balance.coin.as_str());
920
921        // Let `from_total_and_locked` do the clamping and derivation at currency
922        // precision so the `total == locked + free` invariant holds without
923        // bespoke rounding here.
924        balances.push(AccountBalance::from_total_and_locked(
925            balance.total,
926            balance.hold,
927            currency,
928        )?);
929    }
930
931    Ok(balances)
932}
933
934/// Determine the Hyperliquid grouping strategy for an order list.
935///
936/// Contingency type, reduce-only flags, structural shape, and parent/child
937/// linkage must all agree to avoid misclassifying generic contingent lists
938/// as Hyperliquid TP/SL groups.
939///
940/// - `NormalTpsl` (OTOCO bracket): entry order is OTO and not reduce-only,
941///   all child orders are OCO, reduce-only, and reference the entry as parent.
942/// - `PositionTpsl` (OCO pair): every order is OCO, reduce-only, and linked
943///   to the same sibling set.
944/// - `Na`: everything else (independent batch).
945pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExecGrouping {
946    if orders.len() >= 2 {
947        let entry = &orders[0];
948        let children = &orders[1..];
949        let entry_id = entry.client_order_id();
950        let entry_is_oto =
951            entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
952        let children_are_linked = children.iter().all(|o| {
953            o.contingency_type() == Some(ContingencyType::Oco)
954                && o.is_reduce_only()
955                && o.parent_order_id() == Some(entry_id)
956        });
957
958        if entry_is_oto && children_are_linked {
959            return HyperliquidExecGrouping::NormalTpsl;
960        }
961    }
962
963    let all_oco_linked = orders.len() >= 2
964        && orders
965            .iter()
966            .all(|o| o.contingency_type() == Some(ContingencyType::Oco) && o.is_reduce_only())
967        && orders.iter().all(|o| {
968            o.linked_order_ids().is_some_and(|ids| {
969                ids.iter()
970                    .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
971            })
972        });
973
974    if all_oco_linked {
975        HyperliquidExecGrouping::PositionTpsl
976    } else {
977        HyperliquidExecGrouping::Na
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use std::str::FromStr;
984
985    use nautilus_model::{
986        enums::{OrderSide, TimeInForce, TriggerType},
987        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
988        orders::{OrderAny, StopMarketOrder},
989        types::{Price, Quantity},
990    };
991    use rstest::rstest;
992    use rust_decimal::Decimal;
993    use rust_decimal_macros::dec;
994    use serde::{Deserialize, Serialize};
995
996    use super::*;
997
998    #[derive(Serialize, Deserialize)]
999    struct TestStruct {
1000        #[serde(
1001            serialize_with = "serialize_decimal_as_str",
1002            deserialize_with = "deserialize_decimal_from_str"
1003        )]
1004        value: Decimal,
1005        #[serde(
1006            serialize_with = "serialize_optional_decimal_as_str",
1007            deserialize_with = "deserialize_optional_decimal_from_str"
1008        )]
1009        optional_value: Option<Decimal>,
1010    }
1011
1012    #[rstest]
1013    #[case("#10", 100_000_010, 1, 0)]
1014    #[case("+10", 100_000_010, 1, 0)]
1015    #[case("#31", 100_000_031, 3, 1)]
1016    #[case("+31", 100_000_031, 3, 1)]
1017    fn test_parse_outcome_symbol(
1018        #[case] symbol: &str,
1019        #[case] raw_asset_id: u32,
1020        #[case] outcome: u32,
1021        #[case] side: u8,
1022    ) {
1023        let asset_id = parse_outcome_symbol(symbol).unwrap();
1024        assert_eq!(asset_id.to_raw(), raw_asset_id);
1025        assert_eq!(asset_id.outcome_index(), Some(outcome));
1026        assert_eq!(asset_id.outcome_side(), Some(side));
1027    }
1028
1029    #[rstest]
1030    #[case("10", "expected #<encoding> or +<encoding>")]
1031    #[case("#", "encoding must not be empty")]
1032    #[case("#1a", "encoding must be numeric")]
1033    #[case("#12", "side digit 0 or 1")]
1034    #[case("#4294967295", "fit u32")]
1035    fn test_parse_outcome_symbol_rejects_invalid_values(
1036        #[case] symbol: &str,
1037        #[case] expected_error: &str,
1038    ) {
1039        let err = parse_outcome_symbol(symbol).unwrap_err();
1040        assert!(
1041            err.to_string().contains(expected_error),
1042            "expected error to contain '{expected_error}', received '{err}'",
1043        );
1044    }
1045
1046    #[rstest]
1047    fn test_decimal_serialization_roundtrip() {
1048        let original = TestStruct {
1049            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1050            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1051        };
1052
1053        let json = serde_json::to_string(&original).unwrap();
1054        println!("Serialized: {json}");
1055
1056        // Check that it's serialized as strings (rust_decimal may normalize precision)
1057        assert!(json.contains("\"123.45678901234567890123456789\""));
1058        assert!(json.contains("\"0.000000001\""));
1059
1060        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1061        assert_eq!(original.value, deserialized.value);
1062        assert_eq!(original.optional_value, deserialized.optional_value);
1063    }
1064
1065    #[rstest]
1066    fn test_decimal_precision_preservation() {
1067        let test_cases = [
1068            "0",
1069            "1",
1070            "0.1",
1071            "0.01",
1072            "0.001",
1073            "123.456789012345678901234567890",
1074            "999999999999999999.999999999999999999",
1075        ];
1076
1077        for case in test_cases {
1078            let decimal = Decimal::from_str(case).unwrap();
1079            let test_struct = TestStruct {
1080                value: decimal,
1081                optional_value: Some(decimal),
1082            };
1083
1084            let json = serde_json::to_string(&test_struct).unwrap();
1085            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1086
1087            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1088            assert_eq!(
1089                Some(decimal),
1090                parsed.optional_value,
1091                "Failed for case: {case}"
1092            );
1093        }
1094    }
1095
1096    #[rstest]
1097    fn test_optional_none_handling() {
1098        let test_struct = TestStruct {
1099            value: Decimal::from_str("42.0").unwrap(),
1100            optional_value: None,
1101        };
1102
1103        let json = serde_json::to_string(&test_struct).unwrap();
1104        assert!(json.contains("null"));
1105
1106        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1107        assert_eq!(test_struct.value, parsed.value);
1108        assert_eq!(None, parsed.optional_value);
1109    }
1110
1111    #[rstest]
1112    fn test_round_down_to_tick() {
1113        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1114        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1115        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1116
1117        // Edge case: zero tick size
1118        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1119    }
1120
1121    #[rstest]
1122    fn test_round_down_to_step() {
1123        assert_eq!(
1124            round_down_to_step(dec!(0.12349), dec!(0.0001)),
1125            dec!(0.1234)
1126        );
1127        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1128        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1129
1130        // Edge case: zero step size
1131        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1132    }
1133
1134    #[rstest]
1135    fn test_min_notional_validation() {
1136        // Should pass
1137        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1138        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1139
1140        // Should fail
1141        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1142        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1143
1144        // Edge case: exactly at minimum
1145        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1146    }
1147
1148    #[rstest]
1149    fn test_round_to_sig_figs() {
1150        // BTC price ~$104,567 needs to round to 5 sig figs
1151        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1152        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1153        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1154
1155        // Smaller prices should keep decimals
1156        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1157        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1158        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1159
1160        // Sub-1 values with leading zeros must preserve 5 sig figs
1161        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1162        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
1163
1164        // Zero case
1165        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1166    }
1167
1168    #[rstest]
1169    fn test_normalize_price() {
1170        // Now includes 5 sig fig rounding first
1171        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1172        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
1173        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
1174        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
1175
1176        // BTC-like prices get rounded to 5 sig figs
1177        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
1178    }
1179
1180    #[rstest]
1181    fn test_normalize_quantity() {
1182        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1183        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1184        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1185        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1186    }
1187
1188    #[rstest]
1189    fn test_normalize_order_complete() {
1190        let result = normalize_order(
1191            dec!(100.12345), // price
1192            dec!(0.123456),  // qty
1193            dec!(0.01),      // tick_size
1194            dec!(0.0001),    // step_size
1195            dec!(10),        // min_notional
1196            2,               // price_decimals
1197            4,               // size_decimals
1198        );
1199
1200        assert!(result.is_ok());
1201        let (price, qty) = result.unwrap();
1202        assert_eq!(price, dec!(100.12)); // normalized and rounded down
1203        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
1204    }
1205
1206    #[rstest]
1207    fn test_normalize_order_min_notional_fail() {
1208        let result = normalize_order(
1209            dec!(100.12345), // price
1210            dec!(0.05),      // qty (too small for min notional)
1211            dec!(0.01),      // tick_size
1212            dec!(0.0001),    // step_size
1213            dec!(10),        // min_notional
1214            2,               // price_decimals
1215            4,               // size_decimals
1216        );
1217
1218        assert!(result.is_err());
1219        assert!(result.unwrap_err().contains("Notional value"));
1220    }
1221
1222    #[rstest]
1223    fn test_edge_cases() {
1224        // Test with very small numbers
1225        assert_eq!(
1226            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1227            dec!(0.000001)
1228        );
1229
1230        // Test with large numbers
1231        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1232
1233        // Test rounding edge case
1234        assert_eq!(
1235            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1236            dec!(100.00)
1237        );
1238    }
1239
1240    #[rstest]
1241    fn test_is_conditional_order_data() {
1242        // Test with trigger price and tpsl (conditional)
1243        assert!(is_conditional_order_data(
1244            Some("50000.0"),
1245            Some(&HyperliquidTpSl::Sl)
1246        ));
1247
1248        // Test with only trigger price (not conditional - needs both)
1249        assert!(!is_conditional_order_data(Some("50000.0"), None));
1250
1251        // Test with only tpsl (not conditional - needs both)
1252        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1253
1254        // Test with no conditional fields
1255        assert!(!is_conditional_order_data(None, None));
1256    }
1257
1258    #[rstest]
1259    fn test_parse_trigger_order_type() {
1260        // Stop Market
1261        assert_eq!(
1262            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1263            OrderType::StopMarket
1264        );
1265
1266        // Stop Limit
1267        assert_eq!(
1268            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1269            OrderType::StopLimit
1270        );
1271
1272        // Take Profit Market
1273        assert_eq!(
1274            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1275            OrderType::MarketIfTouched
1276        );
1277
1278        // Take Profit Limit
1279        assert_eq!(
1280            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1281            OrderType::LimitIfTouched
1282        );
1283    }
1284
1285    #[rstest]
1286    fn test_parse_order_status_with_trigger() {
1287        // Test with open status and activated trigger
1288        let (status, trigger_status) =
1289            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1290        assert_eq!(status, OrderStatus::Accepted);
1291        assert_eq!(trigger_status, Some("activated".to_string()));
1292
1293        // Test with open status and not activated
1294        let (status, trigger_status) =
1295            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1296        assert_eq!(status, OrderStatus::Accepted);
1297        assert_eq!(trigger_status, Some("pending".to_string()));
1298
1299        // Test without trigger info
1300        let (status, trigger_status) =
1301            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1302        assert_eq!(status, OrderStatus::Accepted);
1303        assert_eq!(trigger_status, None);
1304    }
1305
1306    #[rstest]
1307    fn test_format_trailing_stop_info() {
1308        // Price offset
1309        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1310        assert!(info.contains("100.0"));
1311        assert!(info.contains("callback at 50000.0"));
1312
1313        // Percentage offset
1314        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1315        assert!(info.contains("5.0%"));
1316        assert!(info.contains("Trailing stop"));
1317
1318        // Basis points offset
1319        let info =
1320            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1321        assert!(info.contains("250 bps"));
1322        assert!(info.contains("49000.0"));
1323    }
1324
1325    #[rstest]
1326    fn test_parse_trigger_price() {
1327        // Valid price
1328        let result = parse_trigger_price("50000.0");
1329        assert!(result.is_ok());
1330        assert_eq!(result.unwrap(), dec!(50000.0));
1331
1332        // Valid integer price
1333        let result = parse_trigger_price("49000");
1334        assert!(result.is_ok());
1335        assert_eq!(result.unwrap(), dec!(49000));
1336
1337        // Invalid price
1338        let result = parse_trigger_price("invalid");
1339        assert!(result.is_err());
1340
1341        // Empty string
1342        let result = parse_trigger_price("");
1343        assert!(result.is_err());
1344    }
1345
1346    #[rstest]
1347    #[case(dec!(0), true, dec!(0))] // Zero
1348    #[case(dec!(0), false, dec!(0))] // Zero
1349    #[case(dec!(0.001), true, dec!(0.001005))] // Small price BUY
1350    #[case(dec!(0.001), false, dec!(0.000995))] // Small price SELL
1351    #[case(dec!(100), true, dec!(100.5))] // Round price BUY
1352    #[case(dec!(100), false, dec!(99.5))] // Round price SELL
1353    #[case(dec!(2470), true, dec!(2482.35))] // ETH-like BUY
1354    #[case(dec!(2470), false, dec!(2457.65))] // ETH-like SELL
1355    #[case(dec!(104567.3), true, dec!(105090.1365))] // BTC-like BUY
1356    #[case(dec!(104567.3), false, dec!(104044.4635))] // BTC-like SELL
1357    fn test_derive_limit_from_trigger(
1358        #[case] trigger_price: Decimal,
1359        #[case] is_buy: bool,
1360        #[case] expected: Decimal,
1361    ) {
1362        let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1363        assert_eq!(result, expected);
1364
1365        // Verify invariant: BUY limit >= trigger, SELL limit <= trigger
1366        if is_buy {
1367            assert!(result >= trigger_price);
1368        } else {
1369            assert!(result <= trigger_price);
1370        }
1371    }
1372
1373    #[rstest]
1374    // BUY rounds up (ceil)
1375    #[case(dec!(2457.65), 2, true, dec!(2457.65))] // Already at precision
1376    #[case(dec!(2457.65), 1, true, dec!(2457.7))] // Ceil to 1dp
1377    #[case(dec!(2457.65), 0, true, dec!(2458))] // Ceil to integer
1378    // SELL rounds down (floor)
1379    #[case(dec!(2457.65), 2, false, dec!(2457.65))] // Already at precision
1380    #[case(dec!(2457.65), 1, false, dec!(2457.6))] // Floor to 1dp
1381    #[case(dec!(2457.65), 0, false, dec!(2457))] // Floor to integer
1382    // High precision (no-op)
1383    #[case(dec!(0.4975), 4, true, dec!(0.4975))]
1384    #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1385    // Precision forces clamping on small values
1386    #[case(dec!(0.4975), 2, true, dec!(0.50))]
1387    #[case(dec!(0.4975), 2, false, dec!(0.49))]
1388    fn test_clamp_price_to_precision(
1389        #[case] price: Decimal,
1390        #[case] decimals: u8,
1391        #[case] is_buy: bool,
1392        #[case] expected: Decimal,
1393    ) {
1394        assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1395    }
1396
1397    fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1398        OrderAny::StopMarket(StopMarketOrder::new(
1399            TraderId::from("TESTER-001"),
1400            StrategyId::from("S-001"),
1401            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1402            ClientOrderId::from("O-001"),
1403            side,
1404            Quantity::from(1),
1405            Price::from(trigger_price),
1406            TriggerType::LastPrice,
1407            TimeInForce::Gtc,
1408            None,
1409            false,
1410            false,
1411            None,
1412            None,
1413            None,
1414            None,
1415            None,
1416            None,
1417            None,
1418            None,
1419            None,
1420            None,
1421            None,
1422            Default::default(),
1423            Default::default(),
1424        ))
1425    }
1426
1427    #[rstest]
1428    // ETH-like (precision=2): clamping is a no-op
1429    #[case(OrderSide::Sell, "2470.00", 2)]
1430    #[case(OrderSide::Buy, "2470.00", 2)]
1431    // BTC-like (precision=1): clamping is a no-op
1432    #[case(OrderSide::Sell, "104567.3", 1)]
1433    #[case(OrderSide::Buy, "104567.3", 1)]
1434    // Low-price token (precision=4): clamping is a no-op
1435    #[case(OrderSide::Sell, "0.50", 4)]
1436    #[case(OrderSide::Buy, "0.50", 4)]
1437    // Clamping materially changes: ETH trigger at precision=1
1438    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(1dp) = 2457.6
1439    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(1dp) = 2482.4
1440    #[case(OrderSide::Sell, "2470.00", 1)]
1441    #[case(OrderSide::Buy, "2470.00", 1)]
1442    // Clamping materially changes: precision=0 forces integer
1443    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(0dp) = 2457
1444    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(0dp) = 2483
1445    #[case(OrderSide::Sell, "2470.00", 0)]
1446    #[case(OrderSide::Buy, "2470.00", 0)]
1447    fn test_order_to_request_stop_market_derives_limit_from_trigger(
1448        #[case] side: OrderSide,
1449        #[case] trigger_str: &str,
1450        #[case] price_decimals: u8,
1451    ) {
1452        let order = stop_market_order(side, trigger_str);
1453        let request = order_to_hyperliquid_request_with_asset(
1454            &order,
1455            0,
1456            price_decimals,
1457            true,
1458            DEFAULT_MARKET_SLIPPAGE_BPS,
1459        )
1460        .unwrap();
1461        let trigger = Decimal::from_str(trigger_str).unwrap();
1462        let is_buy = matches!(side, OrderSide::Buy);
1463
1464        // Price must satisfy Hyperliquid's directional constraint
1465        if is_buy {
1466            assert!(
1467                request.price >= trigger,
1468                "BUY limit {} must be >= trigger {trigger}",
1469                request.price,
1470            );
1471            assert!(request.is_buy);
1472        } else {
1473            assert!(
1474                request.price <= trigger,
1475                "SELL limit {} must be <= trigger {trigger}",
1476                request.price,
1477            );
1478            assert!(!request.is_buy);
1479        }
1480
1481        // Price must equal the full pipeline: derive -> sig figs -> clamp -> normalize
1482        let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1483        let sig_rounded = round_to_sig_figs(derived, 5);
1484        let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1485        assert_eq!(request.price, expected);
1486
1487        // Decimal places must not exceed instrument precision
1488        let price_str = request.price.to_string();
1489        let actual_decimals = price_str
1490            .find('.')
1491            .map_or(0, |dot| price_str.len() - dot - 1);
1492        assert!(
1493            actual_decimals <= price_decimals as usize,
1494            "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1495        );
1496
1497        // Decimal trailing zeros must be stripped (canonical form)
1498        if price_str.contains('.') {
1499            assert!(
1500                !price_str.ends_with('0'),
1501                "Price {price_str} has decimal trailing zeros",
1502            );
1503        }
1504
1505        let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1506        assert_eq!(
1507            request.kind,
1508            HyperliquidExecOrderKind::Trigger {
1509                trigger: HyperliquidExecTriggerParams {
1510                    is_market: true,
1511                    trigger_px: expected_trigger,
1512                    tpsl: HyperliquidExecTpSl::Sl,
1513                },
1514            },
1515        );
1516    }
1517
1518    fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1519        HyperliquidExchangeResponse::Status {
1520            status: "ok".to_string(),
1521            response: inner,
1522        }
1523    }
1524
1525    #[rstest]
1526    fn test_extract_inner_error_order_with_error() {
1527        let response = ok_response(serde_json::json!({
1528            "type": "order",
1529            "data": {"statuses": [{"error": "Order has invalid price."}]}
1530        }));
1531        assert_eq!(
1532            extract_inner_error(&response),
1533            Some("Order has invalid price.".to_string()),
1534        );
1535    }
1536
1537    #[rstest]
1538    fn test_extract_inner_error_order_resting() {
1539        let response = ok_response(serde_json::json!({
1540            "type": "order",
1541            "data": {"statuses": [{"resting": {"oid": 12345}}]}
1542        }));
1543        assert_eq!(extract_inner_error(&response), None);
1544    }
1545
1546    #[rstest]
1547    fn test_extract_inner_error_order_filled() {
1548        let response = ok_response(serde_json::json!({
1549            "type": "order",
1550            "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1551        }));
1552        assert_eq!(extract_inner_error(&response), None);
1553    }
1554
1555    #[rstest]
1556    fn test_extract_inner_error_cancel_error() {
1557        let response = ok_response(serde_json::json!({
1558            "type": "cancel",
1559            "data": {"statuses": [{"error": "Order not found"}]}
1560        }));
1561        assert_eq!(
1562            extract_inner_error(&response),
1563            Some("Order not found".to_string()),
1564        );
1565    }
1566
1567    #[rstest]
1568    fn test_extract_inner_error_cancel_success() {
1569        let response = ok_response(serde_json::json!({
1570            "type": "cancel",
1571            "data": {"statuses": ["success"]}
1572        }));
1573        assert_eq!(extract_inner_error(&response), None);
1574    }
1575
1576    #[rstest]
1577    fn test_extract_inner_error_modify_error() {
1578        let response = ok_response(serde_json::json!({
1579            "type": "modify",
1580            "data": {"statuses": [{"error": "Invalid modify"}]}
1581        }));
1582        assert_eq!(
1583            extract_inner_error(&response),
1584            Some("Invalid modify".to_string()),
1585        );
1586    }
1587
1588    #[rstest]
1589    fn test_extract_inner_error_modify_success() {
1590        let response = ok_response(serde_json::json!({
1591            "type": "modify",
1592            "data": {"statuses": ["success"]}
1593        }));
1594        assert_eq!(extract_inner_error(&response), None);
1595    }
1596
1597    #[rstest]
1598    fn test_extract_inner_error_non_status_response() {
1599        let response = HyperliquidExchangeResponse::Error {
1600            error: "top-level error".to_string(),
1601        };
1602        assert_eq!(extract_inner_error(&response), None);
1603    }
1604
1605    #[rstest]
1606    fn test_extract_inner_error_unparsable_response() {
1607        let response = ok_response(serde_json::json!({"unknown": "data"}));
1608        assert_eq!(extract_inner_error(&response), None);
1609    }
1610
1611    #[rstest]
1612    fn test_extract_inner_error_returns_first_error_in_batch() {
1613        let response = ok_response(serde_json::json!({
1614            "type": "order",
1615            "data": {"statuses": [
1616                {"resting": {"oid": 1}},
1617                {"error": "Second failed"},
1618                {"error": "Third failed"},
1619            ]}
1620        }));
1621        assert_eq!(
1622            extract_inner_error(&response),
1623            Some("Second failed".to_string()),
1624        );
1625    }
1626
1627    #[rstest]
1628    fn test_extract_inner_errors_mixed_batch() {
1629        let response = ok_response(serde_json::json!({
1630            "type": "order",
1631            "data": {"statuses": [
1632                {"resting": {"oid": 1}},
1633                {"error": "Failed order"},
1634                {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1635            ]}
1636        }));
1637        let errors = extract_inner_errors(&response);
1638        assert_eq!(errors.len(), 3);
1639        assert_eq!(errors[0], None);
1640        assert_eq!(errors[1], Some("Failed order".to_string()));
1641        assert_eq!(errors[2], None);
1642    }
1643
1644    #[rstest]
1645    fn test_extract_inner_errors_all_success() {
1646        let response = ok_response(serde_json::json!({
1647            "type": "order",
1648            "data": {"statuses": [
1649                {"resting": {"oid": 1}},
1650                {"resting": {"oid": 2}},
1651            ]}
1652        }));
1653        let errors = extract_inner_errors(&response);
1654        assert_eq!(errors.len(), 2);
1655        assert!(errors.iter().all(|e| e.is_none()));
1656    }
1657
1658    #[rstest]
1659    fn test_extract_inner_errors_cancel_success() {
1660        let response = ok_response(serde_json::json!({
1661            "type": "cancel",
1662            "data": {"statuses": ["success"]}
1663        }));
1664        let errors = extract_inner_errors(&response);
1665        assert_eq!(errors.len(), 1);
1666        assert!(errors[0].is_none());
1667    }
1668
1669    #[rstest]
1670    fn test_extract_inner_errors_cancel_mixed() {
1671        let response = ok_response(serde_json::json!({
1672            "type": "cancel",
1673            "data": {"statuses": [
1674                "success",
1675                {"error": "Order was never placed, already canceled, or filled."},
1676                "success",
1677            ]}
1678        }));
1679        let errors = extract_inner_errors(&response);
1680        assert_eq!(errors.len(), 3);
1681        assert_eq!(errors[0], None);
1682        assert_eq!(
1683            errors[1],
1684            Some("Order was never placed, already canceled, or filled.".to_string())
1685        );
1686        assert_eq!(errors[2], None);
1687    }
1688
1689    #[rstest]
1690    fn test_extract_inner_errors_modify_mixed() {
1691        let response = ok_response(serde_json::json!({
1692            "type": "modify",
1693            "data": {"statuses": [
1694                "success",
1695                {"error": "Order does not exist"},
1696            ]}
1697        }));
1698        let errors = extract_inner_errors(&response);
1699        assert_eq!(errors.len(), 2);
1700        assert_eq!(errors[0], None);
1701        assert_eq!(errors[1], Some("Order does not exist".to_string()));
1702    }
1703
1704    #[rstest]
1705    fn test_extract_inner_errors_unparsable() {
1706        let response = ok_response(serde_json::json!({"foo": "bar"}));
1707        let errors = extract_inner_errors(&response);
1708        assert!(errors.is_empty());
1709    }
1710
1711    fn count_sig_figs(s: &str) -> usize {
1712        let s = s.trim_start_matches('-');
1713        if s.contains('.') {
1714            // Decimal: all digits excluding leading zeros are significant
1715            let digits: String = s.replace('.', "");
1716            digits.trim_start_matches('0').len()
1717        } else {
1718            // Integer: trailing zeros are place-holders, not significant
1719            let s = s.trim_start_matches('0');
1720            s.trim_end_matches('0').len()
1721        }
1722    }
1723
1724    fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1725        QuoteTick::new(
1726            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1727            Price::from(bid),
1728            Price::from(ask),
1729            Quantity::from("1"),
1730            Quantity::from("1"),
1731            Default::default(),
1732            Default::default(),
1733        )
1734    }
1735
1736    #[rstest]
1737    // BUY uses ask, SELL uses bid
1738    // Pipeline: base → +/-0.5% slippage → round 5 sig figs → clamp → normalize
1739    //
1740    // ETH-like (precision=2)
1741    // BUY: ask=2470 → 2470*1.005=2482.35 → sig5=2482.4 → clamp(2,ceil)=2482.40 → 2482.4
1742    #[case("2460.00", "2470.00", true, 2, "2482.4")]
1743    // SELL: bid=2460 → 2460*0.995=2447.70 → sig5=2447.7 → clamp(2,floor)=2447.70 → 2447.7
1744    #[case("2460.00", "2470.00", false, 2, "2447.7")]
1745    //
1746    // BTC-like (precision=1)
1747    // BUY: ask=104567.3 → 104567.3*1.005=105090.1365 → sig5=105090 → clamp(1,ceil)=105090 → 105090
1748    #[case("104500.0", "104567.3", true, 1, "105090")]
1749    // SELL: bid=104500.0 → 104500*0.995=103977.5 → sig5=103980 → clamp(1,floor)=103980 → 103980
1750    #[case("104500.0", "104567.3", false, 1, "103980")]
1751    //
1752    // Low-price token (precision=4)
1753    // BUY: ask=0.5000 → 0.5*1.005=0.5025 → sig5=0.50250 → clamp(4,ceil)=0.5025 → 0.5025
1754    #[case("0.4900", "0.5000", true, 4, "0.5025")]
1755    // SELL: bid=0.49 → 0.49*0.995=0.48755 → sig5=0.48755 → clamp(4,floor)=0.4875 → 0.4875
1756    #[case("0.4900", "0.5000", false, 4, "0.4875")]
1757    //
1758    // High-price low-precision (precision=0)
1759    // BUY: ask=50000 → 50000*1.005=50250 → sig5=50250 → clamp(0,ceil)=50250 → 50250
1760    #[case("49900", "50000", true, 0, "50250")]
1761    // SELL: bid=49900 → 49900*0.995=49650.5 → sig5=49650 → clamp(0,floor)=49650 → 49650
1762    #[case("49900", "50000", false, 0, "49650")]
1763    //
1764    // Very small price (precision=6)
1765    // BUY: ask=0.001234 → 0.001234*1.005=0.0012402 → sig5=0.0012402 → clamp(6,ceil)=0.001241
1766    #[case("0.001200", "0.001234", true, 6, "0.001241")]
1767    // SELL: bid=0.0012 → 0.0012*0.995=0.001194 → sig5=0.001194 → clamp(6,floor)=0.001194
1768    #[case("0.001200", "0.001234", false, 6, "0.001194")]
1769    fn test_derive_market_order_price(
1770        #[case] bid: &str,
1771        #[case] ask: &str,
1772        #[case] is_buy: bool,
1773        #[case] price_decimals: u8,
1774        #[case] expected: &str,
1775    ) {
1776        let quote = make_quote(bid, ask);
1777        let result =
1778            derive_market_order_price(&quote, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1779        let expected_dec = Decimal::from_str(expected).unwrap();
1780        assert_eq!(result, expected_dec);
1781
1782        // Verify the result matches the full pipeline manually
1783        let base = if is_buy {
1784            quote.ask_price.as_decimal()
1785        } else {
1786            quote.bid_price.as_decimal()
1787        };
1788        let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1789        let sig_rounded = round_to_sig_figs(derived, 5);
1790        let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1791        assert_eq!(result, pipeline);
1792
1793        // Must not have trailing zeros after decimal point
1794        let s = result.to_string();
1795        if s.contains('.') {
1796            assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
1797        }
1798
1799        // Sig figs must not exceed 5
1800        let sig_count = count_sig_figs(&s);
1801        assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
1802
1803        // Decimal places must not exceed instrument precision
1804        let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
1805        assert!(
1806            actual_decimals <= price_decimals as usize,
1807            "Price {s} has {actual_decimals} decimals, max {price_decimals}",
1808        );
1809    }
1810
1811    #[rstest]
1812    #[case(50, dec!(1000), true, dec!(1005))] // default 0.5% BUY
1813    #[case(50, dec!(1000), false, dec!(995))] // default 0.5% SELL
1814    #[case(0, dec!(1000), true, dec!(1000))] // 0 bps: no adjustment
1815    #[case(100, dec!(1000), true, dec!(1010))] // 1% BUY
1816    #[case(100, dec!(1000), false, dec!(990))] // 1% SELL
1817    #[case(800, dec!(1000), true, dec!(1080))] // 8% (Hyperliquid SDK default) BUY
1818    #[case(800, dec!(1000), false, dec!(920))] // 8% SELL
1819    fn test_derive_limit_from_trigger_respects_bps(
1820        #[case] slippage_bps: u32,
1821        #[case] trigger: Decimal,
1822        #[case] is_buy: bool,
1823        #[case] expected: Decimal,
1824    ) {
1825        let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
1826        assert_eq!(result, expected);
1827    }
1828
1829    #[rstest]
1830    fn test_derive_market_order_price_respects_slippage_override() {
1831        let quote = make_quote("100.00", "100.10");
1832        let tight = derive_market_order_price(&quote, true, 2, 50);
1833        let wide = derive_market_order_price(&quote, true, 2, 800);
1834        assert_eq!(tight, dec!(100.6));
1835        assert_eq!(wide, dec!(108.11));
1836        assert!(wide > tight);
1837    }
1838
1839    // Locks in the field-selection invariant; diverging from it would silently
1840    // disagree with the HTTP parser whenever `account_value != total_raw_usd`
1841    // or the nested and top-level `withdrawable` values differ.
1842    #[rstest]
1843    fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
1844        let json = r#"{
1845            "assetPositions": [],
1846            "crossMarginSummary": {
1847                "accountValue": "150",
1848                "totalNtlPos": "0",
1849                "totalRawUsd": "100",
1850                "totalMarginUsed": "20",
1851                "withdrawable": "120"
1852            },
1853            "withdrawable": "80",
1854            "time": 1700000000000
1855        }"#;
1856
1857        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1858        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1859
1860        assert_eq!(balances.len(), 1);
1861        let balance = &balances[0];
1862        // Total comes from total_raw_usd (100), not account_value (150); free comes
1863        // from top-level state.withdrawable (80), not the nested summary.withdrawable (120).
1864        assert_eq!(balance.total.as_decimal(), dec!(100));
1865        assert_eq!(balance.free.as_decimal(), dec!(80));
1866        assert_eq!(balance.locked.as_decimal(), dec!(20));
1867
1868        assert_eq!(margins.len(), 1);
1869        assert_eq!(margins[0].initial.as_decimal(), dec!(20));
1870    }
1871
1872    #[rstest]
1873    fn test_parse_account_balances_bumps_total_when_withdrawable_exceeds() {
1874        let json = r#"{
1875            "assetPositions": [],
1876            "crossMarginSummary": {
1877                "accountValue": "100",
1878                "totalNtlPos": "0",
1879                "totalRawUsd": "100",
1880                "totalMarginUsed": "0",
1881                "withdrawable": "100"
1882            },
1883            "withdrawable": "150",
1884            "time": 1700000000000
1885        }"#;
1886
1887        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1888        let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
1889
1890        assert_eq!(balances.len(), 1);
1891        let balance = &balances[0];
1892        assert_eq!(balance.total.as_decimal(), dec!(150));
1893        assert_eq!(balance.free.as_decimal(), dec!(150));
1894        assert_eq!(balance.locked.as_decimal(), dec!(0));
1895    }
1896
1897    #[rstest]
1898    fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
1899        let json = r#"{
1900            "assetPositions": [],
1901            "withdrawable": "100",
1902            "time": 1700000000000
1903        }"#;
1904
1905        let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1906        let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1907        assert!(balances.is_empty());
1908        assert!(margins.is_empty());
1909    }
1910
1911    #[rstest]
1912    fn test_parse_spot_account_balances_emits_one_per_token() {
1913        let json = r#"{
1914            "balances": [
1915                {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
1916                {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
1917                {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
1918            ]
1919        }"#;
1920
1921        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1922        let balances = parse_spot_account_balances(&state).unwrap();
1923
1924        assert_eq!(balances.len(), 2);
1925
1926        let usdc = &balances[0];
1927        assert_eq!(usdc.currency.code.as_str(), "USDC");
1928        assert_eq!(usdc.total.as_decimal(), dec!(100.25));
1929        assert_eq!(usdc.free.as_decimal(), dec!(90.25));
1930        assert_eq!(usdc.locked.as_decimal(), dec!(10));
1931
1932        let purr = &balances[1];
1933        assert_eq!(purr.currency.code.as_str(), "PURR");
1934        assert_eq!(purr.total.as_decimal(), dec!(50));
1935        assert_eq!(purr.free.as_decimal(), dec!(50));
1936    }
1937
1938    #[rstest]
1939    fn test_parse_spot_account_balances_clamps_hold_to_total() {
1940        let json = r#"{
1941            "balances": [
1942                {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
1943            ]
1944        }"#;
1945
1946        let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1947        let balances = parse_spot_account_balances(&state).unwrap();
1948
1949        assert_eq!(balances.len(), 1);
1950        let hype = &balances[0];
1951        assert_eq!(hype.total.as_decimal(), dec!(5));
1952        assert_eq!(hype.free.as_decimal(), dec!(0));
1953        assert_eq!(hype.locked.as_decimal(), dec!(5));
1954    }
1955
1956    #[rstest]
1957    fn test_parse_spot_account_balances_empty() {
1958        let state = SpotClearinghouseState::default();
1959        let balances = parse_spot_account_balances(&state).unwrap();
1960        assert!(balances.is_empty());
1961    }
1962
1963    #[rstest]
1964    fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
1965        let perp_json = r#"{
1966            "assetPositions": [],
1967            "crossMarginSummary": {
1968                "accountValue": "500",
1969                "totalNtlPos": "0",
1970                "totalRawUsd": "500",
1971                "totalMarginUsed": "0",
1972                "withdrawable": "500"
1973            },
1974            "withdrawable": "500"
1975        }"#;
1976        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
1977
1978        let spot_json = r#"{
1979            "balances": [
1980                {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
1981                {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
1982            ]
1983        }"#;
1984        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
1985
1986        let (balances, margins) =
1987            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
1988
1989        assert!(margins.is_empty());
1990        assert_eq!(balances.len(), 2);
1991        assert_eq!(balances[0].currency.code.as_str(), "USDC");
1992        assert_eq!(balances[0].total.as_decimal(), dec!(500));
1993        assert_eq!(balances[1].currency.code.as_str(), "PURR");
1994        assert_eq!(balances[1].total.as_decimal(), dec!(10));
1995    }
1996
1997    #[rstest]
1998    fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
1999        let perp_json = r#"{"assetPositions": []}"#;
2000        let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2001
2002        let spot_json = r#"{
2003            "balances": [
2004                {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2005            ]
2006        }"#;
2007        let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2008
2009        let (balances, _) =
2010            parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2011
2012        assert_eq!(balances.len(), 1);
2013        assert_eq!(balances[0].currency.code.as_str(), "USDC");
2014        assert_eq!(balances[0].total.as_decimal(), dec!(50));
2015    }
2016}