Skip to main content

nautilus_hyperliquid/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities that convert Hyperliquid payloads into Nautilus domain models.
17//!
18//! # Conditional Order Support
19//!
20//! This module implements 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//! Hyperliquid uses **mark price** for all trigger evaluations (TP/SL orders).
60
61use anyhow::Context;
62use nautilus_core::UnixNanos;
63pub use nautilus_core::serialization::{
64    deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
65    deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
66    serialize_vec_decimal_as_str,
67};
68use nautilus_model::{
69    data::bar::BarType,
70    enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType, TimeInForce},
71    identifiers::{ClientOrderId, InstrumentId, Symbol, TradeId, Venue},
72    orders::{Order, any::OrderAny},
73    types::{AccountBalance, Currency, MarginBalance, Money},
74};
75use rust_decimal::Decimal;
76
77use crate::{
78    common::enums::{
79        HyperliquidBarInterval::{self, *},
80        HyperliquidOrderStatus, HyperliquidTpSl,
81    },
82    http::models::{
83        Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
84        HyperliquidExecCancelByCloidRequest, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
85        HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
86        HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
87    },
88    websocket::messages::TrailingOffsetType,
89};
90
91/// Creates a deterministic [`TradeId`] from fill fields common to both WS and HTTP responses.
92///
93/// Uses FNV-1a hash of `(hash, oid, px, sz, time, start_position)` to produce a unique
94/// identifier consistent across both data sources for the same physical fill.
95/// Includes `start_position` (running position before each fill) to disambiguate
96/// multiple partial fills within the same transaction at the same price/size.
97/// Format: `{fnv_hex}-{oid_hex}` (exactly 33 chars, within 36-char limit).
98pub fn make_fill_trade_id(
99    hash: &str,
100    oid: u64,
101    px: &str,
102    sz: &str,
103    time: u64,
104    start_position: &str,
105) -> TradeId {
106    // FNV-1a with fixed seed for deterministic output
107    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
108    for &b in hash.as_bytes() {
109        h ^= b as u64;
110        h = h.wrapping_mul(0x0100_0000_01b3);
111    }
112    for b in oid.to_le_bytes() {
113        h ^= b as u64;
114        h = h.wrapping_mul(0x0100_0000_01b3);
115    }
116    for &b in px.as_bytes() {
117        h ^= b as u64;
118        h = h.wrapping_mul(0x0100_0000_01b3);
119    }
120    for &b in sz.as_bytes() {
121        h ^= b as u64;
122        h = h.wrapping_mul(0x0100_0000_01b3);
123    }
124    for b in time.to_le_bytes() {
125        h ^= b as u64;
126        h = h.wrapping_mul(0x0100_0000_01b3);
127    }
128    for &b in start_position.as_bytes() {
129        h ^= b as u64;
130        h = h.wrapping_mul(0x0100_0000_01b3);
131    }
132    TradeId::new(format!("{h:016x}-{oid:016x}"))
133}
134
135/// Round price down to the nearest valid tick size.
136#[inline]
137pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
138    if tick_size.is_zero() {
139        return price;
140    }
141    (price / tick_size).floor() * tick_size
142}
143
144/// Round quantity down to the nearest valid step size.
145#[inline]
146pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
147    if step_size.is_zero() {
148        return qty;
149    }
150    (qty / step_size).floor() * step_size
151}
152
153/// Ensure the notional value meets minimum requirements.
154#[inline]
155pub fn ensure_min_notional(
156    price: Decimal,
157    qty: Decimal,
158    min_notional: Decimal,
159) -> Result<(), String> {
160    let notional = price * qty;
161    if notional < min_notional {
162        Err(format!(
163            "Notional value {notional} is less than minimum required {min_notional}"
164        ))
165    } else {
166        Ok(())
167    }
168}
169
170/// Round a decimal to at most N significant figures.
171/// Hyperliquid requires prices to have at most 5 significant figures.
172pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
173    if value.is_zero() {
174        return Decimal::ZERO;
175    }
176
177    // Find order of magnitude using log10
178    let abs_val = value.abs();
179    let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
180    let magnitude = float_val.log10().floor() as i32;
181
182    // Calculate shift to round to sig_figs
183    let shift = sig_figs as i32 - 1 - magnitude;
184    let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
185
186    if shift >= 0 {
187        (value * factor).round() / factor
188    } else {
189        (value / factor).round() * factor
190    }
191}
192
193/// Normalize price to the specified number of decimal places.
194pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
195    // First round to 5 significant figures (Hyperliquid requirement)
196    let sig_fig_price = round_to_sig_figs(price, 5);
197    // Then truncate to max decimal places
198    let scale = Decimal::from(10_u64.pow(decimals as u32));
199    (sig_fig_price * scale).floor() / scale
200}
201
202/// Normalize quantity to the specified number of decimal places.
203pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
204    let scale = Decimal::from(10_u64.pow(decimals as u32));
205    (qty * scale).floor() / scale
206}
207
208/// Complete normalization for an order including price, quantity, and notional validation
209pub fn normalize_order(
210    price: Decimal,
211    qty: Decimal,
212    tick_size: Decimal,
213    step_size: Decimal,
214    min_notional: Decimal,
215    price_decimals: u8,
216    size_decimals: u8,
217) -> Result<(Decimal, Decimal), String> {
218    // Normalize to decimal places first
219    let normalized_price = normalize_price(price, price_decimals);
220    let normalized_qty = normalize_quantity(qty, size_decimals);
221
222    // Round down to tick/step sizes
223    let final_price = round_down_to_tick(normalized_price, tick_size);
224    let final_qty = round_down_to_step(normalized_qty, step_size);
225
226    // Validate minimum notional
227    ensure_min_notional(final_price, final_qty, min_notional)?;
228
229    Ok((final_price, final_qty))
230}
231
232/// Converts millisecond timestamp to [`UnixNanos`].
233#[inline]
234pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
235    let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
236    Ok(UnixNanos::from(value))
237}
238
239/// Converts a Nautilus `TimeInForce` to Hyperliquid TIF.
240///
241/// # Errors
242///
243/// Returns an error if the time in force is not supported.
244pub fn time_in_force_to_hyperliquid_tif(
245    tif: TimeInForce,
246    is_post_only: bool,
247) -> anyhow::Result<HyperliquidExecTif> {
248    match (tif, is_post_only) {
249        (_, true) => Ok(HyperliquidExecTif::Alo), // Always use ALO for post-only orders
250        (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
251        (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
252        (TimeInForce::Fok, false) => {
253            anyhow::bail!("FOK time in force is not supported by Hyperliquid")
254        }
255        _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
256    }
257}
258
259fn determine_tpsl_type(
260    order_type: OrderType,
261    order_side: OrderSide,
262    trigger_price: Decimal,
263    current_price: Option<Decimal>,
264) -> HyperliquidExecTpSl {
265    match order_type {
266        // Stop orders are protective - always SL
267        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
268
269        // If Touched orders are profit-taking or entry orders - always TP
270        OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
271
272        // For other trigger types, try to infer from price relationship if available
273        _ => {
274            if let Some(current) = current_price {
275                match order_side {
276                    OrderSide::Buy => {
277                        // Buy order: trigger above market = stop loss, below = take profit
278                        if trigger_price > current {
279                            HyperliquidExecTpSl::Sl
280                        } else {
281                            HyperliquidExecTpSl::Tp
282                        }
283                    }
284                    OrderSide::Sell => {
285                        // Sell order: trigger below market = stop loss, above = take profit
286                        if trigger_price < current {
287                            HyperliquidExecTpSl::Sl
288                        } else {
289                            HyperliquidExecTpSl::Tp
290                        }
291                    }
292                    _ => HyperliquidExecTpSl::Sl, // Default to SL for safety
293                }
294            } else {
295                // No market price available, default to SL for safety
296                HyperliquidExecTpSl::Sl
297            }
298        }
299    }
300}
301
302/// Converts a Nautilus `BarType` to a Hyperliquid bar interval.
303///
304/// # Errors
305///
306/// Returns an error if the bar type uses an unsupported aggregation or step value.
307pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
308    let spec = bar_type.spec();
309    let step = spec.step.get();
310
311    anyhow::ensure!(
312        bar_type.aggregation_source() == AggregationSource::External,
313        "Only EXTERNAL aggregation is supported"
314    );
315
316    let interval = match spec.aggregation {
317        BarAggregation::Minute => match step {
318            1 => OneMinute,
319            3 => ThreeMinutes,
320            5 => FiveMinutes,
321            15 => FifteenMinutes,
322            30 => ThirtyMinutes,
323            _ => anyhow::bail!("Unsupported minute step: {step}"),
324        },
325        BarAggregation::Hour => match step {
326            1 => OneHour,
327            2 => TwoHours,
328            4 => FourHours,
329            8 => EightHours,
330            12 => TwelveHours,
331            _ => anyhow::bail!("Unsupported hour step: {step}"),
332        },
333        BarAggregation::Day => match step {
334            1 => OneDay,
335            3 => ThreeDays,
336            _ => anyhow::bail!("Unsupported day step: {step}"),
337        },
338        BarAggregation::Week if step == 1 => OneWeek,
339        BarAggregation::Month if step == 1 => OneMonth,
340        a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
341    };
342
343    Ok(interval)
344}
345
346/// Converts a Nautilus order to Hyperliquid request using a pre-resolved asset index.
347///
348/// This variant is used when the caller has already resolved the asset index
349/// from the instrument cache (e.g., for SPOT instruments where the index
350/// cannot be derived from the symbol alone).
351pub fn order_to_hyperliquid_request_with_asset(
352    order: &OrderAny,
353    asset: u32,
354) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
355    let is_buy = matches!(order.order_side(), OrderSide::Buy);
356    let reduce_only = order.is_reduce_only();
357    let order_side = order.order_side();
358    let order_type = order.order_type();
359
360    // Normalize decimals to strip trailing zeros, matching the server's
361    // canonical form used for EIP-712 signing hash verification.
362    let price_decimal = match order.price() {
363        Some(price) => Decimal::from_str_exact(&price.to_string())
364            .with_context(|| format!("Failed to convert price to decimal: {price}"))?
365            .normalize(),
366        None => {
367            if matches!(
368                order_type,
369                OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
370            ) {
371                Decimal::ZERO
372            } else {
373                anyhow::bail!("Limit orders require a price")
374            }
375        }
376    };
377
378    let size_decimal = Decimal::from_str_exact(&order.quantity().to_string())
379        .with_context(|| {
380            format!(
381                "Failed to convert quantity to decimal: {}",
382                order.quantity()
383            )
384        })?
385        .normalize();
386
387    // Determine order kind based on order type
388    let kind = match order_type {
389        OrderType::Market => HyperliquidExecOrderKind::Limit {
390            limit: HyperliquidExecLimitParams {
391                tif: HyperliquidExecTif::Ioc,
392            },
393        },
394        OrderType::Limit => {
395            let tif =
396                time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
397            HyperliquidExecOrderKind::Limit {
398                limit: HyperliquidExecLimitParams { tif },
399            }
400        }
401        OrderType::StopMarket => {
402            if let Some(trigger_price) = order.trigger_price() {
403                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
404                    .with_context(|| {
405                        format!("Failed to convert trigger price to decimal: {trigger_price}")
406                    })?
407                    .normalize();
408                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
409                HyperliquidExecOrderKind::Trigger {
410                    trigger: HyperliquidExecTriggerParams {
411                        is_market: true,
412                        trigger_px: trigger_price_decimal,
413                        tpsl,
414                    },
415                }
416            } else {
417                anyhow::bail!("Stop market orders require a trigger price")
418            }
419        }
420        OrderType::StopLimit => {
421            if let Some(trigger_price) = order.trigger_price() {
422                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
423                    .with_context(|| {
424                        format!("Failed to convert trigger price to decimal: {trigger_price}")
425                    })?
426                    .normalize();
427                let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
428                HyperliquidExecOrderKind::Trigger {
429                    trigger: HyperliquidExecTriggerParams {
430                        is_market: false,
431                        trigger_px: trigger_price_decimal,
432                        tpsl,
433                    },
434                }
435            } else {
436                anyhow::bail!("Stop limit orders require a trigger price")
437            }
438        }
439        OrderType::MarketIfTouched => {
440            if let Some(trigger_price) = order.trigger_price() {
441                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
442                    .with_context(|| {
443                        format!("Failed to convert trigger price to decimal: {trigger_price}")
444                    })?
445                    .normalize();
446                HyperliquidExecOrderKind::Trigger {
447                    trigger: HyperliquidExecTriggerParams {
448                        is_market: true,
449                        trigger_px: trigger_price_decimal,
450                        tpsl: HyperliquidExecTpSl::Tp,
451                    },
452                }
453            } else {
454                anyhow::bail!("Market-if-touched orders require a trigger price")
455            }
456        }
457        OrderType::LimitIfTouched => {
458            if let Some(trigger_price) = order.trigger_price() {
459                let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
460                    .with_context(|| {
461                        format!("Failed to convert trigger price to decimal: {trigger_price}")
462                    })?
463                    .normalize();
464                HyperliquidExecOrderKind::Trigger {
465                    trigger: HyperliquidExecTriggerParams {
466                        is_market: false,
467                        trigger_px: trigger_price_decimal,
468                        tpsl: HyperliquidExecTpSl::Tp,
469                    },
470                }
471            } else {
472                anyhow::bail!("Limit-if-touched orders require a trigger price")
473            }
474        }
475        _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
476    };
477
478    // Convert client order ID to CLOID by hashing
479    let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
480
481    Ok(HyperliquidExecPlaceOrderRequest {
482        asset,
483        is_buy,
484        price: price_decimal,
485        size: size_decimal,
486        reduce_only,
487        kind,
488        cloid,
489    })
490}
491
492/// Converts a client order ID to a Hyperliquid cancel request using a pre-resolved asset index.
493pub fn client_order_id_to_cancel_request_with_asset(
494    client_order_id: &str,
495    asset: u32,
496) -> HyperliquidExecCancelByCloidRequest {
497    let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
498    HyperliquidExecCancelByCloidRequest { asset, cloid }
499}
500
501/// Extracts error message from a Hyperliquid exchange response.
502pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
503    match response {
504        HyperliquidExchangeResponse::Status { status, response } => {
505            if status == RESPONSE_STATUS_OK {
506                "Operation successful".to_string()
507            } else {
508                // Try to extract error message from response data
509                if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
510                    error_msg.to_string()
511                } else {
512                    format!("Request failed with status: {status}")
513                }
514            }
515        }
516        HyperliquidExchangeResponse::Error { error } => error.clone(),
517    }
518}
519
520/// Determines if an order is a conditional/trigger order based on order data.
521///
522/// # Returns
523///
524/// `true` if the order is a conditional order, `false` otherwise.
525pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
526    trigger_px.is_some() && tpsl.is_some()
527}
528
529/// Parses trigger order type from Hyperliquid order data.
530///
531/// # Returns
532///
533/// The corresponding Nautilus `OrderType`.
534pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
535    match (is_market, tpsl) {
536        (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
537        (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
538        (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
539        (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
540    }
541}
542
543/// Extracts order status from WebSocket order data.
544///
545/// # Returns
546///
547/// A tuple of (OrderStatus, optional trigger status string).
548pub fn parse_order_status_with_trigger(
549    status: HyperliquidOrderStatus,
550    trigger_activated: Option<bool>,
551) -> (OrderStatus, Option<String>) {
552    let base_status = OrderStatus::from(status);
553
554    // For conditional orders, add trigger status information
555    if let Some(activated) = trigger_activated {
556        let trigger_status = if activated {
557            Some("activated".to_string())
558        } else {
559            Some("pending".to_string())
560        };
561        (base_status, trigger_status)
562    } else {
563        (base_status, None)
564    }
565}
566
567/// Converts WebSocket trailing stop data to description string.
568pub fn format_trailing_stop_info(
569    offset: &str,
570    offset_type: TrailingOffsetType,
571    callback_price: Option<&str>,
572) -> String {
573    let offset_desc = offset_type.format_offset(offset);
574
575    if let Some(callback) = callback_price {
576        format!("Trailing stop: {offset_desc} offset, callback at {callback}")
577    } else {
578        format!("Trailing stop: {offset_desc} offset")
579    }
580}
581
582/// Validates conditional order parameters from WebSocket data.
583///
584/// # Returns
585///
586/// `Ok(())` if parameters are valid, `Err` with description otherwise.
587pub fn validate_conditional_order_params(
588    trigger_px: Option<&str>,
589    tpsl: Option<&HyperliquidTpSl>,
590    is_market: Option<bool>,
591) -> anyhow::Result<()> {
592    if trigger_px.is_none() {
593        anyhow::bail!("Conditional order missing trigger price");
594    }
595
596    if tpsl.is_none() {
597        anyhow::bail!("Conditional order missing tpsl indicator");
598    }
599
600    // No need to validate tpsl value - the enum type guarantees it's either Tp or Sl
601
602    if is_market.is_none() {
603        anyhow::bail!("Conditional order missing is_market flag");
604    }
605
606    Ok(())
607}
608
609/// Parses trigger price from string to Decimal.
610///
611/// # Returns
612///
613/// Parsed Decimal value or error.
614pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
615    Decimal::from_str_exact(trigger_px)
616        .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
617}
618
619/// Parses Hyperliquid clearinghouse state into Nautilus account balances and margins.
620///
621/// # Errors
622///
623/// Returns an error if the data cannot be parsed.
624pub fn parse_account_balances_and_margins(
625    cross_margin_summary: &CrossMarginSummary,
626) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
627    let mut balances = Vec::new();
628    let mut margins = Vec::new();
629
630    let currency = Currency::USDC();
631
632    let mut total_value = cross_margin_summary
633        .account_value
634        .to_string()
635        .parse::<f64>()?
636        .max(0.0);
637
638    let free_value = cross_margin_summary
639        .withdrawable
640        .map(|w| w.to_string().parse::<f64>())
641        .transpose()?
642        .unwrap_or(total_value)
643        .max(0.0);
644
645    // Ensure total >= free to satisfy AccountBalance invariant
646    if free_value > total_value {
647        total_value = free_value;
648    }
649
650    let locked_value = total_value - free_value;
651
652    let total = Money::new(total_value, currency);
653    let locked = Money::new(locked_value, currency);
654    let free = Money::new(free_value, currency);
655
656    let balance = AccountBalance::new(total, locked, free);
657    balances.push(balance);
658
659    let margin_used = cross_margin_summary
660        .total_margin_used
661        .to_string()
662        .parse::<f64>()?;
663
664    if margin_used > 0.0 {
665        let margin_instrument_id =
666            InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
667
668        let initial_margin = Money::new(margin_used, currency);
669        let maintenance_margin = Money::new(margin_used, currency);
670
671        let margin_balance =
672            MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
673
674        margins.push(margin_balance);
675    }
676
677    Ok((balances, margins))
678}
679
680#[cfg(test)]
681mod tests {
682    use std::str::FromStr;
683
684    use rstest::rstest;
685    use rust_decimal::Decimal;
686    use rust_decimal_macros::dec;
687    use serde::{Deserialize, Serialize};
688
689    use super::*;
690
691    #[derive(Serialize, Deserialize)]
692    struct TestStruct {
693        #[serde(
694            serialize_with = "serialize_decimal_as_str",
695            deserialize_with = "deserialize_decimal_from_str"
696        )]
697        value: Decimal,
698        #[serde(
699            serialize_with = "serialize_optional_decimal_as_str",
700            deserialize_with = "deserialize_optional_decimal_from_str"
701        )]
702        optional_value: Option<Decimal>,
703    }
704
705    #[rstest]
706    fn test_decimal_serialization_roundtrip() {
707        let original = TestStruct {
708            value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
709            optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
710        };
711
712        let json = serde_json::to_string(&original).unwrap();
713        println!("Serialized: {json}");
714
715        // Check that it's serialized as strings (rust_decimal may normalize precision)
716        assert!(json.contains("\"123.45678901234567890123456789\""));
717        assert!(json.contains("\"0.000000001\""));
718
719        let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
720        assert_eq!(original.value, deserialized.value);
721        assert_eq!(original.optional_value, deserialized.optional_value);
722    }
723
724    #[rstest]
725    fn test_decimal_precision_preservation() {
726        let test_cases = [
727            "0",
728            "1",
729            "0.1",
730            "0.01",
731            "0.001",
732            "123.456789012345678901234567890",
733            "999999999999999999.999999999999999999",
734        ];
735
736        for case in test_cases {
737            let decimal = Decimal::from_str(case).unwrap();
738            let test_struct = TestStruct {
739                value: decimal,
740                optional_value: Some(decimal),
741            };
742
743            let json = serde_json::to_string(&test_struct).unwrap();
744            let parsed: TestStruct = serde_json::from_str(&json).unwrap();
745
746            assert_eq!(decimal, parsed.value, "Failed for case: {case}");
747            assert_eq!(
748                Some(decimal),
749                parsed.optional_value,
750                "Failed for case: {case}"
751            );
752        }
753    }
754
755    #[rstest]
756    fn test_optional_none_handling() {
757        let test_struct = TestStruct {
758            value: Decimal::from_str("42.0").unwrap(),
759            optional_value: None,
760        };
761
762        let json = serde_json::to_string(&test_struct).unwrap();
763        assert!(json.contains("null"));
764
765        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
766        assert_eq!(test_struct.value, parsed.value);
767        assert_eq!(None, parsed.optional_value);
768    }
769
770    #[rstest]
771    fn test_round_down_to_tick() {
772        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
773        assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
774        assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
775
776        // Edge case: zero tick size
777        assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
778    }
779
780    #[rstest]
781    fn test_round_down_to_step() {
782        assert_eq!(
783            round_down_to_step(dec!(0.12349), dec!(0.0001)),
784            dec!(0.1234)
785        );
786        assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
787        assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
788
789        // Edge case: zero step size
790        assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
791    }
792
793    #[rstest]
794    fn test_min_notional_validation() {
795        // Should pass
796        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
797        assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
798
799        // Should fail
800        assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
801        assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
802
803        // Edge case: exactly at minimum
804        assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
805    }
806
807    #[rstest]
808    fn test_round_to_sig_figs() {
809        // BTC price ~$104,567 needs to round to 5 sig figs
810        assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
811        assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
812        assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
813
814        // Smaller prices should keep decimals
815        assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
816        assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
817        assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
818
819        // Sub-1 values with leading zeros must preserve 5 sig figs
820        assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
821        assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); // 6 sig figs -> 5
822
823        // Zero case
824        assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
825    }
826
827    #[rstest]
828    fn test_normalize_price() {
829        // Now includes 5 sig fig rounding first
830        assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
831        assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); // Rounded to 5 sig figs first
832        assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); // 100.999 -> 101.00 (5 sig) -> 101
833        assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12)); // 5 sig figs = 100.12
834
835        // BTC-like prices get rounded to 5 sig figs
836        assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
837    }
838
839    #[rstest]
840    fn test_normalize_quantity() {
841        assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
842        assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
843        assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
844        assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
845    }
846
847    #[rstest]
848    fn test_normalize_order_complete() {
849        let result = normalize_order(
850            dec!(100.12345), // price
851            dec!(0.123456),  // qty
852            dec!(0.01),      // tick_size
853            dec!(0.0001),    // step_size
854            dec!(10),        // min_notional
855            2,               // price_decimals
856            4,               // size_decimals
857        );
858
859        assert!(result.is_ok());
860        let (price, qty) = result.unwrap();
861        assert_eq!(price, dec!(100.12)); // normalized and rounded down
862        assert_eq!(qty, dec!(0.1234)); // normalized and rounded down
863    }
864
865    #[rstest]
866    fn test_normalize_order_min_notional_fail() {
867        let result = normalize_order(
868            dec!(100.12345), // price
869            dec!(0.05),      // qty (too small for min notional)
870            dec!(0.01),      // tick_size
871            dec!(0.0001),    // step_size
872            dec!(10),        // min_notional
873            2,               // price_decimals
874            4,               // size_decimals
875        );
876
877        assert!(result.is_err());
878        assert!(result.unwrap_err().contains("Notional value"));
879    }
880
881    #[rstest]
882    fn test_edge_cases() {
883        // Test with very small numbers
884        assert_eq!(
885            round_down_to_tick(dec!(0.000001), dec!(0.000001)),
886            dec!(0.000001)
887        );
888
889        // Test with large numbers
890        assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
891
892        // Test rounding edge case
893        assert_eq!(
894            round_down_to_tick(dec!(100.009999), dec!(0.01)),
895            dec!(100.00)
896        );
897    }
898
899    #[rstest]
900    fn test_is_conditional_order_data() {
901        // Test with trigger price and tpsl (conditional)
902        assert!(is_conditional_order_data(
903            Some("50000.0"),
904            Some(&HyperliquidTpSl::Sl)
905        ));
906
907        // Test with only trigger price (not conditional - needs both)
908        assert!(!is_conditional_order_data(Some("50000.0"), None));
909
910        // Test with only tpsl (not conditional - needs both)
911        assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
912
913        // Test with no conditional fields
914        assert!(!is_conditional_order_data(None, None));
915    }
916
917    #[rstest]
918    fn test_parse_trigger_order_type() {
919        // Stop Market
920        assert_eq!(
921            parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
922            OrderType::StopMarket
923        );
924
925        // Stop Limit
926        assert_eq!(
927            parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
928            OrderType::StopLimit
929        );
930
931        // Take Profit Market
932        assert_eq!(
933            parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
934            OrderType::MarketIfTouched
935        );
936
937        // Take Profit Limit
938        assert_eq!(
939            parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
940            OrderType::LimitIfTouched
941        );
942    }
943
944    #[rstest]
945    fn test_parse_order_status_with_trigger() {
946        // Test with open status and activated trigger
947        let (status, trigger_status) =
948            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
949        assert_eq!(status, OrderStatus::Accepted);
950        assert_eq!(trigger_status, Some("activated".to_string()));
951
952        // Test with open status and not activated
953        let (status, trigger_status) =
954            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
955        assert_eq!(status, OrderStatus::Accepted);
956        assert_eq!(trigger_status, Some("pending".to_string()));
957
958        // Test without trigger info
959        let (status, trigger_status) =
960            parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
961        assert_eq!(status, OrderStatus::Accepted);
962        assert_eq!(trigger_status, None);
963    }
964
965    #[rstest]
966    fn test_format_trailing_stop_info() {
967        // Price offset
968        let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
969        assert!(info.contains("100.0"));
970        assert!(info.contains("callback at 50000.0"));
971
972        // Percentage offset
973        let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
974        assert!(info.contains("5.0%"));
975        assert!(info.contains("Trailing stop"));
976
977        // Basis points offset
978        let info =
979            format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
980        assert!(info.contains("250 bps"));
981        assert!(info.contains("49000.0"));
982    }
983
984    #[rstest]
985    fn test_parse_trigger_price() {
986        // Valid price
987        let result = parse_trigger_price("50000.0");
988        assert!(result.is_ok());
989        assert_eq!(result.unwrap(), dec!(50000.0));
990
991        // Valid integer price
992        let result = parse_trigger_price("49000");
993        assert!(result.is_ok());
994        assert_eq!(result.unwrap(), dec!(49000));
995
996        // Invalid price
997        let result = parse_trigger_price("invalid");
998        assert!(result.is_err());
999
1000        // Empty string
1001        let result = parse_trigger_price("");
1002        assert!(result.is_err());
1003    }
1004}