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