nautilus_hyperliquid/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 comprehensive 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//! Currently, Hyperliquid uses **last traded price** for all trigger evaluations.
60//!
61//! Future enhancement: Add support for mark/index price triggers if Hyperliquid API adds this feature.
62//! See OKX's `OKXTriggerType` and Bybit's `BybitTriggerType` for reference implementations.
63//!
64//! ## Examples
65//!
66//! ### Stop Loss Order
67//! ```ignore
68//! // Long position at $100, stop loss at $95
69//! let order = StopMarket {
70//!     side: Sell,
71//!     trigger_price: $95,
72//!     // ... other fields
73//! };
74//! // Maps to: Trigger { is_market: true, trigger_px: $95, tpsl: Sl }
75//! ```
76//!
77//! ### Take Profit Order
78//! ```ignore
79//! // Long position at $100, take profit at $110
80//! let order = MarketIfTouched {
81//!     side: Sell,
82//!     trigger_price: $110,
83//!     // ... other fields
84//! };
85//! // Maps to: Trigger { is_market: true, trigger_px: $110, tpsl: Tp }
86//! ```
87//!
88//! ## Integration with Other Adapters
89//!
90//! This implementation reuses patterns from:
91//! - **OKX**: Conditional order types and algo order API structure
92//! - **Bybit**: TP/SL mode detection and trigger direction logic
93//! - **BitMEX**: Stop order handling and trigger price validation
94//!
95//! See:
96//! - `crates/adapters/okx/src/common/consts.rs` - OKX_CONDITIONAL_ORDER_TYPES
97//! - `crates/adapters/bybit/src/common/enums.rs` - BybitStopOrderType, BybitTriggerType
98//! - `crates/adapters/bitmex/src/execution/mod.rs` - trigger_price handling
99
100use std::str::FromStr;
101
102use anyhow::Context;
103use nautilus_model::{
104    enums::{OrderSide, OrderStatus, OrderType, TimeInForce},
105    identifiers::{InstrumentId, Symbol, Venue},
106    orders::{Order, any::OrderAny},
107    types::{AccountBalance, Currency, MarginBalance, Money},
108};
109use rust_decimal::Decimal;
110use serde::{Deserialize, Deserializer, Serializer};
111use serde_json::Value;
112
113use crate::http::models::{
114    AssetId, Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
115    HyperliquidExecCancelByCloidRequest, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
116    HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
117    HyperliquidExecTriggerParams,
118};
119
120/// Serializes decimal as string (lossless, no scientific notation).
121pub fn serialize_decimal_as_str<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
122where
123    S: Serializer,
124{
125    serializer.serialize_str(&decimal.normalize().to_string())
126}
127
128/// Deserializes decimal from string only (reject numbers to avoid precision loss).
129pub fn deserialize_decimal_from_str<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
130where
131    D: Deserializer<'de>,
132{
133    let s = String::deserialize(deserializer)?;
134    Decimal::from_str(&s).map_err(serde::de::Error::custom)
135}
136
137/// Serialize optional decimal as string
138pub fn serialize_optional_decimal_as_str<S>(
139    decimal: &Option<Decimal>,
140    serializer: S,
141) -> Result<S::Ok, S::Error>
142where
143    S: Serializer,
144{
145    match decimal {
146        Some(d) => serializer.serialize_str(&d.normalize().to_string()),
147        None => serializer.serialize_none(),
148    }
149}
150
151/// Deserialize optional decimal from string
152pub fn deserialize_optional_decimal_from_str<'de, D>(
153    deserializer: D,
154) -> Result<Option<Decimal>, D::Error>
155where
156    D: Deserializer<'de>,
157{
158    let opt = Option::<String>::deserialize(deserializer)?;
159    match opt {
160        Some(s) => {
161            let decimal = Decimal::from_str(&s).map_err(serde::de::Error::custom)?;
162            Ok(Some(decimal))
163        }
164        None => Ok(None),
165    }
166}
167
168/// Serialize vector of decimals as strings
169pub fn serialize_vec_decimal_as_str<S>(
170    decimals: &Vec<Decimal>,
171    serializer: S,
172) -> Result<S::Ok, S::Error>
173where
174    S: Serializer,
175{
176    use serde::ser::SerializeSeq;
177    let mut seq = serializer.serialize_seq(Some(decimals.len()))?;
178    for decimal in decimals {
179        seq.serialize_element(&decimal.normalize().to_string())?;
180    }
181    seq.end()
182}
183
184/// Deserialize vector of decimals from strings
185pub fn deserialize_vec_decimal_from_str<'de, D>(deserializer: D) -> Result<Vec<Decimal>, D::Error>
186where
187    D: Deserializer<'de>,
188{
189    let strings = Vec::<String>::deserialize(deserializer)?;
190    strings
191        .into_iter()
192        .map(|s| Decimal::from_str(&s).map_err(serde::de::Error::custom))
193        .collect()
194}
195
196////////////////////////////////////////////////////////////////////////////////
197// Normalization and Validation Functions
198////////////////////////////////////////////////////////////////////////////////
199
200/// Round price down to the nearest valid tick size
201#[inline]
202pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
203    if tick_size.is_zero() {
204        return price;
205    }
206    (price / tick_size).floor() * tick_size
207}
208
209/// Round quantity down to the nearest valid step size
210#[inline]
211pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
212    if step_size.is_zero() {
213        return qty;
214    }
215    (qty / step_size).floor() * step_size
216}
217
218/// Ensure the notional value meets minimum requirements
219#[inline]
220pub fn ensure_min_notional(
221    price: Decimal,
222    qty: Decimal,
223    min_notional: Decimal,
224) -> Result<(), String> {
225    let notional = price * qty;
226    if notional < min_notional {
227        Err(format!(
228            "Notional value {} is less than minimum required {}",
229            notional, min_notional
230        ))
231    } else {
232        Ok(())
233    }
234}
235
236/// Normalize price to the specified number of decimal places
237pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
238    let scale = Decimal::from(10_u64.pow(decimals as u32));
239    (price * scale).floor() / scale
240}
241
242/// Normalize quantity to the specified number of decimal places
243pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
244    let scale = Decimal::from(10_u64.pow(decimals as u32));
245    (qty * scale).floor() / scale
246}
247
248/// Complete normalization for an order including price, quantity, and notional validation
249pub fn normalize_order(
250    price: Decimal,
251    qty: Decimal,
252    tick_size: Decimal,
253    step_size: Decimal,
254    min_notional: Decimal,
255    price_decimals: u8,
256    size_decimals: u8,
257) -> Result<(Decimal, Decimal), String> {
258    // Normalize to decimal places first
259    let normalized_price = normalize_price(price, price_decimals);
260    let normalized_qty = normalize_quantity(qty, size_decimals);
261
262    // Round down to tick/step sizes
263    let final_price = round_down_to_tick(normalized_price, tick_size);
264    let final_qty = round_down_to_step(normalized_qty, step_size);
265
266    // Validate minimum notional
267    ensure_min_notional(final_price, final_qty, min_notional)?;
268
269    Ok((final_price, final_qty))
270}
271
272// ================================================================================================
273// Order Conversion Functions
274// ================================================================================================
275
276/// Converts a Nautilus `TimeInForce` to Hyperliquid TIF.
277///
278/// # Errors
279///
280/// Returns an error if the time in force is not supported.
281pub fn time_in_force_to_hyperliquid_tif(
282    tif: TimeInForce,
283    is_post_only: bool,
284) -> anyhow::Result<HyperliquidExecTif> {
285    match (tif, is_post_only) {
286        (_, true) => Ok(HyperliquidExecTif::Alo), // Always use ALO for post-only orders
287        (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
288        (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
289        (TimeInForce::Fok, false) => Ok(HyperliquidExecTif::Ioc), // FOK maps to IOC in Hyperliquid
290        _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
291    }
292}
293
294/// Extracts asset ID from instrument symbol.
295///
296/// For Hyperliquid, this typically involves parsing the symbol to get the underlying asset.
297/// Currently supports a hardcoded mapping for common assets.
298///
299/// # Errors
300///
301/// Returns an error if the symbol format is unsupported or the asset is not found.
302pub fn extract_asset_id_from_symbol(symbol: &str) -> anyhow::Result<AssetId> {
303    // For perpetuals, remove "-USD-PERP" or "-USD" suffix to get the base asset
304    let base = if let Some(base) = symbol.strip_suffix("-PERP") {
305        // Remove "-USD-PERP" -> Remove "-USD" from what remains
306        base.strip_suffix("-USD")
307            .ok_or_else(|| anyhow::anyhow!("Cannot extract asset from symbol: {symbol}"))?
308    } else if let Some(base) = symbol.strip_suffix("-USD") {
309        // Just "-USD" suffix
310        base
311    } else {
312        anyhow::bail!("Cannot extract asset ID from symbol: {symbol}")
313    };
314
315    // Convert symbol like "BTC" to asset index
316    // Asset indices from Hyperliquid testnet meta endpoint (as of October 2025)
317    // Source: https://api.hyperliquid-testnet.xyz/info
318    //
319    // NOTE: These indices may change. For production, consider querying the meta endpoint
320    // dynamically during initialization to avoid hardcoded mappings.
321    Ok(match base {
322        "SOL" => 0,    // Solana
323        "APT" => 1,    // Aptos
324        "ATOM" => 2,   // Cosmos
325        "BTC" => 3,    // Bitcoin
326        "ETH" => 4,    // Ethereum
327        "MATIC" => 5,  // Polygon
328        "BNB" => 6,    // Binance Coin
329        "AVAX" => 7,   // Avalanche
330        "DYDX" => 9,   // dYdX
331        "APE" => 10,   // ApeCoin
332        "OP" => 11,    // Optimism
333        "kPEPE" => 12, // Pepe (1k units)
334        "ARB" => 13,   // Arbitrum
335        "kSHIB" => 29, // Shiba Inu (1k units)
336        "WIF" => 78,   // Dogwifhat
337        "DOGE" => 173, // Dogecoin
338        _ => {
339            // For unknown assets, query the meta endpoint or add to this mapping
340            anyhow::bail!("Asset ID mapping not found for symbol: {symbol}")
341        }
342    })
343}
344
345/// Determines if a trigger order should be TP (take profit) or SL (stop loss).
346///
347/// Logic follows exchange patterns from OKX/Bybit:
348/// - For BUY orders: trigger above current price = SL, below = TP
349/// - For SELL orders: trigger below current price = SL, above = TP
350/// - For Market/Limit If Touched orders: always TP (triggered when price reaches target)
351///
352/// # Note
353///
354/// Hyperliquid's trigger logic:
355/// - StopMarket/StopLimit: Protective stops (SL)
356/// - MarketIfTouched/LimitIfTouched: Profit taking or entry orders (TP)
357fn determine_tpsl_type(
358    order_type: OrderType,
359    order_side: OrderSide,
360    trigger_price: Decimal,
361    current_price: Option<Decimal>,
362) -> HyperliquidExecTpSl {
363    match order_type {
364        // Stop orders are protective - always SL
365        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
366
367        // If Touched orders are profit-taking or entry orders - always TP
368        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
369
370        // For other trigger types, try to infer from price relationship if available
371        _ => {
372            if let Some(current) = current_price {
373                match order_side {
374                    OrderSide::Buy => {
375                        // Buy order: trigger above market = stop loss, below = take profit
376                        if trigger_price > current {
377                            HyperliquidExecTpSl::Sl
378                        } else {
379                            HyperliquidExecTpSl::Tp
380                        }
381                    }
382                    OrderSide::Sell => {
383                        // Sell order: trigger below market = stop loss, above = take profit
384                        if trigger_price < current {
385                            HyperliquidExecTpSl::Sl
386                        } else {
387                            HyperliquidExecTpSl::Tp
388                        }
389                    }
390                    _ => HyperliquidExecTpSl::Sl, // Default to SL for safety
391                }
392            } else {
393                // No market price available, default to SL for safety
394                HyperliquidExecTpSl::Sl
395            }
396        }
397    }
398}
399
400/// Converts a Nautilus order into a Hyperliquid order request.
401///
402/// # Supported Order Types
403///
404/// - `Market`: Implemented as IOC limit order
405/// - `Limit`: Standard limit order with TIF (GTC/IOC/ALO)
406/// - `StopMarket`: Trigger order with market execution (protective stop)
407/// - `StopLimit`: Trigger order with limit price (protective stop)
408/// - `MarketIfTouched`: Trigger order with market execution (profit taking/entry)
409/// - `LimitIfTouched`: Trigger order with limit price (profit taking/entry)
410///
411/// # Conditional Order Patterns
412///
413/// Following patterns from OKX and Bybit adapters:
414/// - Stop orders (StopMarket/StopLimit) use `tpsl: Sl`
415/// - If Touched orders (MIT/LIT) use `tpsl: Tp`
416/// - Trigger price determines when order activates
417/// - Order side and trigger price relationship determines TP vs SL semantics
418///
419/// # Trigger Type Support
420///
421/// Hyperliquid currently uses last traded price for all triggers.
422/// Future enhancement: Add support for mark/index price triggers if Hyperliquid API supports it.
423pub fn order_to_hyperliquid_request(
424    order: &OrderAny,
425) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
426    let instrument_id = order.instrument_id();
427    let symbol = instrument_id.symbol.as_str();
428    let asset = extract_asset_id_from_symbol(symbol)
429        .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
430
431    let is_buy = matches!(order.order_side(), OrderSide::Buy);
432    let reduce_only = order.is_reduce_only();
433    let order_side = order.order_side();
434    let order_type = order.order_type();
435
436    // Convert price to decimal
437    let price_decimal = match order.price() {
438        Some(price) => Decimal::from_str_exact(&price.to_string())
439            .with_context(|| format!("Failed to convert price to decimal: {}", price))?,
440        None => {
441            // For market orders without price, use 0 as placeholder
442            // The actual market price will be determined by the exchange
443            if matches!(
444                order_type,
445                OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
446            ) {
447                Decimal::ZERO
448            } else {
449                anyhow::bail!("Limit orders require a price")
450            }
451        }
452    };
453
454    // Convert size to decimal
455    let size_decimal =
456        Decimal::from_str_exact(&order.quantity().to_string()).with_context(|| {
457            format!(
458                "Failed to convert quantity to decimal: {}",
459                order.quantity()
460            )
461        })?;
462
463    // Determine order kind based on order type
464    let kind = match order_type {
465        OrderType::Market => {
466            // Market orders in Hyperliquid are implemented as limit orders with IOC time-in-force
467            HyperliquidExecOrderKind::Limit {
468                limit: HyperliquidExecLimitParams {
469                    tif: HyperliquidExecTif::Ioc,
470                },
471            }
472        }
473        OrderType::Limit => {
474            let tif =
475                time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
476            HyperliquidExecOrderKind::Limit {
477                limit: HyperliquidExecLimitParams { tif },
478            }
479        }
480        OrderType::StopMarket => {
481            if let Some(trigger_price) = order.trigger_price() {
482                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
483                    .with_context(|| {
484                        format!(
485                            "Failed to convert trigger price to decimal: {}",
486                            trigger_price
487                        )
488                    })?;
489
490                // Determine TP/SL based on order semantics
491                let tpsl = determine_tpsl_type(
492                    order_type,
493                    order_side,
494                    trigger_price_decimal,
495                    None, // Current market price not available here
496                );
497
498                HyperliquidExecOrderKind::Trigger {
499                    trigger: HyperliquidExecTriggerParams {
500                        is_market: true,
501                        trigger_px: trigger_price_decimal,
502                        tpsl,
503                    },
504                }
505            } else {
506                anyhow::bail!("Stop market orders require a trigger price")
507            }
508        }
509        OrderType::StopLimit => {
510            if let Some(trigger_price) = order.trigger_price() {
511                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
512                    .with_context(|| {
513                        format!(
514                            "Failed to convert trigger price to decimal: {}",
515                            trigger_price
516                        )
517                    })?;
518
519                // Determine TP/SL based on order semantics
520                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
521
522                HyperliquidExecOrderKind::Trigger {
523                    trigger: HyperliquidExecTriggerParams {
524                        is_market: false,
525                        trigger_px: trigger_price_decimal,
526                        tpsl,
527                    },
528                }
529            } else {
530                anyhow::bail!("Stop limit orders require a trigger price")
531            }
532        }
533        OrderType::MarketIfTouched => {
534            // MIT orders trigger when price is reached and execute at market
535            // These are typically used for profit taking or entry orders
536            if let Some(trigger_price) = order.trigger_price() {
537                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
538                    .with_context(|| {
539                        format!(
540                            "Failed to convert trigger price to decimal: {}",
541                            trigger_price
542                        )
543                    })?;
544
545                HyperliquidExecOrderKind::Trigger {
546                    trigger: HyperliquidExecTriggerParams {
547                        is_market: true,
548                        trigger_px: trigger_price_decimal,
549                        tpsl: HyperliquidExecTpSl::Tp, // MIT is typically for profit taking
550                    },
551                }
552            } else {
553                anyhow::bail!("Market-if-touched orders require a trigger price")
554            }
555        }
556        OrderType::LimitIfTouched => {
557            // LIT orders trigger when price is reached and execute at limit price
558            // These are typically used for profit taking or entry orders with price control
559            if let Some(trigger_price) = order.trigger_price() {
560                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
561                    .with_context(|| {
562                        format!(
563                            "Failed to convert trigger price to decimal: {}",
564                            trigger_price
565                        )
566                    })?;
567
568                HyperliquidExecOrderKind::Trigger {
569                    trigger: HyperliquidExecTriggerParams {
570                        is_market: false,
571                        trigger_px: trigger_price_decimal,
572                        tpsl: HyperliquidExecTpSl::Tp, // LIT is typically for profit taking
573                    },
574                }
575            } else {
576                anyhow::bail!("Limit-if-touched orders require a trigger price")
577            }
578        }
579        _ => anyhow::bail!("Unsupported order type for Hyperliquid: {:?}", order_type),
580    };
581
582    // Convert client order ID to CLOID
583    let cloid = match Cloid::from_hex(order.client_order_id()) {
584        Ok(cloid) => Some(cloid),
585        Err(e) => {
586            anyhow::bail!(
587                "Failed to convert client order ID '{}' to CLOID: {}",
588                order.client_order_id(),
589                e
590            )
591        }
592    };
593
594    Ok(HyperliquidExecPlaceOrderRequest {
595        asset,
596        is_buy,
597        price: price_decimal,
598        size: size_decimal,
599        reduce_only,
600        kind,
601        cloid,
602    })
603}
604
605/// Converts a list of Nautilus orders into Hyperliquid order requests.
606pub fn orders_to_hyperliquid_requests(
607    orders: &[&OrderAny],
608) -> anyhow::Result<Vec<HyperliquidExecPlaceOrderRequest>> {
609    orders
610        .iter()
611        .map(|order| order_to_hyperliquid_request(order))
612        .collect()
613}
614
615/// Creates a JSON value representing multiple orders for the Hyperliquid exchange action.
616pub fn orders_to_hyperliquid_action_value(orders: &[&OrderAny]) -> anyhow::Result<Value> {
617    let requests = orders_to_hyperliquid_requests(orders)?;
618    serde_json::to_value(requests).context("Failed to serialize orders to JSON")
619}
620
621/// Converts an OrderAny into a Hyperliquid order request.
622pub fn order_any_to_hyperliquid_request(
623    order: &OrderAny,
624) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
625    order_to_hyperliquid_request(order)
626}
627
628/// Converts a client order ID to a Hyperliquid cancel request.
629///
630/// # Errors
631///
632/// Returns an error if the symbol cannot be parsed or the client order ID is invalid.
633pub fn client_order_id_to_cancel_request(
634    client_order_id: &str,
635    symbol: &str,
636) -> anyhow::Result<HyperliquidExecCancelByCloidRequest> {
637    let asset = extract_asset_id_from_symbol(symbol)
638        .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
639
640    let cloid = Cloid::from_hex(client_order_id).map_err(|e| {
641        anyhow::anyhow!(
642            "Failed to convert client order ID '{}' to CLOID: {}",
643            client_order_id,
644            e
645        )
646    })?;
647
648    Ok(HyperliquidExecCancelByCloidRequest { asset, cloid })
649}
650
651/// Checks if a Hyperliquid exchange response indicates success.
652pub fn is_response_successful(response: &HyperliquidExchangeResponse) -> bool {
653    matches!(response, HyperliquidExchangeResponse::Status { status, .. } if status == "ok")
654}
655
656/// Extracts error message from a Hyperliquid exchange response.
657pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
658    match response {
659        HyperliquidExchangeResponse::Status { status, response } => {
660            if status == "ok" {
661                "Operation successful".to_string()
662            } else {
663                // Try to extract error message from response data
664                if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
665                    error_msg.to_string()
666                } else {
667                    format!("Request failed with status: {}", status)
668                }
669            }
670        }
671        HyperliquidExchangeResponse::Error { error } => error.clone(),
672    }
673}
674
675/// Determines if an order is a conditional/trigger order based on order data.
676///
677/// # Arguments
678///
679/// * `trigger_px` - Optional trigger price
680/// * `tpsl` - Optional TP/SL indicator
681///
682/// # Returns
683///
684/// `true` if the order is a conditional order, `false` otherwise.
685pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&str>) -> bool {
686    trigger_px.is_some() && tpsl.is_some()
687}
688
689/// Parses trigger order type from Hyperliquid order data.
690///
691/// # Arguments
692///
693/// * `is_market` - Whether this is a market trigger order
694/// * `tpsl` - TP/SL indicator ("tp" or "sl")
695///
696/// # Returns
697///
698/// The corresponding Nautilus `OrderType`.
699pub fn parse_trigger_order_type(is_market: bool, tpsl: &str) -> OrderType {
700    match (is_market, tpsl) {
701        (true, "sl") => OrderType::StopMarket,
702        (false, "sl") => OrderType::StopLimit,
703        (true, "tp") => OrderType::MarketIfTouched,
704        (false, "tp") => OrderType::LimitIfTouched,
705        _ => OrderType::StopMarket, // Default fallback
706    }
707}
708
709/// Extracts order status from WebSocket order data.
710///
711/// # Arguments
712///
713/// * `status` - Status string from WebSocket
714/// * `trigger_activated` - Whether trigger has been activated (for conditional orders)
715///
716/// # Returns
717///
718/// A tuple of (OrderStatus, optional trigger status string).
719pub fn parse_order_status_with_trigger(
720    status: &str,
721    trigger_activated: Option<bool>,
722) -> (OrderStatus, Option<String>) {
723    use crate::common::enums::hyperliquid_status_to_order_status;
724
725    let base_status = hyperliquid_status_to_order_status(status);
726
727    // For conditional orders, add trigger status information
728    if let Some(activated) = trigger_activated {
729        let trigger_status = if activated {
730            Some("activated".to_string())
731        } else {
732            Some("pending".to_string())
733        };
734        (base_status, trigger_status)
735    } else {
736        (base_status, None)
737    }
738}
739
740/// Converts WebSocket trailing stop data to description string.
741///
742/// # Arguments
743///
744/// * `offset` - Trailing offset value
745/// * `offset_type` - Type of offset ("price", "percentage", "basisPoints")
746/// * `callback_price` - Current callback price
747///
748/// # Returns
749///
750/// Human-readable description of trailing stop parameters.
751pub fn format_trailing_stop_info(
752    offset: &str,
753    offset_type: &str,
754    callback_price: Option<&str>,
755) -> String {
756    let offset_desc = match offset_type {
757        "percentage" => format!("{}%", offset),
758        "basisPoints" => format!("{} bps", offset),
759        "price" => offset.to_string(),
760        _ => offset.to_string(),
761    };
762
763    if let Some(callback) = callback_price {
764        format!(
765            "Trailing stop: {} offset, callback at {}",
766            offset_desc, callback
767        )
768    } else {
769        format!("Trailing stop: {} offset", offset_desc)
770    }
771}
772
773/// Validates conditional order parameters from WebSocket data.
774///
775/// # Arguments
776///
777/// * `trigger_px` - Trigger price
778/// * `tpsl` - TP/SL indicator
779/// * `is_market` - Market or limit flag
780///
781/// # Returns
782///
783/// `Ok(())` if parameters are valid, `Err` with description otherwise.
784///
785/// # Panics
786///
787/// This function does not panic - it returns errors instead of panicking.
788pub fn validate_conditional_order_params(
789    trigger_px: Option<&str>,
790    tpsl: Option<&str>,
791    is_market: Option<bool>,
792) -> anyhow::Result<()> {
793    if trigger_px.is_none() {
794        anyhow::bail!("Conditional order missing trigger price");
795    }
796
797    if tpsl.is_none() {
798        anyhow::bail!("Conditional order missing tpsl indicator");
799    }
800
801    let tpsl_value = tpsl.expect("tpsl should be Some at this point");
802    if tpsl_value != "tp" && tpsl_value != "sl" {
803        anyhow::bail!("Invalid tpsl value: {}", tpsl_value);
804    }
805
806    if is_market.is_none() {
807        anyhow::bail!("Conditional order missing is_market flag");
808    }
809
810    Ok(())
811}
812
813/// Parses trigger price from string to Decimal.
814///
815/// # Arguments
816///
817/// * `trigger_px` - Trigger price as string
818///
819/// # Returns
820///
821/// Parsed Decimal value or error.
822pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
823    Decimal::from_str_exact(trigger_px)
824        .with_context(|| format!("Failed to parse trigger price: {}", trigger_px))
825}
826
827/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
828///
829/// # Errors
830///
831/// Returns an error if the data cannot be parsed.
832pub fn parse_account_balances_and_margins(
833    cross_margin_summary: &CrossMarginSummary,
834) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
835    let mut balances = Vec::new();
836    let mut margins = Vec::new();
837
838    // Parse balance from cross margin summary
839    let currency = Currency::USD(); // Hyperliquid uses USDC/USD
840
841    // Account value represents total collateral
842    let total_value = cross_margin_summary
843        .account_value
844        .to_string()
845        .parse::<f64>()?;
846
847    // Withdrawable represents available balance
848    let withdrawable = cross_margin_summary
849        .withdrawable
850        .to_string()
851        .parse::<f64>()?;
852
853    // Total margin used is locked in positions
854    let margin_used = cross_margin_summary
855        .total_margin_used
856        .to_string()
857        .parse::<f64>()?;
858
859    // Calculate total, locked, and free
860    let total = Money::new(total_value, currency);
861    let locked = Money::new(margin_used, currency);
862    let free = Money::new(withdrawable, currency);
863
864    let balance = AccountBalance::new(total, locked, free);
865    balances.push(balance);
866
867    // Create margin balance for the account
868    // Initial margin = margin used (locked in positions)
869    // Maintenance margin can be approximated from leverage and position values
870    // For now, use margin_used as both initial and maintenance (conservative)
871    if margin_used > 0.0 {
872        let margin_instrument_id =
873            InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
874
875        let initial_margin = Money::new(margin_used, currency);
876        let maintenance_margin = Money::new(margin_used, currency);
877
878        let margin_balance =
879            MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
880
881        margins.push(margin_balance);
882    }
883
884    Ok((balances, margins))
885}
886
887////////////////////////////////////////////////////////////////////////////////
888// Tests
889////////////////////////////////////////////////////////////////////////////////
890
891#[cfg(test)]
892mod tests {
893    use rstest::rstest;
894    use serde::{Deserialize, Serialize};
895
896    use super::*;
897
898    #[derive(Serialize, Deserialize)]
899    struct TestStruct {
900        #[serde(
901            serialize_with = "serialize_decimal_as_str",
902            deserialize_with = "deserialize_decimal_from_str"
903        )]
904        value: Decimal,
905        #[serde(
906            serialize_with = "serialize_optional_decimal_as_str",
907            deserialize_with = "deserialize_optional_decimal_from_str"
908        )]
909        optional_value: Option<Decimal>,
910    }
911
912    #[rstest]
913    fn test_decimal_serialization_roundtrip() {
914        let original = TestStruct {
915            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
916            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
917        };
918
919        let json = serde_json::to_string(&original).unwrap();
920        println!("Serialized: {}", json);
921
922        // Check that it's serialized as strings (rust_decimal may normalize precision)
923        assert!(json.contains("\"123.45678901234567890123456789\""));
924        assert!(json.contains("\"0.000000001\""));
925
926        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
927        assert_eq!(original.value, deserialized.value);
928        assert_eq!(original.optional_value, deserialized.optional_value);
929    }
930
931    #[rstest]
932    fn test_decimal_precision_preservation() {
933        let test_cases = [
934            "0",
935            "1",
936            "0.1",
937            "0.01",
938            "0.001",
939            "123.456789012345678901234567890",
940            "999999999999999999.999999999999999999",
941        ];
942
943        for case in test_cases {
944            let decimal = Decimal::from_str(case).unwrap();
945            let test_struct = TestStruct {
946                value: decimal,
947                optional_value: Some(decimal),
948            };
949
950            let json = serde_json::to_string(&test_struct).unwrap();
951            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
952
953            assert_eq!(decimal, parsed.value, "Failed for case: {}", case);
954            assert_eq!(
955                Some(decimal),
956                parsed.optional_value,
957                "Failed for case: {}",
958                case
959            );
960        }
961    }
962
963    #[rstest]
964    fn test_optional_none_handling() {
965        let test_struct = TestStruct {
966            value: Decimal::from_str("42.0").unwrap(),
967            optional_value: None,
968        };
969
970        let json = serde_json::to_string(&test_struct).unwrap();
971        assert!(json.contains("null"));
972
973        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
974        assert_eq!(test_struct.value, parsed.value);
975        assert_eq!(None, parsed.optional_value);
976    }
977
978    #[rstest]
979    fn test_round_down_to_tick() {
980        use rust_decimal_macros::dec;
981
982        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
983        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
984        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
985
986        // Edge case: zero tick size
987        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
988    }
989
990    #[rstest]
991    fn test_round_down_to_step() {
992        use rust_decimal_macros::dec;
993
994        assert_eq!(
995            round_down_to_step(dec!(0.12349), dec!(0.0001)),
996            dec!(0.1234)
997        );
998        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
999        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1000
1001        // Edge case: zero step size
1002        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1003    }
1004
1005    #[rstest]
1006    fn test_min_notional_validation() {
1007        use rust_decimal_macros::dec;
1008
1009        // Should pass
1010        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1011        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1012
1013        // Should fail
1014        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1015        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1016
1017        // Edge case: exactly at minimum
1018        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1019    }
1020
1021    #[rstest]
1022    fn test_normalize_price() {
1023        use rust_decimal_macros::dec;
1024
1025        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1026        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.19));
1027        assert_eq!(normalize_price(dec!(100.999), 0), dec!(100));
1028        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.1234));
1029    }
1030
1031    #[rstest]
1032    fn test_normalize_quantity() {
1033        use rust_decimal_macros::dec;
1034
1035        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1036        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1037        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1038        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1039    }
1040
1041    #[rstest]
1042    fn test_normalize_order_complete() {
1043        use rust_decimal_macros::dec;
1044
1045        let result = normalize_order(
1046            dec!(100.12345), // price
1047            dec!(0.123456),  // qty
1048            dec!(0.01),      // tick_size
1049            dec!(0.0001),    // step_size
1050            dec!(10),        // min_notional
1051            2,               // price_decimals
1052            4,               // size_decimals
1053        );
1054
1055        assert!(result.is_ok());
1056        let (price, qty) = result.unwrap();
1057        assert_eq!(price, dec!(100.12)); // normalized and rounded down
1058        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
1059    }
1060
1061    #[rstest]
1062    fn test_normalize_order_min_notional_fail() {
1063        use rust_decimal_macros::dec;
1064
1065        let result = normalize_order(
1066            dec!(100.12345), // price
1067            dec!(0.05),      // qty (too small for min notional)
1068            dec!(0.01),      // tick_size
1069            dec!(0.0001),    // step_size
1070            dec!(10),        // min_notional
1071            2,               // price_decimals
1072            4,               // size_decimals
1073        );
1074
1075        assert!(result.is_err());
1076        assert!(result.unwrap_err().contains("Notional value"));
1077    }
1078
1079    #[rstest]
1080    fn test_edge_cases() {
1081        use rust_decimal_macros::dec;
1082
1083        // Test with very small numbers
1084        assert_eq!(
1085            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1086            dec!(0.000001)
1087        );
1088
1089        // Test with large numbers
1090        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1091
1092        // Test rounding edge case
1093        assert_eq!(
1094            round_down_to_tick(dec!(100.009999), dec!(0.01)),
1095            dec!(100.00)
1096        );
1097    }
1098
1099    // ========================================================================
1100    // Conditional Order Parsing Tests
1101    // ========================================================================
1102
1103    #[rstest]
1104    fn test_is_conditional_order_data() {
1105        // Test with trigger price and tpsl (conditional)
1106        assert!(is_conditional_order_data(Some("50000.0"), Some("sl")));
1107
1108        // Test with only trigger price (not conditional - needs both)
1109        assert!(!is_conditional_order_data(Some("50000.0"), None));
1110
1111        // Test with only tpsl (not conditional - needs both)
1112        assert!(!is_conditional_order_data(None, Some("tp")));
1113
1114        // Test with no conditional fields
1115        assert!(!is_conditional_order_data(None, None));
1116    }
1117
1118    #[rstest]
1119    fn test_parse_trigger_order_type() {
1120        // Stop Market
1121        assert_eq!(parse_trigger_order_type(true, "sl"), OrderType::StopMarket);
1122
1123        // Stop Limit
1124        assert_eq!(parse_trigger_order_type(false, "sl"), OrderType::StopLimit);
1125
1126        // Take Profit Market
1127        assert_eq!(
1128            parse_trigger_order_type(true, "tp"),
1129            OrderType::MarketIfTouched
1130        );
1131
1132        // Take Profit Limit
1133        assert_eq!(
1134            parse_trigger_order_type(false, "tp"),
1135            OrderType::LimitIfTouched
1136        );
1137    }
1138
1139    #[rstest]
1140    fn test_parse_order_status_with_trigger() {
1141        // Test with open status and activated trigger
1142        let (status, trigger_status) = parse_order_status_with_trigger("open", Some(true));
1143        assert_eq!(status, OrderStatus::Accepted);
1144        assert_eq!(trigger_status, Some("activated".to_string()));
1145
1146        // Test with open status and not activated
1147        let (status, trigger_status) = parse_order_status_with_trigger("open", Some(false));
1148        assert_eq!(status, OrderStatus::Accepted);
1149        assert_eq!(trigger_status, Some("pending".to_string()));
1150
1151        // Test without trigger info
1152        let (status, trigger_status) = parse_order_status_with_trigger("open", None);
1153        assert_eq!(status, OrderStatus::Accepted);
1154        assert_eq!(trigger_status, None);
1155    }
1156
1157    #[rstest]
1158    fn test_format_trailing_stop_info() {
1159        // Price offset
1160        let info = format_trailing_stop_info("100.0", "price", Some("50000.0"));
1161        assert!(info.contains("100.0"));
1162        assert!(info.contains("callback at 50000.0"));
1163
1164        // Percentage offset
1165        let info = format_trailing_stop_info("5.0", "percentage", None);
1166        assert!(info.contains("5.0%"));
1167        assert!(info.contains("Trailing stop"));
1168
1169        // Basis points offset
1170        let info = format_trailing_stop_info("250", "basisPoints", Some("49000.0"));
1171        assert!(info.contains("250 bps"));
1172        assert!(info.contains("49000.0"));
1173    }
1174
1175    #[rstest]
1176    fn test_parse_trigger_price() {
1177        use rust_decimal_macros::dec;
1178
1179        // Valid price
1180        let result = parse_trigger_price("50000.0");
1181        assert!(result.is_ok());
1182        assert_eq!(result.unwrap(), dec!(50000.0));
1183
1184        // Valid integer price
1185        let result = parse_trigger_price("49000");
1186        assert!(result.is_ok());
1187        assert_eq!(result.unwrap(), dec!(49000));
1188
1189        // Invalid price
1190        let result = parse_trigger_price("invalid");
1191        assert!(result.is_err());
1192
1193        // Empty string
1194        let result = parse_trigger_price("");
1195        assert!(result.is_err());
1196    }
1197}