use anyhow::Context;
use nautilus_core::UnixNanos;
pub use nautilus_core::serialization::{
deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
serialize_vec_decimal_as_str,
};
use nautilus_model::{
data::{bar::BarType, quote::QuoteTick},
enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType, TimeInForce},
identifiers::{ClientOrderId, InstrumentId, Symbol, TradeId, Venue},
orders::{Order, any::OrderAny},
types::{AccountBalance, Currency, MarginBalance, Money},
};
use rust_decimal::Decimal;
use crate::{
common::enums::{
HyperliquidBarInterval::{self, *},
HyperliquidOrderStatus, HyperliquidTpSl,
},
http::models::{
Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelStatus,
HyperliquidExecLimitParams, HyperliquidExecModifyStatus, HyperliquidExecOrderKind,
HyperliquidExecOrderStatus, HyperliquidExecPlaceOrderRequest, HyperliquidExecResponseData,
HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
},
websocket::messages::TrailingOffsetType,
};
pub fn make_fill_trade_id(
hash: &str,
oid: u64,
px: &str,
sz: &str,
time: u64,
start_position: &str,
) -> TradeId {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in hash.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
for b in oid.to_le_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
for &b in px.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
for &b in sz.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
for b in time.to_le_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
for &b in start_position.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
TradeId::new(format!("{h:016x}-{oid:016x}"))
}
#[inline]
pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
if tick_size.is_zero() {
return price;
}
(price / tick_size).floor() * tick_size
}
#[inline]
pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
if step_size.is_zero() {
return qty;
}
(qty / step_size).floor() * step_size
}
#[inline]
pub fn ensure_min_notional(
price: Decimal,
qty: Decimal,
min_notional: Decimal,
) -> Result<(), String> {
let notional = price * qty;
if notional < min_notional {
Err(format!(
"Notional value {notional} is less than minimum required {min_notional}"
))
} else {
Ok(())
}
}
pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
if value.is_zero() {
return Decimal::ZERO;
}
let abs_val = value.abs();
let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
let magnitude = float_val.log10().floor() as i32;
let shift = sig_figs as i32 - 1 - magnitude;
let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
if shift >= 0 {
(value * factor).round() / factor
} else {
(value / factor).round() * factor
}
}
pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
let sig_fig_price = round_to_sig_figs(price, 5);
let scale = Decimal::from(10_u64.pow(decimals as u32));
(sig_fig_price * scale).floor() / scale
}
pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
let scale = Decimal::from(10_u64.pow(decimals as u32));
(qty * scale).floor() / scale
}
pub fn normalize_order(
price: Decimal,
qty: Decimal,
tick_size: Decimal,
step_size: Decimal,
min_notional: Decimal,
price_decimals: u8,
size_decimals: u8,
) -> Result<(Decimal, Decimal), String> {
let normalized_price = normalize_price(price, price_decimals);
let normalized_qty = normalize_quantity(qty, size_decimals);
let final_price = round_down_to_tick(normalized_price, tick_size);
let final_qty = round_down_to_step(normalized_qty, step_size);
ensure_min_notional(final_price, final_qty, min_notional)?;
Ok((final_price, final_qty))
}
#[inline]
pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
Ok(UnixNanos::from(value))
}
pub fn time_in_force_to_hyperliquid_tif(
tif: TimeInForce,
is_post_only: bool,
) -> anyhow::Result<HyperliquidExecTif> {
match (tif, is_post_only) {
(_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
(TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
(TimeInForce::Fok, false) => {
anyhow::bail!("FOK time in force is not supported by Hyperliquid")
}
_ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
}
}
fn determine_tpsl_type(
order_type: OrderType,
order_side: OrderSide,
trigger_price: Decimal,
current_price: Option<Decimal>,
) -> HyperliquidExecTpSl {
match order_type {
OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
_ => {
if let Some(current) = current_price {
match order_side {
OrderSide::Buy => {
if trigger_price > current {
HyperliquidExecTpSl::Sl
} else {
HyperliquidExecTpSl::Tp
}
}
OrderSide::Sell => {
if trigger_price < current {
HyperliquidExecTpSl::Sl
} else {
HyperliquidExecTpSl::Tp
}
}
_ => HyperliquidExecTpSl::Sl, }
} else {
HyperliquidExecTpSl::Sl
}
}
}
}
pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
let spec = bar_type.spec();
let step = spec.step.get();
anyhow::ensure!(
bar_type.aggregation_source() == AggregationSource::External,
"Only EXTERNAL aggregation is supported"
);
let interval = match spec.aggregation {
BarAggregation::Minute => match step {
1 => OneMinute,
3 => ThreeMinutes,
5 => FiveMinutes,
15 => FifteenMinutes,
30 => ThirtyMinutes,
_ => anyhow::bail!("Unsupported minute step: {step}"),
},
BarAggregation::Hour => match step {
1 => OneHour,
2 => TwoHours,
4 => FourHours,
8 => EightHours,
12 => TwelveHours,
_ => anyhow::bail!("Unsupported hour step: {step}"),
},
BarAggregation::Day => match step {
1 => OneDay,
3 => ThreeDays,
_ => anyhow::bail!("Unsupported day step: {step}"),
},
BarAggregation::Week if step == 1 => OneWeek,
BarAggregation::Month if step == 1 => OneMonth,
a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
};
Ok(interval)
}
pub fn order_to_hyperliquid_request_with_asset(
order: &OrderAny,
asset: u32,
price_decimals: u8,
should_normalize_prices: bool,
) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
let is_buy = matches!(order.order_side(), OrderSide::Buy);
let reduce_only = order.is_reduce_only();
let order_side = order.order_side();
let order_type = order.order_type();
let price_decimal = if let Some(price) = order.price() {
let raw = price.as_decimal();
if should_normalize_prices {
normalize_price(raw, price_decimals).normalize()
} else {
raw.normalize()
}
} else if matches!(order_type, OrderType::Market) {
Decimal::ZERO
} else if matches!(
order_type,
OrderType::StopMarket | OrderType::MarketIfTouched
) {
match order.trigger_price() {
Some(tp) => {
let base = tp.as_decimal().normalize();
let derived = derive_limit_from_trigger(base, is_buy);
let sig_rounded = round_to_sig_figs(derived, 5);
clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
}
None => Decimal::ZERO,
}
} else {
anyhow::bail!("Limit orders require a price")
};
let size_decimal = order.quantity().as_decimal().normalize();
let kind = match order_type {
OrderType::Market => HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams {
tif: HyperliquidExecTif::Ioc,
},
},
OrderType::Limit => {
let tif =
time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
HyperliquidExecOrderKind::Limit {
limit: HyperliquidExecLimitParams { tif },
}
}
OrderType::StopMarket => {
if let Some(trigger_price) = order.trigger_price() {
let raw = trigger_price.as_decimal();
let trigger_price_decimal = if should_normalize_prices {
normalize_price(raw, price_decimals).normalize()
} else {
raw.normalize()
};
let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market: true,
trigger_px: trigger_price_decimal,
tpsl,
},
}
} else {
anyhow::bail!("Stop market orders require a trigger price")
}
}
OrderType::StopLimit => {
if let Some(trigger_price) = order.trigger_price() {
let raw = trigger_price.as_decimal();
let trigger_price_decimal = if should_normalize_prices {
normalize_price(raw, price_decimals).normalize()
} else {
raw.normalize()
};
let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market: false,
trigger_px: trigger_price_decimal,
tpsl,
},
}
} else {
anyhow::bail!("Stop limit orders require a trigger price")
}
}
OrderType::MarketIfTouched => {
if let Some(trigger_price) = order.trigger_price() {
let raw = trigger_price.as_decimal();
let trigger_price_decimal = if should_normalize_prices {
normalize_price(raw, price_decimals).normalize()
} else {
raw.normalize()
};
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market: true,
trigger_px: trigger_price_decimal,
tpsl: HyperliquidExecTpSl::Tp,
},
}
} else {
anyhow::bail!("Market-if-touched orders require a trigger price")
}
}
OrderType::LimitIfTouched => {
if let Some(trigger_price) = order.trigger_price() {
let raw = trigger_price.as_decimal();
let trigger_price_decimal = if should_normalize_prices {
normalize_price(raw, price_decimals).normalize()
} else {
raw.normalize()
};
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market: false,
trigger_px: trigger_price_decimal,
tpsl: HyperliquidExecTpSl::Tp,
},
}
} else {
anyhow::bail!("Limit-if-touched orders require a trigger price")
}
}
_ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
};
let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
Ok(HyperliquidExecPlaceOrderRequest {
asset,
is_buy,
price: price_decimal,
size: size_decimal,
reduce_only,
kind,
cloid,
})
}
pub fn derive_market_order_price(quote: &QuoteTick, is_buy: bool, price_decimals: u8) -> Decimal {
let base = if is_buy {
quote.ask_price.as_decimal()
} else {
quote.bid_price.as_decimal()
};
let derived = derive_limit_from_trigger(base, is_buy);
let sig_rounded = round_to_sig_figs(derived, 5);
clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
}
pub fn derive_limit_from_trigger(trigger_price: Decimal, is_buy: bool) -> Decimal {
let slippage = Decimal::new(5, 3); let price = if is_buy {
trigger_price * (Decimal::ONE + slippage)
} else {
trigger_price * (Decimal::ONE - slippage)
};
price.normalize()
}
pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
let scale = Decimal::from(10_u64.pow(decimals as u32));
if is_buy {
(price * scale).ceil() / scale
} else {
(price * scale).floor() / scale
}
}
pub fn client_order_id_to_cancel_request_with_asset(
client_order_id: &str,
asset: u32,
) -> HyperliquidExecCancelByCloidRequest {
let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
HyperliquidExecCancelByCloidRequest { asset, cloid }
}
pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
let HyperliquidExchangeResponse::Status { response, .. } = response else {
return None;
};
let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
match data {
HyperliquidExecResponseData::Order { data } => {
for status in &data.statuses {
if let HyperliquidExecOrderStatus::Error { error } = status {
return Some(error.clone());
}
}
None
}
HyperliquidExecResponseData::Cancel { data } => {
for status in &data.statuses {
if let HyperliquidExecCancelStatus::Error { error } = status {
return Some(error.clone());
}
}
None
}
HyperliquidExecResponseData::Modify { data } => {
for status in &data.statuses {
if let HyperliquidExecModifyStatus::Error { error } = status {
return Some(error.clone());
}
}
None
}
_ => None,
}
}
pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
let HyperliquidExchangeResponse::Status { response, .. } = response else {
return Vec::new();
};
let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
return Vec::new();
};
match data {
HyperliquidExecResponseData::Order { data } => data
.statuses
.into_iter()
.map(|s| match s {
HyperliquidExecOrderStatus::Error { error } => Some(error),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
match response {
HyperliquidExchangeResponse::Status { status, response } => {
if status == RESPONSE_STATUS_OK {
"Operation successful".to_string()
} else {
if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
error_msg.to_string()
} else {
format!("Request failed with status: {status}")
}
}
}
HyperliquidExchangeResponse::Error { error } => error.clone(),
}
}
pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
trigger_px.is_some() && tpsl.is_some()
}
pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
match (is_market, tpsl) {
(true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
(false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
(true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
(false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
}
}
pub fn parse_order_status_with_trigger(
status: HyperliquidOrderStatus,
trigger_activated: Option<bool>,
) -> (OrderStatus, Option<String>) {
let base_status = OrderStatus::from(status);
if let Some(activated) = trigger_activated {
let trigger_status = if activated {
Some("activated".to_string())
} else {
Some("pending".to_string())
};
(base_status, trigger_status)
} else {
(base_status, None)
}
}
pub fn format_trailing_stop_info(
offset: &str,
offset_type: TrailingOffsetType,
callback_price: Option<&str>,
) -> String {
let offset_desc = offset_type.format_offset(offset);
if let Some(callback) = callback_price {
format!("Trailing stop: {offset_desc} offset, callback at {callback}")
} else {
format!("Trailing stop: {offset_desc} offset")
}
}
pub fn validate_conditional_order_params(
trigger_px: Option<&str>,
tpsl: Option<&HyperliquidTpSl>,
is_market: Option<bool>,
) -> anyhow::Result<()> {
if trigger_px.is_none() {
anyhow::bail!("Conditional order missing trigger price");
}
if tpsl.is_none() {
anyhow::bail!("Conditional order missing tpsl indicator");
}
if is_market.is_none() {
anyhow::bail!("Conditional order missing is_market flag");
}
Ok(())
}
pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
Decimal::from_str_exact(trigger_px)
.with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
}
pub fn parse_account_balances_and_margins(
cross_margin_summary: &CrossMarginSummary,
) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
let mut balances = Vec::new();
let mut margins = Vec::new();
let currency = Currency::USDC();
let mut total_value = cross_margin_summary
.account_value
.to_string()
.parse::<f64>()?
.max(0.0);
let free_value = cross_margin_summary
.withdrawable
.map(|w| w.to_string().parse::<f64>())
.transpose()?
.unwrap_or(total_value)
.max(0.0);
if free_value > total_value {
total_value = free_value;
}
let locked_value = total_value - free_value;
let total = Money::new(total_value, currency);
let locked = Money::new(locked_value, currency);
let free = Money::new(free_value, currency);
let balance = AccountBalance::new(total, locked, free);
balances.push(balance);
let margin_used = cross_margin_summary
.total_margin_used
.to_string()
.parse::<f64>()?;
if margin_used > 0.0 {
let margin_instrument_id =
InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
let initial_margin = Money::new(margin_used, currency);
let maintenance_margin = Money::new(margin_used, currency);
let margin_balance =
MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
margins.push(margin_balance);
}
Ok((balances, margins))
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use nautilus_model::{
enums::{OrderSide, TimeInForce, TriggerType},
identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
orders::{OrderAny, StopMarketOrder},
types::{Price, Quantity},
};
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize)]
struct TestStruct {
#[serde(
serialize_with = "serialize_decimal_as_str",
deserialize_with = "deserialize_decimal_from_str"
)]
value: Decimal,
#[serde(
serialize_with = "serialize_optional_decimal_as_str",
deserialize_with = "deserialize_optional_decimal_from_str"
)]
optional_value: Option<Decimal>,
}
#[rstest]
fn test_decimal_serialization_roundtrip() {
let original = TestStruct {
value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
};
let json = serde_json::to_string(&original).unwrap();
println!("Serialized: {json}");
assert!(json.contains("\"123.45678901234567890123456789\""));
assert!(json.contains("\"0.000000001\""));
let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
assert_eq!(original.value, deserialized.value);
assert_eq!(original.optional_value, deserialized.optional_value);
}
#[rstest]
fn test_decimal_precision_preservation() {
let test_cases = [
"0",
"1",
"0.1",
"0.01",
"0.001",
"123.456789012345678901234567890",
"999999999999999999.999999999999999999",
];
for case in test_cases {
let decimal = Decimal::from_str(case).unwrap();
let test_struct = TestStruct {
value: decimal,
optional_value: Some(decimal),
};
let json = serde_json::to_string(&test_struct).unwrap();
let parsed: TestStruct = serde_json::from_str(&json).unwrap();
assert_eq!(decimal, parsed.value, "Failed for case: {case}");
assert_eq!(
Some(decimal),
parsed.optional_value,
"Failed for case: {case}"
);
}
}
#[rstest]
fn test_optional_none_handling() {
let test_struct = TestStruct {
value: Decimal::from_str("42.0").unwrap(),
optional_value: None,
};
let json = serde_json::to_string(&test_struct).unwrap();
assert!(json.contains("null"));
let parsed: TestStruct = serde_json::from_str(&json).unwrap();
assert_eq!(test_struct.value, parsed.value);
assert_eq!(None, parsed.optional_value);
}
#[rstest]
fn test_round_down_to_tick() {
assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
}
#[rstest]
fn test_round_down_to_step() {
assert_eq!(
round_down_to_step(dec!(0.12349), dec!(0.0001)),
dec!(0.1234)
);
assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
}
#[rstest]
fn test_min_notional_validation() {
assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
}
#[rstest]
fn test_round_to_sig_figs() {
assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000));
assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
}
#[rstest]
fn test_normalize_price() {
assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.2)); assert_eq!(normalize_price(dec!(100.999), 0), dec!(101)); assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.12));
assert_eq!(normalize_price(dec!(104567.3), 1), dec!(104570));
}
#[rstest]
fn test_normalize_quantity() {
assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
}
#[rstest]
fn test_normalize_order_complete() {
let result = normalize_order(
dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
assert!(result.is_ok());
let (price, qty) = result.unwrap();
assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
#[rstest]
fn test_normalize_order_min_notional_fail() {
let result = normalize_order(
dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
assert!(result.is_err());
assert!(result.unwrap_err().contains("Notional value"));
}
#[rstest]
fn test_edge_cases() {
assert_eq!(
round_down_to_tick(dec!(0.000001), dec!(0.000001)),
dec!(0.000001)
);
assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
assert_eq!(
round_down_to_tick(dec!(100.009999), dec!(0.01)),
dec!(100.00)
);
}
#[rstest]
fn test_is_conditional_order_data() {
assert!(is_conditional_order_data(
Some("50000.0"),
Some(&HyperliquidTpSl::Sl)
));
assert!(!is_conditional_order_data(Some("50000.0"), None));
assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
assert!(!is_conditional_order_data(None, None));
}
#[rstest]
fn test_parse_trigger_order_type() {
assert_eq!(
parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
OrderType::StopMarket
);
assert_eq!(
parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
OrderType::StopLimit
);
assert_eq!(
parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
OrderType::MarketIfTouched
);
assert_eq!(
parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
OrderType::LimitIfTouched
);
}
#[rstest]
fn test_parse_order_status_with_trigger() {
let (status, trigger_status) =
parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
assert_eq!(status, OrderStatus::Accepted);
assert_eq!(trigger_status, Some("activated".to_string()));
let (status, trigger_status) =
parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
assert_eq!(status, OrderStatus::Accepted);
assert_eq!(trigger_status, Some("pending".to_string()));
let (status, trigger_status) =
parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
assert_eq!(status, OrderStatus::Accepted);
assert_eq!(trigger_status, None);
}
#[rstest]
fn test_format_trailing_stop_info() {
let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
assert!(info.contains("100.0"));
assert!(info.contains("callback at 50000.0"));
let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
assert!(info.contains("5.0%"));
assert!(info.contains("Trailing stop"));
let info =
format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
assert!(info.contains("250 bps"));
assert!(info.contains("49000.0"));
}
#[rstest]
fn test_parse_trigger_price() {
let result = parse_trigger_price("50000.0");
assert!(result.is_ok());
assert_eq!(result.unwrap(), dec!(50000.0));
let result = parse_trigger_price("49000");
assert!(result.is_ok());
assert_eq!(result.unwrap(), dec!(49000));
let result = parse_trigger_price("invalid");
assert!(result.is_err());
let result = parse_trigger_price("");
assert!(result.is_err());
}
#[rstest]
#[case(dec!(0), true, dec!(0))] #[case(dec!(0), false, dec!(0))] #[case(dec!(0.001), true, dec!(0.001005))] #[case(dec!(0.001), false, dec!(0.000995))] #[case(dec!(100), true, dec!(100.5))] #[case(dec!(100), false, dec!(99.5))] #[case(dec!(2470), true, dec!(2482.35))] #[case(dec!(2470), false, dec!(2457.65))] #[case(dec!(104567.3), true, dec!(105090.1365))] #[case(dec!(104567.3), false, dec!(104044.4635))] fn test_derive_limit_from_trigger(
#[case] trigger_price: Decimal,
#[case] is_buy: bool,
#[case] expected: Decimal,
) {
let result = derive_limit_from_trigger(trigger_price, is_buy);
assert_eq!(result, expected);
if is_buy {
assert!(result >= trigger_price);
} else {
assert!(result <= trigger_price);
}
}
#[rstest]
#[case(dec!(2457.65), 2, true, dec!(2457.65))] #[case(dec!(2457.65), 1, true, dec!(2457.7))] #[case(dec!(2457.65), 0, true, dec!(2458))] #[case(dec!(2457.65), 2, false, dec!(2457.65))] #[case(dec!(2457.65), 1, false, dec!(2457.6))] #[case(dec!(2457.65), 0, false, dec!(2457))] #[case(dec!(0.4975), 4, true, dec!(0.4975))]
#[case(dec!(0.4975), 4, false, dec!(0.4975))]
#[case(dec!(0.4975), 2, true, dec!(0.50))]
#[case(dec!(0.4975), 2, false, dec!(0.49))]
fn test_clamp_price_to_precision(
#[case] price: Decimal,
#[case] decimals: u8,
#[case] is_buy: bool,
#[case] expected: Decimal,
) {
assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
}
fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
OrderAny::StopMarket(StopMarketOrder::new(
TraderId::from("TESTER-001"),
StrategyId::from("S-001"),
InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
ClientOrderId::from("O-001"),
side,
Quantity::from(1),
Price::from(trigger_price),
TriggerType::LastPrice,
TimeInForce::Gtc,
None,
false,
false,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Default::default(),
Default::default(),
))
}
#[rstest]
#[case(OrderSide::Sell, "2470.00", 2)]
#[case(OrderSide::Buy, "2470.00", 2)]
#[case(OrderSide::Sell, "104567.3", 1)]
#[case(OrderSide::Buy, "104567.3", 1)]
#[case(OrderSide::Sell, "0.50", 4)]
#[case(OrderSide::Buy, "0.50", 4)]
#[case(OrderSide::Sell, "2470.00", 1)]
#[case(OrderSide::Buy, "2470.00", 1)]
#[case(OrderSide::Sell, "2470.00", 0)]
#[case(OrderSide::Buy, "2470.00", 0)]
fn test_order_to_request_stop_market_derives_limit_from_trigger(
#[case] side: OrderSide,
#[case] trigger_str: &str,
#[case] price_decimals: u8,
) {
let order = stop_market_order(side, trigger_str);
let request =
order_to_hyperliquid_request_with_asset(&order, 0, price_decimals, true).unwrap();
let trigger = Decimal::from_str(trigger_str).unwrap();
let is_buy = matches!(side, OrderSide::Buy);
if is_buy {
assert!(
request.price >= trigger,
"BUY limit {} must be >= trigger {trigger}",
request.price,
);
assert!(request.is_buy);
} else {
assert!(
request.price <= trigger,
"SELL limit {} must be <= trigger {trigger}",
request.price,
);
assert!(!request.is_buy);
}
let derived = derive_limit_from_trigger(trigger, is_buy);
let sig_rounded = round_to_sig_figs(derived, 5);
let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
assert_eq!(request.price, expected);
let price_str = request.price.to_string();
let actual_decimals = price_str
.find('.')
.map_or(0, |dot| price_str.len() - dot - 1);
assert!(
actual_decimals <= price_decimals as usize,
"Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
);
if price_str.contains('.') {
assert!(
!price_str.ends_with('0'),
"Price {price_str} has decimal trailing zeros",
);
}
let expected_trigger = normalize_price(trigger, price_decimals).normalize();
assert_eq!(
request.kind,
HyperliquidExecOrderKind::Trigger {
trigger: HyperliquidExecTriggerParams {
is_market: true,
trigger_px: expected_trigger,
tpsl: HyperliquidExecTpSl::Sl,
},
},
);
}
fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
HyperliquidExchangeResponse::Status {
status: "ok".to_string(),
response: inner,
}
}
#[rstest]
fn test_extract_inner_error_order_with_error() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [{"error": "Order has invalid price."}]}
}));
assert_eq!(
extract_inner_error(&response),
Some("Order has invalid price.".to_string()),
);
}
#[rstest]
fn test_extract_inner_error_order_resting() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [{"resting": {"oid": 12345}}]}
}));
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_order_filled() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
}));
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_cancel_error() {
let response = ok_response(serde_json::json!({
"type": "cancel",
"data": {"statuses": [{"error": "Order not found"}]}
}));
assert_eq!(
extract_inner_error(&response),
Some("Order not found".to_string()),
);
}
#[rstest]
fn test_extract_inner_error_cancel_success() {
let response = ok_response(serde_json::json!({
"type": "cancel",
"data": {"statuses": ["success"]}
}));
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_modify_error() {
let response = ok_response(serde_json::json!({
"type": "modify",
"data": {"statuses": [{"error": "Invalid modify"}]}
}));
assert_eq!(
extract_inner_error(&response),
Some("Invalid modify".to_string()),
);
}
#[rstest]
fn test_extract_inner_error_modify_success() {
let response = ok_response(serde_json::json!({
"type": "modify",
"data": {"statuses": ["success"]}
}));
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_non_status_response() {
let response = HyperliquidExchangeResponse::Error {
error: "top-level error".to_string(),
};
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_unparsable_response() {
let response = ok_response(serde_json::json!({"unknown": "data"}));
assert_eq!(extract_inner_error(&response), None);
}
#[rstest]
fn test_extract_inner_error_returns_first_error_in_batch() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [
{"resting": {"oid": 1}},
{"error": "Second failed"},
{"error": "Third failed"},
]}
}));
assert_eq!(
extract_inner_error(&response),
Some("Second failed".to_string()),
);
}
#[rstest]
fn test_extract_inner_errors_mixed_batch() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [
{"resting": {"oid": 1}},
{"error": "Failed order"},
{"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
]}
}));
let errors = extract_inner_errors(&response);
assert_eq!(errors.len(), 3);
assert_eq!(errors[0], None);
assert_eq!(errors[1], Some("Failed order".to_string()));
assert_eq!(errors[2], None);
}
#[rstest]
fn test_extract_inner_errors_all_success() {
let response = ok_response(serde_json::json!({
"type": "order",
"data": {"statuses": [
{"resting": {"oid": 1}},
{"resting": {"oid": 2}},
]}
}));
let errors = extract_inner_errors(&response);
assert_eq!(errors.len(), 2);
assert!(errors.iter().all(|e| e.is_none()));
}
#[rstest]
fn test_extract_inner_errors_non_order_response() {
let response = ok_response(serde_json::json!({
"type": "cancel",
"data": {"statuses": ["success"]}
}));
let errors = extract_inner_errors(&response);
assert!(errors.is_empty());
}
#[rstest]
fn test_extract_inner_errors_unparsable() {
let response = ok_response(serde_json::json!({"foo": "bar"}));
let errors = extract_inner_errors(&response);
assert!(errors.is_empty());
}
fn count_sig_figs(s: &str) -> usize {
let s = s.trim_start_matches('-');
if s.contains('.') {
let digits: String = s.replace('.', "");
digits.trim_start_matches('0').len()
} else {
let s = s.trim_start_matches('0');
s.trim_end_matches('0').len()
}
}
fn make_quote(bid: &str, ask: &str) -> QuoteTick {
QuoteTick::new(
InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
Price::from(bid),
Price::from(ask),
Quantity::from("1"),
Quantity::from("1"),
Default::default(),
Default::default(),
)
}
#[rstest]
#[case("2460.00", "2470.00", true, 2, "2482.4")]
#[case("2460.00", "2470.00", false, 2, "2447.7")]
#[case("104500.0", "104567.3", true, 1, "105090")]
#[case("104500.0", "104567.3", false, 1, "103980")]
#[case("0.4900", "0.5000", true, 4, "0.5025")]
#[case("0.4900", "0.5000", false, 4, "0.4875")]
#[case("49900", "50000", true, 0, "50250")]
#[case("49900", "50000", false, 0, "49650")]
#[case("0.001200", "0.001234", true, 6, "0.001241")]
#[case("0.001200", "0.001234", false, 6, "0.001194")]
fn test_derive_market_order_price(
#[case] bid: &str,
#[case] ask: &str,
#[case] is_buy: bool,
#[case] price_decimals: u8,
#[case] expected: &str,
) {
let quote = make_quote(bid, ask);
let result = derive_market_order_price("e, is_buy, price_decimals);
let expected_dec = Decimal::from_str(expected).unwrap();
assert_eq!(result, expected_dec);
let base = if is_buy {
quote.ask_price.as_decimal()
} else {
quote.bid_price.as_decimal()
};
let derived = derive_limit_from_trigger(base, is_buy);
let sig_rounded = round_to_sig_figs(derived, 5);
let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
assert_eq!(result, pipeline);
let s = result.to_string();
if s.contains('.') {
assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
}
let sig_count = count_sig_figs(&s);
assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
assert!(
actual_decimals <= price_decimals as usize,
"Price {s} has {actual_decimals} decimals, max {price_decimals}",
);
}
}