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::{AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType, TimeInForce},
71    identifiers::{ClientOrderId, InstrumentId, Symbol, TradeId, Venue},
72    orders::{Order, any::OrderAny},
73    types::{AccountBalance, Currency, MarginBalance, Money},
74};
75use rust_decimal::Decimal;
76
77use crate::{
78    common::enums::{
79        HyperliquidBarInterval::{self, *},
80        HyperliquidOrderStatus, HyperliquidTpSl,
81    },
82    http::models::{
83        Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
84        HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelStatus,
85        HyperliquidExecLimitParams, HyperliquidExecModifyStatus, HyperliquidExecOrderKind,
86        HyperliquidExecOrderStatus, HyperliquidExecPlaceOrderRequest, HyperliquidExecResponseData,
87        HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
88    },
89    websocket::messages::TrailingOffsetType,
90};
91
92/// Creates a deterministic [`TradeId`] from fill fields common to both WS and HTTP responses.
93///
94/// Uses FNV-1a hash of `(hash, oid, px, sz, time, start_position)` to produce a unique
95/// identifier consistent across both data sources for the same physical fill.
96/// Includes `start_position` (running position before each fill) to disambiguate
97/// multiple partial fills within the same transaction at the same price/size.
98/// Format: `{fnv_hex}-{oid_hex}` (exactly 33 chars, within 36-char limit).
99pub fn make_fill_trade_id(
100    hash: &str,
101    oid: u64,
102    px: &str,
103    sz: &str,
104    time: u64,
105    start_position: &str,
106) -> TradeId {
107    // FNV-1a with fixed seed for deterministic output
108    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
109    for &b in hash.as_bytes() {
110        h ^= b as u64;
111        h = h.wrapping_mul(0x0100_0000_01b3);
112    }
113    for b in oid.to_le_bytes() {
114        h ^= b as u64;
115        h = h.wrapping_mul(0x0100_0000_01b3);
116    }
117    for &b in px.as_bytes() {
118        h ^= b as u64;
119        h = h.wrapping_mul(0x0100_0000_01b3);
120    }
121    for &b in sz.as_bytes() {
122        h ^= b as u64;
123        h = h.wrapping_mul(0x0100_0000_01b3);
124    }
125    for b in time.to_le_bytes() {
126        h ^= b as u64;
127        h = h.wrapping_mul(0x0100_0000_01b3);
128    }
129    for &b in start_position.as_bytes() {
130        h ^= b as u64;
131        h = h.wrapping_mul(0x0100_0000_01b3);
132    }
133    TradeId::new(format!("{h:016x}-{oid:016x}"))
134}
135
136/// Round price down to the nearest valid tick size.
137#[inline]
138pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
139    if tick_size.is_zero() {
140        return price;
141    }
142    (price / tick_size).floor() * tick_size
143}
144
145/// Round quantity down to the nearest valid step size.
146#[inline]
147pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
148    if step_size.is_zero() {
149        return qty;
150    }
151    (qty / step_size).floor() * step_size
152}
153
154/// Ensure the notional value meets minimum requirements.
155#[inline]
156pub fn ensure_min_notional(
157    price: Decimal,
158    qty: Decimal,
159    min_notional: Decimal,
160) -> Result<(), String> {
161    let notional = price * qty;
162    if notional < min_notional {
163        Err(format!(
164            "Notional value {notional} is less than minimum required {min_notional}"
165        ))
166    } else {
167        Ok(())
168    }
169}
170
171/// Round a decimal to at most N significant figures.
172/// Hyperliquid requires prices to have at most 5 significant figures.
173pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
174    if value.is_zero() {
175        return Decimal::ZERO;
176    }
177
178    // Find order of magnitude using log10
179    let abs_val = value.abs();
180    let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
181    let magnitude = float_val.log10().floor() as i32;
182
183    // Calculate shift to round to sig_figs
184    let shift = sig_figs as i32 - 1 - magnitude;
185    let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
186
187    if shift >= 0 {
188        (value * factor).round() / factor
189    } else {
190        (value / factor).round() * factor
191    }
192}
193
194/// Normalize price to the specified number of decimal places.
195pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
196    // First round to 5 significant figures (Hyperliquid requirement)
197    let sig_fig_price = round_to_sig_figs(price, 5);
198    // Then truncate to max decimal places
199    let scale = Decimal::from(10_u64.pow(decimals as u32));
200    (sig_fig_price * scale).floor() / scale
201}
202
203/// Normalize quantity to the specified number of decimal places.
204pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
205    let scale = Decimal::from(10_u64.pow(decimals as u32));
206    (qty * scale).floor() / scale
207}
208
209/// Complete normalization for an order including price, quantity, and notional validation
210pub fn normalize_order(
211    price: Decimal,
212    qty: Decimal,
213    tick_size: Decimal,
214    step_size: Decimal,
215    min_notional: Decimal,
216    price_decimals: u8,
217    size_decimals: u8,
218) -> Result<(Decimal, Decimal), String> {
219    // Normalize to decimal places first
220    let normalized_price = normalize_price(price, price_decimals);
221    let normalized_qty = normalize_quantity(qty, size_decimals);
222
223    // Round down to tick/step sizes
224    let final_price = round_down_to_tick(normalized_price, tick_size);
225    let final_qty = round_down_to_step(normalized_qty, step_size);
226
227    // Validate minimum notional
228    ensure_min_notional(final_price, final_qty, min_notional)?;
229
230    Ok((final_price, final_qty))
231}
232
233/// Converts millisecond timestamp to [`UnixNanos`].
234#[inline]
235pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
236    let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
237    Ok(UnixNanos::from(value))
238}
239
240/// Converts a Nautilus `TimeInForce` to Hyperliquid TIF.
241///
242/// # Errors
243///
244/// Returns an error if the time in force is not supported.
245pub fn time_in_force_to_hyperliquid_tif(
246    tif: TimeInForce,
247    is_post_only: bool,
248) -> anyhow::Result<HyperliquidExecTif> {
249    match (tif, is_post_only) {
250        (_, true) => Ok(HyperliquidExecTif::Alo), // Always use ALO for post-only orders
251        (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
252        (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
253        (TimeInForce::Fok, false) => {
254            anyhow::bail!("FOK time in force is not supported by Hyperliquid")
255        }
256        _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
257    }
258}
259
260fn determine_tpsl_type(
261    order_type: OrderType,
262    order_side: OrderSide,
263    trigger_price: Decimal,
264    current_price: Option<Decimal>,
265) -> HyperliquidExecTpSl {
266    match order_type {
267        // Stop orders are protective - always SL
268        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
269
270        // If Touched orders are profit-taking or entry orders - always TP
271        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
272
273        // For other trigger types, try to infer from price relationship if available
274        _ => {
275            if let Some(current) = current_price {
276                match order_side {
277                    OrderSide::Buy => {
278                        // Buy order: trigger above market = stop loss, below = take profit
279                        if trigger_price > current {
280                            HyperliquidExecTpSl::Sl
281                        } else {
282                            HyperliquidExecTpSl::Tp
283                        }
284                    }
285                    OrderSide::Sell => {
286                        // Sell order: trigger below market = stop loss, above = take profit
287                        if trigger_price < current {
288                            HyperliquidExecTpSl::Sl
289                        } else {
290                            HyperliquidExecTpSl::Tp
291                        }
292                    }
293                    _ => HyperliquidExecTpSl::Sl, // Default to SL for safety
294                }
295            } else {
296                // No market price available, default to SL for safety
297                HyperliquidExecTpSl::Sl
298            }
299        }
300    }
301}
302
303/// Converts a Nautilus `BarType` to a Hyperliquid bar interval.
304///
305/// # Errors
306///
307/// Returns an error if the bar type uses an unsupported aggregation or step value.
308pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
309    let spec = bar_type.spec();
310    let step = spec.step.get();
311
312    anyhow::ensure!(
313        bar_type.aggregation_source() == AggregationSource::External,
314        "Only EXTERNAL aggregation is supported"
315    );
316
317    let interval = match spec.aggregation {
318        BarAggregation::Minute => match step {
319            1 => OneMinute,
320            3 => ThreeMinutes,
321            5 => FiveMinutes,
322            15 => FifteenMinutes,
323            30 => ThirtyMinutes,
324            _ => anyhow::bail!("Unsupported minute step: {step}"),
325        },
326        BarAggregation::Hour => match step {
327            1 => OneHour,
328            2 => TwoHours,
329            4 => FourHours,
330            8 => EightHours,
331            12 => TwelveHours,
332            _ => anyhow::bail!("Unsupported hour step: {step}"),
333        },
334        BarAggregation::Day => match step {
335            1 => OneDay,
336            3 => ThreeDays,
337            _ => anyhow::bail!("Unsupported day step: {step}"),
338        },
339        BarAggregation::Week if step == 1 => OneWeek,
340        BarAggregation::Month if step == 1 => OneMonth,
341        a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
342    };
343
344    Ok(interval)
345}
346
347/// Converts a Nautilus order to Hyperliquid request using a pre-resolved asset index.
348///
349/// This variant is used when the caller has already resolved the asset index
350/// from the instrument cache (e.g., for SPOT instruments where the index
351/// cannot be derived from the symbol alone).
352pub fn order_to_hyperliquid_request_with_asset(
353    order: &OrderAny,
354    asset: u32,
355    price_decimals: u8,
356    should_normalize_prices: bool,
357) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
358    let is_buy = matches!(order.order_side(), OrderSide::Buy);
359    let reduce_only = order.is_reduce_only();
360    let order_side = order.order_side();
361    let order_type = order.order_type();
362
363    // Normalize decimals to strip trailing zeros, matching the server's
364    // canonical form used for EIP-712 signing hash verification.
365    let price_decimal = if let Some(price) = order.price() {
366        let raw = price.as_decimal();
367
368        if should_normalize_prices {
369            normalize_price(raw, price_decimals).normalize()
370        } else {
371            raw.normalize()
372        }
373    } else if matches!(order_type, OrderType::Market) {
374        Decimal::ZERO
375    } else if matches!(
376        order_type,
377        OrderType::StopMarket | OrderType::MarketIfTouched
378    ) {
379        match order.trigger_price() {
380            Some(tp) => {
381                let base = tp.as_decimal().normalize();
382                let derived = derive_limit_from_trigger(base, is_buy);
383                let sig_rounded = round_to_sig_figs(derived, 5);
384                clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
385            }
386            None => Decimal::ZERO,
387        }
388    } else {
389        anyhow::bail!("Limit orders require a price")
390    };
391
392    let size_decimal = order.quantity().as_decimal().normalize();
393
394    // Determine order kind based on order type
395    let kind = match order_type {
396        OrderType::Market => HyperliquidExecOrderKind::Limit {
397            limit: HyperliquidExecLimitParams {
398                tif: HyperliquidExecTif::Ioc,
399            },
400        },
401        OrderType::Limit => {
402            let tif =
403                time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
404            HyperliquidExecOrderKind::Limit {
405                limit: HyperliquidExecLimitParams { tif },
406            }
407        }
408        OrderType::StopMarket => {
409            if let Some(trigger_price) = order.trigger_price() {
410                let raw = trigger_price.as_decimal();
411                let trigger_price_decimal = if should_normalize_prices {
412                    normalize_price(raw, price_decimals).normalize()
413                } else {
414                    raw.normalize()
415                };
416                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
417                HyperliquidExecOrderKind::Trigger {
418                    trigger: HyperliquidExecTriggerParams {
419                        is_market: true,
420                        trigger_px: trigger_price_decimal,
421                        tpsl,
422                    },
423                }
424            } else {
425                anyhow::bail!("Stop market orders require a trigger price")
426            }
427        }
428        OrderType::StopLimit => {
429            if let Some(trigger_price) = order.trigger_price() {
430                let raw = trigger_price.as_decimal();
431                let trigger_price_decimal = if should_normalize_prices {
432                    normalize_price(raw, price_decimals).normalize()
433                } else {
434                    raw.normalize()
435                };
436                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
437                HyperliquidExecOrderKind::Trigger {
438                    trigger: HyperliquidExecTriggerParams {
439                        is_market: false,
440                        trigger_px: trigger_price_decimal,
441                        tpsl,
442                    },
443                }
444            } else {
445                anyhow::bail!("Stop limit orders require a trigger price")
446            }
447        }
448        OrderType::MarketIfTouched => {
449            if let Some(trigger_price) = order.trigger_price() {
450                let raw = trigger_price.as_decimal();
451                let trigger_price_decimal = if should_normalize_prices {
452                    normalize_price(raw, price_decimals).normalize()
453                } else {
454                    raw.normalize()
455                };
456                HyperliquidExecOrderKind::Trigger {
457                    trigger: HyperliquidExecTriggerParams {
458                        is_market: true,
459                        trigger_px: trigger_price_decimal,
460                        tpsl: HyperliquidExecTpSl::Tp,
461                    },
462                }
463            } else {
464                anyhow::bail!("Market-if-touched orders require a trigger price")
465            }
466        }
467        OrderType::LimitIfTouched => {
468            if let Some(trigger_price) = order.trigger_price() {
469                let raw = trigger_price.as_decimal();
470                let trigger_price_decimal = if should_normalize_prices {
471                    normalize_price(raw, price_decimals).normalize()
472                } else {
473                    raw.normalize()
474                };
475                HyperliquidExecOrderKind::Trigger {
476                    trigger: HyperliquidExecTriggerParams {
477                        is_market: false,
478                        trigger_px: trigger_price_decimal,
479                        tpsl: HyperliquidExecTpSl::Tp,
480                    },
481                }
482            } else {
483                anyhow::bail!("Limit-if-touched orders require a trigger price")
484            }
485        }
486        _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
487    };
488
489    let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
490
491    Ok(HyperliquidExecPlaceOrderRequest {
492        asset,
493        is_buy,
494        price: price_decimal,
495        size: size_decimal,
496        reduce_only,
497        kind,
498        cloid,
499    })
500}
501
502/// Derives a market order limit price from a quote with 0.5% slippage.
503///
504/// Uses the ask price for buys and the bid price for sells, applies
505/// slippage, rounds to 5 significant figures, and clamps to the
506/// instrument's price precision.
507pub fn derive_market_order_price(quote: &QuoteTick, is_buy: bool, price_decimals: u8) -> Decimal {
508    let base = if is_buy {
509        quote.ask_price.as_decimal()
510    } else {
511        quote.bid_price.as_decimal()
512    };
513    let derived = derive_limit_from_trigger(base, is_buy);
514    let sig_rounded = round_to_sig_figs(derived, 5);
515    clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
516}
517
518/// Derives a limit price from a trigger price with slippage.
519///
520/// Hyperliquid requires that the limit price satisfies:
521/// - SELL stops: `limit_px <= trigger_px`
522/// - BUY stops: `limit_px >= trigger_px`
523///
524/// Applies 0.5% slippage in the appropriate direction.
525pub fn derive_limit_from_trigger(trigger_price: Decimal, is_buy: bool) -> Decimal {
526    let slippage = Decimal::new(5, 3); // 0.5%
527    let price = if is_buy {
528        trigger_price * (Decimal::ONE + slippage)
529    } else {
530        trigger_price * (Decimal::ONE - slippage)
531    };
532
533    // Strip trailing zeros for EIP-712 signing hash verification
534    price.normalize()
535}
536
537/// Clamp a price to the instrument's decimal precision,
538/// rounding in the direction that preserves the slippage buffer.
539pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
540    let scale = Decimal::from(10_u64.pow(decimals as u32));
541
542    if is_buy {
543        (price * scale).ceil() / scale
544    } else {
545        (price * scale).floor() / scale
546    }
547}
548
549/// Converts a client order ID to a Hyperliquid cancel request using a pre-resolved asset index.
550pub fn client_order_id_to_cancel_request_with_asset(
551    client_order_id: &str,
552    asset: u32,
553) -> HyperliquidExecCancelByCloidRequest {
554    let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
555    HyperliquidExecCancelByCloidRequest { asset, cloid }
556}
557
558/// Extracts per-item error from a successful Hyperliquid exchange response.
559///
560/// When the top-level status is "ok", individual items in the `statuses`
561/// array may still contain errors. Returns the first error found, or
562/// `None` if all items succeeded or the response cannot be parsed.
563pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
564    let HyperliquidExchangeResponse::Status { response, .. } = response else {
565        return None;
566    };
567    let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
568    match data {
569        HyperliquidExecResponseData::Order { data } => {
570            for status in &data.statuses {
571                if let HyperliquidExecOrderStatus::Error { error } = status {
572                    return Some(error.clone());
573                }
574            }
575            None
576        }
577        HyperliquidExecResponseData::Cancel { data } => {
578            for status in &data.statuses {
579                if let HyperliquidExecCancelStatus::Error { error } = status {
580                    return Some(error.clone());
581                }
582            }
583            None
584        }
585        HyperliquidExecResponseData::Modify { data } => {
586            for status in &data.statuses {
587                if let HyperliquidExecModifyStatus::Error { error } = status {
588                    return Some(error.clone());
589                }
590            }
591            None
592        }
593        _ => None,
594    }
595}
596
597/// Extracts per-item errors from a successful batch response.
598///
599/// Returns a `Vec` with one `Option<String>` per item in the `statuses`
600/// array: `Some(error)` for failed items, `None` for successful ones.
601/// Returns an empty vec if the response cannot be parsed.
602pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
603    let HyperliquidExchangeResponse::Status { response, .. } = response else {
604        return Vec::new();
605    };
606    let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
607        return Vec::new();
608    };
609    match data {
610        HyperliquidExecResponseData::Order { data } => data
611            .statuses
612            .into_iter()
613            .map(|s| match s {
614                HyperliquidExecOrderStatus::Error { error } => Some(error),
615                _ => None,
616            })
617            .collect(),
618        _ => Vec::new(),
619    }
620}
621
622/// Extracts error message from a Hyperliquid exchange response.
623pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
624    match response {
625        HyperliquidExchangeResponse::Status { status, response } => {
626            if status == RESPONSE_STATUS_OK {
627                "Operation successful".to_string()
628            } else {
629                // Try to extract error message from response data
630                if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
631                    error_msg.to_string()
632                } else {
633                    format!("Request failed with status: {status}")
634                }
635            }
636        }
637        HyperliquidExchangeResponse::Error { error } => error.clone(),
638    }
639}
640
641/// Determines if an order is a conditional/trigger order based on order data.
642///
643/// # Returns
644///
645/// `true` if the order is a conditional order, `false` otherwise.
646pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
647    trigger_px.is_some() && tpsl.is_some()
648}
649
650/// Parses trigger order type from Hyperliquid order data.
651///
652/// # Returns
653///
654/// The corresponding Nautilus `OrderType`.
655pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
656    match (is_market, tpsl) {
657        (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
658        (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
659        (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
660        (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
661    }
662}
663
664/// Extracts order status from WebSocket order data.
665///
666/// # Returns
667///
668/// A tuple of (OrderStatus, optional trigger status string).
669pub fn parse_order_status_with_trigger(
670    status: HyperliquidOrderStatus,
671    trigger_activated: Option<bool>,
672) -> (OrderStatus, Option<String>) {
673    let base_status = OrderStatus::from(status);
674
675    // For conditional orders, add trigger status information
676    if let Some(activated) = trigger_activated {
677        let trigger_status = if activated {
678            Some("activated".to_string())
679        } else {
680            Some("pending".to_string())
681        };
682        (base_status, trigger_status)
683    } else {
684        (base_status, None)
685    }
686}
687
688/// Converts WebSocket trailing stop data to description string.
689pub fn format_trailing_stop_info(
690    offset: &str,
691    offset_type: TrailingOffsetType,
692    callback_price: Option<&str>,
693) -> String {
694    let offset_desc = offset_type.format_offset(offset);
695
696    if let Some(callback) = callback_price {
697        format!("Trailing stop: {offset_desc} offset, callback at {callback}")
698    } else {
699        format!("Trailing stop: {offset_desc} offset")
700    }
701}
702
703/// Validates conditional order parameters from WebSocket data.
704///
705/// # Returns
706///
707/// `Ok(())` if parameters are valid, `Err` with description otherwise.
708pub fn validate_conditional_order_params(
709    trigger_px: Option<&str>,
710    tpsl: Option<&HyperliquidTpSl>,
711    is_market: Option<bool>,
712) -> anyhow::Result<()> {
713    if trigger_px.is_none() {
714        anyhow::bail!("Conditional order missing trigger price");
715    }
716
717    if tpsl.is_none() {
718        anyhow::bail!("Conditional order missing tpsl indicator");
719    }
720
721    // No need to validate tpsl value - the enum type guarantees it's either Tp or Sl
722
723    if is_market.is_none() {
724        anyhow::bail!("Conditional order missing is_market flag");
725    }
726
727    Ok(())
728}
729
730/// Parses trigger price from string to Decimal.
731///
732/// # Returns
733///
734/// Parsed Decimal value or error.
735pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
736    Decimal::from_str_exact(trigger_px)
737        .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
738}
739
740/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
741///
742/// # Errors
743///
744/// Returns an error if the data cannot be parsed.
745pub fn parse_account_balances_and_margins(
746    cross_margin_summary: &CrossMarginSummary,
747) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
748    let mut balances = Vec::new();
749    let mut margins = Vec::new();
750
751    let currency = Currency::USDC();
752
753    let mut total_value = cross_margin_summary
754        .account_value
755        .to_string()
756        .parse::<f64>()?
757        .max(0.0);
758
759    let free_value = cross_margin_summary
760        .withdrawable
761        .map(|w| w.to_string().parse::<f64>())
762        .transpose()?
763        .unwrap_or(total_value)
764        .max(0.0);
765
766    // Ensure total >= free to satisfy AccountBalance invariant
767    if free_value > total_value {
768        total_value = free_value;
769    }
770
771    let locked_value = total_value - free_value;
772
773    let total = Money::new(total_value, currency);
774    let locked = Money::new(locked_value, currency);
775    let free = Money::new(free_value, currency);
776
777    let balance = AccountBalance::new(total, locked, free);
778    balances.push(balance);
779
780    let margin_used = cross_margin_summary
781        .total_margin_used
782        .to_string()
783        .parse::<f64>()?;
784
785    if margin_used > 0.0 {
786        let margin_instrument_id =
787            InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
788
789        let initial_margin = Money::new(margin_used, currency);
790        let maintenance_margin = Money::new(margin_used, currency);
791
792        let margin_balance =
793            MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
794
795        margins.push(margin_balance);
796    }
797
798    Ok((balances, margins))
799}
800
801#[cfg(test)]
802mod tests {
803    use std::str::FromStr;
804
805    use nautilus_model::{
806        enums::{OrderSide, TimeInForce, TriggerType},
807        identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
808        orders::{OrderAny, StopMarketOrder},
809        types::{Price, Quantity},
810    };
811    use rstest::rstest;
812    use rust_decimal::Decimal;
813    use rust_decimal_macros::dec;
814    use serde::{Deserialize, Serialize};
815
816    use super::*;
817
818    #[derive(Serialize, Deserialize)]
819    struct TestStruct {
820        #[serde(
821            serialize_with = "serialize_decimal_as_str",
822            deserialize_with = "deserialize_decimal_from_str"
823        )]
824        value: Decimal,
825        #[serde(
826            serialize_with = "serialize_optional_decimal_as_str",
827            deserialize_with = "deserialize_optional_decimal_from_str"
828        )]
829        optional_value: Option<Decimal>,
830    }
831
832    #[rstest]
833    fn test_decimal_serialization_roundtrip() {
834        let original = TestStruct {
835            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
836            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
837        };
838
839        let json = serde_json::to_string(&original).unwrap();
840        println!("Serialized: {json}");
841
842        // Check that it's serialized as strings (rust_decimal may normalize precision)
843        assert!(json.contains("\"123.45678901234567890123456789\""));
844        assert!(json.contains("\"0.000000001\""));
845
846        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
847        assert_eq!(original.value, deserialized.value);
848        assert_eq!(original.optional_value, deserialized.optional_value);
849    }
850
851    #[rstest]
852    fn test_decimal_precision_preservation() {
853        let test_cases = [
854            "0",
855            "1",
856            "0.1",
857            "0.01",
858            "0.001",
859            "123.456789012345678901234567890",
860            "999999999999999999.999999999999999999",
861        ];
862
863        for case in test_cases {
864            let decimal = Decimal::from_str(case).unwrap();
865            let test_struct = TestStruct {
866                value: decimal,
867                optional_value: Some(decimal),
868            };
869
870            let json = serde_json::to_string(&test_struct).unwrap();
871            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
872
873            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
874            assert_eq!(
875                Some(decimal),
876                parsed.optional_value,
877                "Failed for case: {case}"
878            );
879        }
880    }
881
882    #[rstest]
883    fn test_optional_none_handling() {
884        let test_struct = TestStruct {
885            value: Decimal::from_str("42.0").unwrap(),
886            optional_value: None,
887        };
888
889        let json = serde_json::to_string(&test_struct).unwrap();
890        assert!(json.contains("null"));
891
892        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
893        assert_eq!(test_struct.value, parsed.value);
894        assert_eq!(None, parsed.optional_value);
895    }
896
897    #[rstest]
898    fn test_round_down_to_tick() {
899        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
900        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
901        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
902
903        // Edge case: zero tick size
904        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
905    }
906
907    #[rstest]
908    fn test_round_down_to_step() {
909        assert_eq!(
910            round_down_to_step(dec!(0.12349), dec!(0.0001)),
911            dec!(0.1234)
912        );
913        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
914        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
915
916        // Edge case: zero step size
917        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
918    }
919
920    #[rstest]
921    fn test_min_notional_validation() {
922        // Should pass
923        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
924        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
925
926        // Should fail
927        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
928        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
929
930        // Edge case: exactly at minimum
931        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
932    }
933
934    #[rstest]
935    fn test_round_to_sig_figs() {
936        // BTC price ~$104,567 needs to round to 5 sig figs
937        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
938        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
939        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
940
941        // Smaller prices should keep decimals
942        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
943        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
944        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
945
946        // Sub-1 values with leading zeros must preserve 5 sig figs
947        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
948        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
949
950        // Zero case
951        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
952    }
953
954    #[rstest]
955    fn test_normalize_price() {
956        // Now includes 5 sig fig rounding first
957        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
958        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
959        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
960        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
961
962        // BTC-like prices get rounded to 5 sig figs
963        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
964    }
965
966    #[rstest]
967    fn test_normalize_quantity() {
968        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
969        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
970        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
971        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
972    }
973
974    #[rstest]
975    fn test_normalize_order_complete() {
976        let result = normalize_order(
977            dec!(100.12345), // price
978            dec!(0.123456),  // qty
979            dec!(0.01),      // tick_size
980            dec!(0.0001),    // step_size
981            dec!(10),        // min_notional
982            2,               // price_decimals
983            4,               // size_decimals
984        );
985
986        assert!(result.is_ok());
987        let (price, qty) = result.unwrap();
988        assert_eq!(price, dec!(100.12)); // normalized and rounded down
989        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
990    }
991
992    #[rstest]
993    fn test_normalize_order_min_notional_fail() {
994        let result = normalize_order(
995            dec!(100.12345), // price
996            dec!(0.05),      // qty (too small for min notional)
997            dec!(0.01),      // tick_size
998            dec!(0.0001),    // step_size
999            dec!(10),        // min_notional
1000            2,               // price_decimals
1001            4,               // size_decimals
1002        );
1003
1004        assert!(result.is_err());
1005        assert!(result.unwrap_err().contains("Notional value"));
1006    }
1007
1008    #[rstest]
1009    fn test_edge_cases() {
1010        // Test with very small numbers
1011        assert_eq!(
1012            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1013            dec!(0.000001)
1014        );
1015
1016        // Test with large numbers
1017        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1018
1019        // Test rounding edge case
1020        assert_eq!(
1021            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1022            dec!(100.00)
1023        );
1024    }
1025
1026    #[rstest]
1027    fn test_is_conditional_order_data() {
1028        // Test with trigger price and tpsl (conditional)
1029        assert!(is_conditional_order_data(
1030            Some("50000.0"),
1031            Some(&HyperliquidTpSl::Sl)
1032        ));
1033
1034        // Test with only trigger price (not conditional - needs both)
1035        assert!(!is_conditional_order_data(Some("50000.0"), None));
1036
1037        // Test with only tpsl (not conditional - needs both)
1038        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1039
1040        // Test with no conditional fields
1041        assert!(!is_conditional_order_data(None, None));
1042    }
1043
1044    #[rstest]
1045    fn test_parse_trigger_order_type() {
1046        // Stop Market
1047        assert_eq!(
1048            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1049            OrderType::StopMarket
1050        );
1051
1052        // Stop Limit
1053        assert_eq!(
1054            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1055            OrderType::StopLimit
1056        );
1057
1058        // Take Profit Market
1059        assert_eq!(
1060            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1061            OrderType::MarketIfTouched
1062        );
1063
1064        // Take Profit Limit
1065        assert_eq!(
1066            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1067            OrderType::LimitIfTouched
1068        );
1069    }
1070
1071    #[rstest]
1072    fn test_parse_order_status_with_trigger() {
1073        // Test with open status and activated trigger
1074        let (status, trigger_status) =
1075            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1076        assert_eq!(status, OrderStatus::Accepted);
1077        assert_eq!(trigger_status, Some("activated".to_string()));
1078
1079        // Test with open status and not activated
1080        let (status, trigger_status) =
1081            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1082        assert_eq!(status, OrderStatus::Accepted);
1083        assert_eq!(trigger_status, Some("pending".to_string()));
1084
1085        // Test without trigger info
1086        let (status, trigger_status) =
1087            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1088        assert_eq!(status, OrderStatus::Accepted);
1089        assert_eq!(trigger_status, None);
1090    }
1091
1092    #[rstest]
1093    fn test_format_trailing_stop_info() {
1094        // Price offset
1095        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1096        assert!(info.contains("100.0"));
1097        assert!(info.contains("callback at 50000.0"));
1098
1099        // Percentage offset
1100        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1101        assert!(info.contains("5.0%"));
1102        assert!(info.contains("Trailing stop"));
1103
1104        // Basis points offset
1105        let info =
1106            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1107        assert!(info.contains("250 bps"));
1108        assert!(info.contains("49000.0"));
1109    }
1110
1111    #[rstest]
1112    fn test_parse_trigger_price() {
1113        // Valid price
1114        let result = parse_trigger_price("50000.0");
1115        assert!(result.is_ok());
1116        assert_eq!(result.unwrap(), dec!(50000.0));
1117
1118        // Valid integer price
1119        let result = parse_trigger_price("49000");
1120        assert!(result.is_ok());
1121        assert_eq!(result.unwrap(), dec!(49000));
1122
1123        // Invalid price
1124        let result = parse_trigger_price("invalid");
1125        assert!(result.is_err());
1126
1127        // Empty string
1128        let result = parse_trigger_price("");
1129        assert!(result.is_err());
1130    }
1131
1132    #[rstest]
1133    #[case(dec!(0), true, dec!(0))] // Zero
1134    #[case(dec!(0), false, dec!(0))] // Zero
1135    #[case(dec!(0.001), true, dec!(0.001005))] // Small price BUY
1136    #[case(dec!(0.001), false, dec!(0.000995))] // Small price SELL
1137    #[case(dec!(100), true, dec!(100.5))] // Round price BUY
1138    #[case(dec!(100), false, dec!(99.5))] // Round price SELL
1139    #[case(dec!(2470), true, dec!(2482.35))] // ETH-like BUY
1140    #[case(dec!(2470), false, dec!(2457.65))] // ETH-like SELL
1141    #[case(dec!(104567.3), true, dec!(105090.1365))] // BTC-like BUY
1142    #[case(dec!(104567.3), false, dec!(104044.4635))] // BTC-like SELL
1143    fn test_derive_limit_from_trigger(
1144        #[case] trigger_price: Decimal,
1145        #[case] is_buy: bool,
1146        #[case] expected: Decimal,
1147    ) {
1148        let result = derive_limit_from_trigger(trigger_price, is_buy);
1149        assert_eq!(result, expected);
1150
1151        // Verify invariant: BUY limit >= trigger, SELL limit <= trigger
1152        if is_buy {
1153            assert!(result >= trigger_price);
1154        } else {
1155            assert!(result <= trigger_price);
1156        }
1157    }
1158
1159    #[rstest]
1160    // BUY rounds up (ceil)
1161    #[case(dec!(2457.65), 2, true, dec!(2457.65))] // Already at precision
1162    #[case(dec!(2457.65), 1, true, dec!(2457.7))] // Ceil to 1dp
1163    #[case(dec!(2457.65), 0, true, dec!(2458))] // Ceil to integer
1164    // SELL rounds down (floor)
1165    #[case(dec!(2457.65), 2, false, dec!(2457.65))] // Already at precision
1166    #[case(dec!(2457.65), 1, false, dec!(2457.6))] // Floor to 1dp
1167    #[case(dec!(2457.65), 0, false, dec!(2457))] // Floor to integer
1168    // High precision (no-op)
1169    #[case(dec!(0.4975), 4, true, dec!(0.4975))]
1170    #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1171    // Precision forces clamping on small values
1172    #[case(dec!(0.4975), 2, true, dec!(0.50))]
1173    #[case(dec!(0.4975), 2, false, dec!(0.49))]
1174    fn test_clamp_price_to_precision(
1175        #[case] price: Decimal,
1176        #[case] decimals: u8,
1177        #[case] is_buy: bool,
1178        #[case] expected: Decimal,
1179    ) {
1180        assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1181    }
1182
1183    fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1184        OrderAny::StopMarket(StopMarketOrder::new(
1185            TraderId::from("TESTER-001"),
1186            StrategyId::from("S-001"),
1187            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1188            ClientOrderId::from("O-001"),
1189            side,
1190            Quantity::from(1),
1191            Price::from(trigger_price),
1192            TriggerType::LastPrice,
1193            TimeInForce::Gtc,
1194            None,
1195            false,
1196            false,
1197            None,
1198            None,
1199            None,
1200            None,
1201            None,
1202            None,
1203            None,
1204            None,
1205            None,
1206            None,
1207            None,
1208            Default::default(),
1209            Default::default(),
1210        ))
1211    }
1212
1213    #[rstest]
1214    // ETH-like (precision=2): clamping is a no-op
1215    #[case(OrderSide::Sell, "2470.00", 2)]
1216    #[case(OrderSide::Buy, "2470.00", 2)]
1217    // BTC-like (precision=1): clamping is a no-op
1218    #[case(OrderSide::Sell, "104567.3", 1)]
1219    #[case(OrderSide::Buy, "104567.3", 1)]
1220    // Low-price token (precision=4): clamping is a no-op
1221    #[case(OrderSide::Sell, "0.50", 4)]
1222    #[case(OrderSide::Buy, "0.50", 4)]
1223    // Clamping materially changes: ETH trigger at precision=1
1224    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(1dp) = 2457.6
1225    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(1dp) = 2482.4
1226    #[case(OrderSide::Sell, "2470.00", 1)]
1227    #[case(OrderSide::Buy, "2470.00", 1)]
1228    // Clamping materially changes: precision=0 forces integer
1229    // SELL: 2470 * 0.995 = 2457.65 → sig5 = 2457.6 → floor(0dp) = 2457
1230    // BUY:  2470 * 1.005 = 2482.35 → sig5 = 2482.4 → ceil(0dp) = 2483
1231    #[case(OrderSide::Sell, "2470.00", 0)]
1232    #[case(OrderSide::Buy, "2470.00", 0)]
1233    fn test_order_to_request_stop_market_derives_limit_from_trigger(
1234        #[case] side: OrderSide,
1235        #[case] trigger_str: &str,
1236        #[case] price_decimals: u8,
1237    ) {
1238        let order = stop_market_order(side, trigger_str);
1239        let request =
1240            order_to_hyperliquid_request_with_asset(&order, 0, price_decimals, true).unwrap();
1241        let trigger = Decimal::from_str(trigger_str).unwrap();
1242        let is_buy = matches!(side, OrderSide::Buy);
1243
1244        // Price must satisfy Hyperliquid's directional constraint
1245        if is_buy {
1246            assert!(
1247                request.price >= trigger,
1248                "BUY limit {} must be >= trigger {trigger}",
1249                request.price,
1250            );
1251            assert!(request.is_buy);
1252        } else {
1253            assert!(
1254                request.price <= trigger,
1255                "SELL limit {} must be <= trigger {trigger}",
1256                request.price,
1257            );
1258            assert!(!request.is_buy);
1259        }
1260
1261        // Price must equal the full pipeline: derive → sig figs → clamp → normalize
1262        let derived = derive_limit_from_trigger(trigger, is_buy);
1263        let sig_rounded = round_to_sig_figs(derived, 5);
1264        let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1265        assert_eq!(request.price, expected);
1266
1267        // Decimal places must not exceed instrument precision
1268        let price_str = request.price.to_string();
1269        let actual_decimals = price_str
1270            .find('.')
1271            .map_or(0, |dot| price_str.len() - dot - 1);
1272        assert!(
1273            actual_decimals <= price_decimals as usize,
1274            "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1275        );
1276
1277        // Decimal trailing zeros must be stripped (canonical form)
1278        if price_str.contains('.') {
1279            assert!(
1280                !price_str.ends_with('0'),
1281                "Price {price_str} has decimal trailing zeros",
1282            );
1283        }
1284
1285        let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1286        assert_eq!(
1287            request.kind,
1288            HyperliquidExecOrderKind::Trigger {
1289                trigger: HyperliquidExecTriggerParams {
1290                    is_market: true,
1291                    trigger_px: expected_trigger,
1292                    tpsl: HyperliquidExecTpSl::Sl,
1293                },
1294            },
1295        );
1296    }
1297
1298    fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1299        HyperliquidExchangeResponse::Status {
1300            status: "ok".to_string(),
1301            response: inner,
1302        }
1303    }
1304
1305    #[rstest]
1306    fn test_extract_inner_error_order_with_error() {
1307        let response = ok_response(serde_json::json!({
1308            "type": "order",
1309            "data": {"statuses": [{"error": "Order has invalid price."}]}
1310        }));
1311        assert_eq!(
1312            extract_inner_error(&response),
1313            Some("Order has invalid price.".to_string()),
1314        );
1315    }
1316
1317    #[rstest]
1318    fn test_extract_inner_error_order_resting() {
1319        let response = ok_response(serde_json::json!({
1320            "type": "order",
1321            "data": {"statuses": [{"resting": {"oid": 12345}}]}
1322        }));
1323        assert_eq!(extract_inner_error(&response), None);
1324    }
1325
1326    #[rstest]
1327    fn test_extract_inner_error_order_filled() {
1328        let response = ok_response(serde_json::json!({
1329            "type": "order",
1330            "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1331        }));
1332        assert_eq!(extract_inner_error(&response), None);
1333    }
1334
1335    #[rstest]
1336    fn test_extract_inner_error_cancel_error() {
1337        let response = ok_response(serde_json::json!({
1338            "type": "cancel",
1339            "data": {"statuses": [{"error": "Order not found"}]}
1340        }));
1341        assert_eq!(
1342            extract_inner_error(&response),
1343            Some("Order not found".to_string()),
1344        );
1345    }
1346
1347    #[rstest]
1348    fn test_extract_inner_error_cancel_success() {
1349        let response = ok_response(serde_json::json!({
1350            "type": "cancel",
1351            "data": {"statuses": ["success"]}
1352        }));
1353        assert_eq!(extract_inner_error(&response), None);
1354    }
1355
1356    #[rstest]
1357    fn test_extract_inner_error_modify_error() {
1358        let response = ok_response(serde_json::json!({
1359            "type": "modify",
1360            "data": {"statuses": [{"error": "Invalid modify"}]}
1361        }));
1362        assert_eq!(
1363            extract_inner_error(&response),
1364            Some("Invalid modify".to_string()),
1365        );
1366    }
1367
1368    #[rstest]
1369    fn test_extract_inner_error_modify_success() {
1370        let response = ok_response(serde_json::json!({
1371            "type": "modify",
1372            "data": {"statuses": ["success"]}
1373        }));
1374        assert_eq!(extract_inner_error(&response), None);
1375    }
1376
1377    #[rstest]
1378    fn test_extract_inner_error_non_status_response() {
1379        let response = HyperliquidExchangeResponse::Error {
1380            error: "top-level error".to_string(),
1381        };
1382        assert_eq!(extract_inner_error(&response), None);
1383    }
1384
1385    #[rstest]
1386    fn test_extract_inner_error_unparsable_response() {
1387        let response = ok_response(serde_json::json!({"unknown": "data"}));
1388        assert_eq!(extract_inner_error(&response), None);
1389    }
1390
1391    #[rstest]
1392    fn test_extract_inner_error_returns_first_error_in_batch() {
1393        let response = ok_response(serde_json::json!({
1394            "type": "order",
1395            "data": {"statuses": [
1396                {"resting": {"oid": 1}},
1397                {"error": "Second failed"},
1398                {"error": "Third failed"},
1399            ]}
1400        }));
1401        assert_eq!(
1402            extract_inner_error(&response),
1403            Some("Second failed".to_string()),
1404        );
1405    }
1406
1407    #[rstest]
1408    fn test_extract_inner_errors_mixed_batch() {
1409        let response = ok_response(serde_json::json!({
1410            "type": "order",
1411            "data": {"statuses": [
1412                {"resting": {"oid": 1}},
1413                {"error": "Failed order"},
1414                {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1415            ]}
1416        }));
1417        let errors = extract_inner_errors(&response);
1418        assert_eq!(errors.len(), 3);
1419        assert_eq!(errors[0], None);
1420        assert_eq!(errors[1], Some("Failed order".to_string()));
1421        assert_eq!(errors[2], None);
1422    }
1423
1424    #[rstest]
1425    fn test_extract_inner_errors_all_success() {
1426        let response = ok_response(serde_json::json!({
1427            "type": "order",
1428            "data": {"statuses": [
1429                {"resting": {"oid": 1}},
1430                {"resting": {"oid": 2}},
1431            ]}
1432        }));
1433        let errors = extract_inner_errors(&response);
1434        assert_eq!(errors.len(), 2);
1435        assert!(errors.iter().all(|e| e.is_none()));
1436    }
1437
1438    #[rstest]
1439    fn test_extract_inner_errors_non_order_response() {
1440        let response = ok_response(serde_json::json!({
1441            "type": "cancel",
1442            "data": {"statuses": ["success"]}
1443        }));
1444        let errors = extract_inner_errors(&response);
1445        assert!(errors.is_empty());
1446    }
1447
1448    #[rstest]
1449    fn test_extract_inner_errors_unparsable() {
1450        let response = ok_response(serde_json::json!({"foo": "bar"}));
1451        let errors = extract_inner_errors(&response);
1452        assert!(errors.is_empty());
1453    }
1454
1455    fn count_sig_figs(s: &str) -> usize {
1456        let s = s.trim_start_matches('-');
1457        if s.contains('.') {
1458            // Decimal: all digits excluding leading zeros are significant
1459            let digits: String = s.replace('.', "");
1460            digits.trim_start_matches('0').len()
1461        } else {
1462            // Integer: trailing zeros are place-holders, not significant
1463            let s = s.trim_start_matches('0');
1464            s.trim_end_matches('0').len()
1465        }
1466    }
1467
1468    fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1469        QuoteTick::new(
1470            InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1471            Price::from(bid),
1472            Price::from(ask),
1473            Quantity::from("1"),
1474            Quantity::from("1"),
1475            Default::default(),
1476            Default::default(),
1477        )
1478    }
1479
1480    #[rstest]
1481    // BUY uses ask, SELL uses bid
1482    // Pipeline: base → +/-0.5% slippage → round 5 sig figs → clamp → normalize
1483    //
1484    // ETH-like (precision=2)
1485    // BUY: ask=2470 → 2470*1.005=2482.35 → sig5=2482.4 → clamp(2,ceil)=2482.40 → 2482.4
1486    #[case("2460.00", "2470.00", true, 2, "2482.4")]
1487    // SELL: bid=2460 → 2460*0.995=2447.70 → sig5=2447.7 → clamp(2,floor)=2447.70 → 2447.7
1488    #[case("2460.00", "2470.00", false, 2, "2447.7")]
1489    //
1490    // BTC-like (precision=1)
1491    // BUY: ask=104567.3 → 104567.3*1.005=105090.1365 → sig5=105090 → clamp(1,ceil)=105090 → 105090
1492    #[case("104500.0", "104567.3", true, 1, "105090")]
1493    // SELL: bid=104500.0 → 104500*0.995=103977.5 → sig5=103980 → clamp(1,floor)=103980 → 103980
1494    #[case("104500.0", "104567.3", false, 1, "103980")]
1495    //
1496    // Low-price token (precision=4)
1497    // BUY: ask=0.5000 → 0.5*1.005=0.5025 → sig5=0.50250 → clamp(4,ceil)=0.5025 → 0.5025
1498    #[case("0.4900", "0.5000", true, 4, "0.5025")]
1499    // SELL: bid=0.49 → 0.49*0.995=0.48755 → sig5=0.48755 → clamp(4,floor)=0.4875 → 0.4875
1500    #[case("0.4900", "0.5000", false, 4, "0.4875")]
1501    //
1502    // High-price low-precision (precision=0)
1503    // BUY: ask=50000 → 50000*1.005=50250 → sig5=50250 → clamp(0,ceil)=50250 → 50250
1504    #[case("49900", "50000", true, 0, "50250")]
1505    // SELL: bid=49900 → 49900*0.995=49650.5 → sig5=49650 → clamp(0,floor)=49650 → 49650
1506    #[case("49900", "50000", false, 0, "49650")]
1507    //
1508    // Very small price (precision=6)
1509    // BUY: ask=0.001234 → 0.001234*1.005=0.0012402 → sig5=0.0012402 → clamp(6,ceil)=0.001241
1510    #[case("0.001200", "0.001234", true, 6, "0.001241")]
1511    // SELL: bid=0.0012 → 0.0012*0.995=0.001194 → sig5=0.001194 → clamp(6,floor)=0.001194
1512    #[case("0.001200", "0.001234", false, 6, "0.001194")]
1513    fn test_derive_market_order_price(
1514        #[case] bid: &str,
1515        #[case] ask: &str,
1516        #[case] is_buy: bool,
1517        #[case] price_decimals: u8,
1518        #[case] expected: &str,
1519    ) {
1520        let quote = make_quote(bid, ask);
1521        let result = derive_market_order_price(&quote, is_buy, price_decimals);
1522        let expected_dec = Decimal::from_str(expected).unwrap();
1523        assert_eq!(result, expected_dec);
1524
1525        // Verify the result matches the full pipeline manually
1526        let base = if is_buy {
1527            quote.ask_price.as_decimal()
1528        } else {
1529            quote.bid_price.as_decimal()
1530        };
1531        let derived = derive_limit_from_trigger(base, is_buy);
1532        let sig_rounded = round_to_sig_figs(derived, 5);
1533        let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1534        assert_eq!(result, pipeline);
1535
1536        // Must not have trailing zeros after decimal point
1537        let s = result.to_string();
1538        if s.contains('.') {
1539            assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
1540        }
1541
1542        // Sig figs must not exceed 5
1543        let sig_count = count_sig_figs(&s);
1544        assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
1545
1546        // Decimal places must not exceed instrument precision
1547        let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
1548        assert!(
1549            actual_decimals <= price_decimals as usize,
1550            "Price {s} has {actual_decimals} decimals, max {price_decimals}",
1551        );
1552    }
1553}