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