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