Skip to main content

nautilus_hyperliquid/common/
parse.rs

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