1use 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, quote::QuoteTick},
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, HyperliquidExecCancelStatus,
85 HyperliquidExecLimitParams, HyperliquidExecModifyStatus, HyperliquidExecOrderKind,
86 HyperliquidExecOrderStatus, HyperliquidExecPlaceOrderRequest, HyperliquidExecResponseData,
87 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
88 },
89 websocket::messages::TrailingOffsetType,
90};
91
92pub fn make_fill_trade_id(
100 hash: &str,
101 oid: u64,
102 px: &str,
103 sz: &str,
104 time: u64,
105 start_position: &str,
106) -> TradeId {
107 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
109 for &b in hash.as_bytes() {
110 h ^= b as u64;
111 h = h.wrapping_mul(0x0100_0000_01b3);
112 }
113 for b in oid.to_le_bytes() {
114 h ^= b as u64;
115 h = h.wrapping_mul(0x0100_0000_01b3);
116 }
117 for &b in px.as_bytes() {
118 h ^= b as u64;
119 h = h.wrapping_mul(0x0100_0000_01b3);
120 }
121 for &b in sz.as_bytes() {
122 h ^= b as u64;
123 h = h.wrapping_mul(0x0100_0000_01b3);
124 }
125 for b in time.to_le_bytes() {
126 h ^= b as u64;
127 h = h.wrapping_mul(0x0100_0000_01b3);
128 }
129 for &b in start_position.as_bytes() {
130 h ^= b as u64;
131 h = h.wrapping_mul(0x0100_0000_01b3);
132 }
133 TradeId::new(format!("{h:016x}-{oid:016x}"))
134}
135
136#[inline]
138pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
139 if tick_size.is_zero() {
140 return price;
141 }
142 (price / tick_size).floor() * tick_size
143}
144
145#[inline]
147pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
148 if step_size.is_zero() {
149 return qty;
150 }
151 (qty / step_size).floor() * step_size
152}
153
154#[inline]
156pub fn ensure_min_notional(
157 price: Decimal,
158 qty: Decimal,
159 min_notional: Decimal,
160) -> Result<(), String> {
161 let notional = price * qty;
162 if notional < min_notional {
163 Err(format!(
164 "Notional value {notional} is less than minimum required {min_notional}"
165 ))
166 } else {
167 Ok(())
168 }
169}
170
171pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
174 if value.is_zero() {
175 return Decimal::ZERO;
176 }
177
178 let abs_val = value.abs();
180 let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
181 let magnitude = float_val.log10().floor() as i32;
182
183 let shift = sig_figs as i32 - 1 - magnitude;
185 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
186
187 if shift >= 0 {
188 (value * factor).round() / factor
189 } else {
190 (value / factor).round() * factor
191 }
192}
193
194pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
196 let sig_fig_price = round_to_sig_figs(price, 5);
198 let scale = Decimal::from(10_u64.pow(decimals as u32));
200 (sig_fig_price * scale).floor() / scale
201}
202
203pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
205 let scale = Decimal::from(10_u64.pow(decimals as u32));
206 (qty * scale).floor() / scale
207}
208
209pub fn normalize_order(
211 price: Decimal,
212 qty: Decimal,
213 tick_size: Decimal,
214 step_size: Decimal,
215 min_notional: Decimal,
216 price_decimals: u8,
217 size_decimals: u8,
218) -> Result<(Decimal, Decimal), String> {
219 let normalized_price = normalize_price(price, price_decimals);
221 let normalized_qty = normalize_quantity(qty, size_decimals);
222
223 let final_price = round_down_to_tick(normalized_price, tick_size);
225 let final_qty = round_down_to_step(normalized_qty, step_size);
226
227 ensure_min_notional(final_price, final_qty, min_notional)?;
229
230 Ok((final_price, final_qty))
231}
232
233#[inline]
235pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
236 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
237 Ok(UnixNanos::from(value))
238}
239
240pub fn time_in_force_to_hyperliquid_tif(
246 tif: TimeInForce,
247 is_post_only: bool,
248) -> anyhow::Result<HyperliquidExecTif> {
249 match (tif, is_post_only) {
250 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
252 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
253 (TimeInForce::Fok, false) => {
254 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
255 }
256 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
257 }
258}
259
260fn determine_tpsl_type(
261 order_type: OrderType,
262 order_side: OrderSide,
263 trigger_price: Decimal,
264 current_price: Option<Decimal>,
265) -> HyperliquidExecTpSl {
266 match order_type {
267 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
269
270 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
272
273 _ => {
275 if let Some(current) = current_price {
276 match order_side {
277 OrderSide::Buy => {
278 if trigger_price > current {
280 HyperliquidExecTpSl::Sl
281 } else {
282 HyperliquidExecTpSl::Tp
283 }
284 }
285 OrderSide::Sell => {
286 if trigger_price < current {
288 HyperliquidExecTpSl::Sl
289 } else {
290 HyperliquidExecTpSl::Tp
291 }
292 }
293 _ => HyperliquidExecTpSl::Sl, }
295 } else {
296 HyperliquidExecTpSl::Sl
298 }
299 }
300 }
301}
302
303pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
309 let spec = bar_type.spec();
310 let step = spec.step.get();
311
312 anyhow::ensure!(
313 bar_type.aggregation_source() == AggregationSource::External,
314 "Only EXTERNAL aggregation is supported"
315 );
316
317 let interval = match spec.aggregation {
318 BarAggregation::Minute => match step {
319 1 => OneMinute,
320 3 => ThreeMinutes,
321 5 => FiveMinutes,
322 15 => FifteenMinutes,
323 30 => ThirtyMinutes,
324 _ => anyhow::bail!("Unsupported minute step: {step}"),
325 },
326 BarAggregation::Hour => match step {
327 1 => OneHour,
328 2 => TwoHours,
329 4 => FourHours,
330 8 => EightHours,
331 12 => TwelveHours,
332 _ => anyhow::bail!("Unsupported hour step: {step}"),
333 },
334 BarAggregation::Day => match step {
335 1 => OneDay,
336 3 => ThreeDays,
337 _ => anyhow::bail!("Unsupported day step: {step}"),
338 },
339 BarAggregation::Week if step == 1 => OneWeek,
340 BarAggregation::Month if step == 1 => OneMonth,
341 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
342 };
343
344 Ok(interval)
345}
346
347pub fn order_to_hyperliquid_request_with_asset(
353 order: &OrderAny,
354 asset: u32,
355 price_decimals: u8,
356 should_normalize_prices: bool,
357) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
358 let is_buy = matches!(order.order_side(), OrderSide::Buy);
359 let reduce_only = order.is_reduce_only();
360 let order_side = order.order_side();
361 let order_type = order.order_type();
362
363 let price_decimal = if let Some(price) = order.price() {
366 let raw = price.as_decimal();
367
368 if should_normalize_prices {
369 normalize_price(raw, price_decimals).normalize()
370 } else {
371 raw.normalize()
372 }
373 } else if matches!(order_type, OrderType::Market) {
374 Decimal::ZERO
375 } else if matches!(
376 order_type,
377 OrderType::StopMarket | OrderType::MarketIfTouched
378 ) {
379 match order.trigger_price() {
380 Some(tp) => {
381 let base = tp.as_decimal().normalize();
382 let derived = derive_limit_from_trigger(base, is_buy);
383 let sig_rounded = round_to_sig_figs(derived, 5);
384 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
385 }
386 None => Decimal::ZERO,
387 }
388 } else {
389 anyhow::bail!("Limit orders require a price")
390 };
391
392 let size_decimal = order.quantity().as_decimal().normalize();
393
394 let kind = match order_type {
396 OrderType::Market => HyperliquidExecOrderKind::Limit {
397 limit: HyperliquidExecLimitParams {
398 tif: HyperliquidExecTif::Ioc,
399 },
400 },
401 OrderType::Limit => {
402 let tif =
403 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
404 HyperliquidExecOrderKind::Limit {
405 limit: HyperliquidExecLimitParams { tif },
406 }
407 }
408 OrderType::StopMarket => {
409 if let Some(trigger_price) = order.trigger_price() {
410 let raw = trigger_price.as_decimal();
411 let trigger_price_decimal = if should_normalize_prices {
412 normalize_price(raw, price_decimals).normalize()
413 } else {
414 raw.normalize()
415 };
416 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
417 HyperliquidExecOrderKind::Trigger {
418 trigger: HyperliquidExecTriggerParams {
419 is_market: true,
420 trigger_px: trigger_price_decimal,
421 tpsl,
422 },
423 }
424 } else {
425 anyhow::bail!("Stop market orders require a trigger price")
426 }
427 }
428 OrderType::StopLimit => {
429 if let Some(trigger_price) = order.trigger_price() {
430 let raw = trigger_price.as_decimal();
431 let trigger_price_decimal = if should_normalize_prices {
432 normalize_price(raw, price_decimals).normalize()
433 } else {
434 raw.normalize()
435 };
436 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
437 HyperliquidExecOrderKind::Trigger {
438 trigger: HyperliquidExecTriggerParams {
439 is_market: false,
440 trigger_px: trigger_price_decimal,
441 tpsl,
442 },
443 }
444 } else {
445 anyhow::bail!("Stop limit orders require a trigger price")
446 }
447 }
448 OrderType::MarketIfTouched => {
449 if let Some(trigger_price) = order.trigger_price() {
450 let raw = trigger_price.as_decimal();
451 let trigger_price_decimal = if should_normalize_prices {
452 normalize_price(raw, price_decimals).normalize()
453 } else {
454 raw.normalize()
455 };
456 HyperliquidExecOrderKind::Trigger {
457 trigger: HyperliquidExecTriggerParams {
458 is_market: true,
459 trigger_px: trigger_price_decimal,
460 tpsl: HyperliquidExecTpSl::Tp,
461 },
462 }
463 } else {
464 anyhow::bail!("Market-if-touched orders require a trigger price")
465 }
466 }
467 OrderType::LimitIfTouched => {
468 if let Some(trigger_price) = order.trigger_price() {
469 let raw = trigger_price.as_decimal();
470 let trigger_price_decimal = if should_normalize_prices {
471 normalize_price(raw, price_decimals).normalize()
472 } else {
473 raw.normalize()
474 };
475 HyperliquidExecOrderKind::Trigger {
476 trigger: HyperliquidExecTriggerParams {
477 is_market: false,
478 trigger_px: trigger_price_decimal,
479 tpsl: HyperliquidExecTpSl::Tp,
480 },
481 }
482 } else {
483 anyhow::bail!("Limit-if-touched orders require a trigger price")
484 }
485 }
486 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
487 };
488
489 let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
490
491 Ok(HyperliquidExecPlaceOrderRequest {
492 asset,
493 is_buy,
494 price: price_decimal,
495 size: size_decimal,
496 reduce_only,
497 kind,
498 cloid,
499 })
500}
501
502pub fn derive_market_order_price(quote: &QuoteTick, is_buy: bool, price_decimals: u8) -> Decimal {
508 let base = if is_buy {
509 quote.ask_price.as_decimal()
510 } else {
511 quote.bid_price.as_decimal()
512 };
513 let derived = derive_limit_from_trigger(base, is_buy);
514 let sig_rounded = round_to_sig_figs(derived, 5);
515 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
516}
517
518pub fn derive_limit_from_trigger(trigger_price: Decimal, is_buy: bool) -> Decimal {
526 let slippage = Decimal::new(5, 3); let price = if is_buy {
528 trigger_price * (Decimal::ONE + slippage)
529 } else {
530 trigger_price * (Decimal::ONE - slippage)
531 };
532
533 price.normalize()
535}
536
537pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
540 let scale = Decimal::from(10_u64.pow(decimals as u32));
541
542 if is_buy {
543 (price * scale).ceil() / scale
544 } else {
545 (price * scale).floor() / scale
546 }
547}
548
549pub fn client_order_id_to_cancel_request_with_asset(
551 client_order_id: &str,
552 asset: u32,
553) -> HyperliquidExecCancelByCloidRequest {
554 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
555 HyperliquidExecCancelByCloidRequest { asset, cloid }
556}
557
558pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
564 let HyperliquidExchangeResponse::Status { response, .. } = response else {
565 return None;
566 };
567 let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
568 match data {
569 HyperliquidExecResponseData::Order { data } => {
570 for status in &data.statuses {
571 if let HyperliquidExecOrderStatus::Error { error } = status {
572 return Some(error.clone());
573 }
574 }
575 None
576 }
577 HyperliquidExecResponseData::Cancel { data } => {
578 for status in &data.statuses {
579 if let HyperliquidExecCancelStatus::Error { error } = status {
580 return Some(error.clone());
581 }
582 }
583 None
584 }
585 HyperliquidExecResponseData::Modify { data } => {
586 for status in &data.statuses {
587 if let HyperliquidExecModifyStatus::Error { error } = status {
588 return Some(error.clone());
589 }
590 }
591 None
592 }
593 _ => None,
594 }
595}
596
597pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
603 let HyperliquidExchangeResponse::Status { response, .. } = response else {
604 return Vec::new();
605 };
606 let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
607 return Vec::new();
608 };
609 match data {
610 HyperliquidExecResponseData::Order { data } => data
611 .statuses
612 .into_iter()
613 .map(|s| match s {
614 HyperliquidExecOrderStatus::Error { error } => Some(error),
615 _ => None,
616 })
617 .collect(),
618 _ => Vec::new(),
619 }
620}
621
622pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
624 match response {
625 HyperliquidExchangeResponse::Status { status, response } => {
626 if status == RESPONSE_STATUS_OK {
627 "Operation successful".to_string()
628 } else {
629 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
631 error_msg.to_string()
632 } else {
633 format!("Request failed with status: {status}")
634 }
635 }
636 }
637 HyperliquidExchangeResponse::Error { error } => error.clone(),
638 }
639}
640
641pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
647 trigger_px.is_some() && tpsl.is_some()
648}
649
650pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
656 match (is_market, tpsl) {
657 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
658 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
659 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
660 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
661 }
662}
663
664pub fn parse_order_status_with_trigger(
670 status: HyperliquidOrderStatus,
671 trigger_activated: Option<bool>,
672) -> (OrderStatus, Option<String>) {
673 let base_status = OrderStatus::from(status);
674
675 if let Some(activated) = trigger_activated {
677 let trigger_status = if activated {
678 Some("activated".to_string())
679 } else {
680 Some("pending".to_string())
681 };
682 (base_status, trigger_status)
683 } else {
684 (base_status, None)
685 }
686}
687
688pub fn format_trailing_stop_info(
690 offset: &str,
691 offset_type: TrailingOffsetType,
692 callback_price: Option<&str>,
693) -> String {
694 let offset_desc = offset_type.format_offset(offset);
695
696 if let Some(callback) = callback_price {
697 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
698 } else {
699 format!("Trailing stop: {offset_desc} offset")
700 }
701}
702
703pub fn validate_conditional_order_params(
709 trigger_px: Option<&str>,
710 tpsl: Option<&HyperliquidTpSl>,
711 is_market: Option<bool>,
712) -> anyhow::Result<()> {
713 if trigger_px.is_none() {
714 anyhow::bail!("Conditional order missing trigger price");
715 }
716
717 if tpsl.is_none() {
718 anyhow::bail!("Conditional order missing tpsl indicator");
719 }
720
721 if is_market.is_none() {
724 anyhow::bail!("Conditional order missing is_market flag");
725 }
726
727 Ok(())
728}
729
730pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
736 Decimal::from_str_exact(trigger_px)
737 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
738}
739
740pub fn parse_account_balances_and_margins(
746 cross_margin_summary: &CrossMarginSummary,
747) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
748 let mut balances = Vec::new();
749 let mut margins = Vec::new();
750
751 let currency = Currency::USDC();
752
753 let mut total_value = cross_margin_summary
754 .account_value
755 .to_string()
756 .parse::<f64>()?
757 .max(0.0);
758
759 let free_value = cross_margin_summary
760 .withdrawable
761 .map(|w| w.to_string().parse::<f64>())
762 .transpose()?
763 .unwrap_or(total_value)
764 .max(0.0);
765
766 if free_value > total_value {
768 total_value = free_value;
769 }
770
771 let locked_value = total_value - free_value;
772
773 let total = Money::new(total_value, currency);
774 let locked = Money::new(locked_value, currency);
775 let free = Money::new(free_value, currency);
776
777 let balance = AccountBalance::new(total, locked, free);
778 balances.push(balance);
779
780 let margin_used = cross_margin_summary
781 .total_margin_used
782 .to_string()
783 .parse::<f64>()?;
784
785 if margin_used > 0.0 {
786 let margin_instrument_id =
787 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
788
789 let initial_margin = Money::new(margin_used, currency);
790 let maintenance_margin = Money::new(margin_used, currency);
791
792 let margin_balance =
793 MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
794
795 margins.push(margin_balance);
796 }
797
798 Ok((balances, margins))
799}
800
801#[cfg(test)]
802mod tests {
803 use std::str::FromStr;
804
805 use nautilus_model::{
806 enums::{OrderSide, TimeInForce, TriggerType},
807 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
808 orders::{OrderAny, StopMarketOrder},
809 types::{Price, Quantity},
810 };
811 use rstest::rstest;
812 use rust_decimal::Decimal;
813 use rust_decimal_macros::dec;
814 use serde::{Deserialize, Serialize};
815
816 use super::*;
817
818 #[derive(Serialize, Deserialize)]
819 struct TestStruct {
820 #[serde(
821 serialize_with = "serialize_decimal_as_str",
822 deserialize_with = "deserialize_decimal_from_str"
823 )]
824 value: Decimal,
825 #[serde(
826 serialize_with = "serialize_optional_decimal_as_str",
827 deserialize_with = "deserialize_optional_decimal_from_str"
828 )]
829 optional_value: Option<Decimal>,
830 }
831
832 #[rstest]
833 fn test_decimal_serialization_roundtrip() {
834 let original = TestStruct {
835 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
836 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
837 };
838
839 let json = serde_json::to_string(&original).unwrap();
840 println!("Serialized: {json}");
841
842 assert!(json.contains("\"123.45678901234567890123456789\""));
844 assert!(json.contains("\"0.000000001\""));
845
846 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
847 assert_eq!(original.value, deserialized.value);
848 assert_eq!(original.optional_value, deserialized.optional_value);
849 }
850
851 #[rstest]
852 fn test_decimal_precision_preservation() {
853 let test_cases = [
854 "0",
855 "1",
856 "0.1",
857 "0.01",
858 "0.001",
859 "123.456789012345678901234567890",
860 "999999999999999999.999999999999999999",
861 ];
862
863 for case in test_cases {
864 let decimal = Decimal::from_str(case).unwrap();
865 let test_struct = TestStruct {
866 value: decimal,
867 optional_value: Some(decimal),
868 };
869
870 let json = serde_json::to_string(&test_struct).unwrap();
871 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
872
873 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
874 assert_eq!(
875 Some(decimal),
876 parsed.optional_value,
877 "Failed for case: {case}"
878 );
879 }
880 }
881
882 #[rstest]
883 fn test_optional_none_handling() {
884 let test_struct = TestStruct {
885 value: Decimal::from_str("42.0").unwrap(),
886 optional_value: None,
887 };
888
889 let json = serde_json::to_string(&test_struct).unwrap();
890 assert!(json.contains("null"));
891
892 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
893 assert_eq!(test_struct.value, parsed.value);
894 assert_eq!(None, parsed.optional_value);
895 }
896
897 #[rstest]
898 fn test_round_down_to_tick() {
899 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
900 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
901 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
902
903 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
905 }
906
907 #[rstest]
908 fn test_round_down_to_step() {
909 assert_eq!(
910 round_down_to_step(dec!(0.12349), dec!(0.0001)),
911 dec!(0.1234)
912 );
913 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
914 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
915
916 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
918 }
919
920 #[rstest]
921 fn test_min_notional_validation() {
922 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
924 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
925
926 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
928 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
929
930 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
932 }
933
934 #[rstest]
935 fn test_round_to_sig_figs() {
936 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
938 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
939 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
940
941 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
943 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
944 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
945
946 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
948 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
952 }
953
954 #[rstest]
955 fn test_normalize_price() {
956 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
958 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));
964 }
965
966 #[rstest]
967 fn test_normalize_quantity() {
968 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
969 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
970 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
971 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
972 }
973
974 #[rstest]
975 fn test_normalize_order_complete() {
976 let result = normalize_order(
977 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
985
986 assert!(result.is_ok());
987 let (price, qty) = result.unwrap();
988 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
991
992 #[rstest]
993 fn test_normalize_order_min_notional_fail() {
994 let result = normalize_order(
995 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1003
1004 assert!(result.is_err());
1005 assert!(result.unwrap_err().contains("Notional value"));
1006 }
1007
1008 #[rstest]
1009 fn test_edge_cases() {
1010 assert_eq!(
1012 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1013 dec!(0.000001)
1014 );
1015
1016 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1018
1019 assert_eq!(
1021 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1022 dec!(100.00)
1023 );
1024 }
1025
1026 #[rstest]
1027 fn test_is_conditional_order_data() {
1028 assert!(is_conditional_order_data(
1030 Some("50000.0"),
1031 Some(&HyperliquidTpSl::Sl)
1032 ));
1033
1034 assert!(!is_conditional_order_data(Some("50000.0"), None));
1036
1037 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1039
1040 assert!(!is_conditional_order_data(None, None));
1042 }
1043
1044 #[rstest]
1045 fn test_parse_trigger_order_type() {
1046 assert_eq!(
1048 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1049 OrderType::StopMarket
1050 );
1051
1052 assert_eq!(
1054 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1055 OrderType::StopLimit
1056 );
1057
1058 assert_eq!(
1060 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1061 OrderType::MarketIfTouched
1062 );
1063
1064 assert_eq!(
1066 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1067 OrderType::LimitIfTouched
1068 );
1069 }
1070
1071 #[rstest]
1072 fn test_parse_order_status_with_trigger() {
1073 let (status, trigger_status) =
1075 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1076 assert_eq!(status, OrderStatus::Accepted);
1077 assert_eq!(trigger_status, Some("activated".to_string()));
1078
1079 let (status, trigger_status) =
1081 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1082 assert_eq!(status, OrderStatus::Accepted);
1083 assert_eq!(trigger_status, Some("pending".to_string()));
1084
1085 let (status, trigger_status) =
1087 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1088 assert_eq!(status, OrderStatus::Accepted);
1089 assert_eq!(trigger_status, None);
1090 }
1091
1092 #[rstest]
1093 fn test_format_trailing_stop_info() {
1094 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1096 assert!(info.contains("100.0"));
1097 assert!(info.contains("callback at 50000.0"));
1098
1099 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1101 assert!(info.contains("5.0%"));
1102 assert!(info.contains("Trailing stop"));
1103
1104 let info =
1106 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1107 assert!(info.contains("250 bps"));
1108 assert!(info.contains("49000.0"));
1109 }
1110
1111 #[rstest]
1112 fn test_parse_trigger_price() {
1113 let result = parse_trigger_price("50000.0");
1115 assert!(result.is_ok());
1116 assert_eq!(result.unwrap(), dec!(50000.0));
1117
1118 let result = parse_trigger_price("49000");
1120 assert!(result.is_ok());
1121 assert_eq!(result.unwrap(), dec!(49000));
1122
1123 let result = parse_trigger_price("invalid");
1125 assert!(result.is_err());
1126
1127 let result = parse_trigger_price("");
1129 assert!(result.is_err());
1130 }
1131
1132 #[rstest]
1133 #[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(
1144 #[case] trigger_price: Decimal,
1145 #[case] is_buy: bool,
1146 #[case] expected: Decimal,
1147 ) {
1148 let result = derive_limit_from_trigger(trigger_price, is_buy);
1149 assert_eq!(result, expected);
1150
1151 if is_buy {
1153 assert!(result >= trigger_price);
1154 } else {
1155 assert!(result <= trigger_price);
1156 }
1157 }
1158
1159 #[rstest]
1160 #[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))]
1170 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1171 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1173 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1174 fn test_clamp_price_to_precision(
1175 #[case] price: Decimal,
1176 #[case] decimals: u8,
1177 #[case] is_buy: bool,
1178 #[case] expected: Decimal,
1179 ) {
1180 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1181 }
1182
1183 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1184 OrderAny::StopMarket(StopMarketOrder::new(
1185 TraderId::from("TESTER-001"),
1186 StrategyId::from("S-001"),
1187 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1188 ClientOrderId::from("O-001"),
1189 side,
1190 Quantity::from(1),
1191 Price::from(trigger_price),
1192 TriggerType::LastPrice,
1193 TimeInForce::Gtc,
1194 None,
1195 false,
1196 false,
1197 None,
1198 None,
1199 None,
1200 None,
1201 None,
1202 None,
1203 None,
1204 None,
1205 None,
1206 None,
1207 None,
1208 Default::default(),
1209 Default::default(),
1210 ))
1211 }
1212
1213 #[rstest]
1214 #[case(OrderSide::Sell, "2470.00", 2)]
1216 #[case(OrderSide::Buy, "2470.00", 2)]
1217 #[case(OrderSide::Sell, "104567.3", 1)]
1219 #[case(OrderSide::Buy, "104567.3", 1)]
1220 #[case(OrderSide::Sell, "0.50", 4)]
1222 #[case(OrderSide::Buy, "0.50", 4)]
1223 #[case(OrderSide::Sell, "2470.00", 1)]
1227 #[case(OrderSide::Buy, "2470.00", 1)]
1228 #[case(OrderSide::Sell, "2470.00", 0)]
1232 #[case(OrderSide::Buy, "2470.00", 0)]
1233 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1234 #[case] side: OrderSide,
1235 #[case] trigger_str: &str,
1236 #[case] price_decimals: u8,
1237 ) {
1238 let order = stop_market_order(side, trigger_str);
1239 let request =
1240 order_to_hyperliquid_request_with_asset(&order, 0, price_decimals, true).unwrap();
1241 let trigger = Decimal::from_str(trigger_str).unwrap();
1242 let is_buy = matches!(side, OrderSide::Buy);
1243
1244 if is_buy {
1246 assert!(
1247 request.price >= trigger,
1248 "BUY limit {} must be >= trigger {trigger}",
1249 request.price,
1250 );
1251 assert!(request.is_buy);
1252 } else {
1253 assert!(
1254 request.price <= trigger,
1255 "SELL limit {} must be <= trigger {trigger}",
1256 request.price,
1257 );
1258 assert!(!request.is_buy);
1259 }
1260
1261 let derived = derive_limit_from_trigger(trigger, is_buy);
1263 let sig_rounded = round_to_sig_figs(derived, 5);
1264 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1265 assert_eq!(request.price, expected);
1266
1267 let price_str = request.price.to_string();
1269 let actual_decimals = price_str
1270 .find('.')
1271 .map_or(0, |dot| price_str.len() - dot - 1);
1272 assert!(
1273 actual_decimals <= price_decimals as usize,
1274 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1275 );
1276
1277 if price_str.contains('.') {
1279 assert!(
1280 !price_str.ends_with('0'),
1281 "Price {price_str} has decimal trailing zeros",
1282 );
1283 }
1284
1285 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1286 assert_eq!(
1287 request.kind,
1288 HyperliquidExecOrderKind::Trigger {
1289 trigger: HyperliquidExecTriggerParams {
1290 is_market: true,
1291 trigger_px: expected_trigger,
1292 tpsl: HyperliquidExecTpSl::Sl,
1293 },
1294 },
1295 );
1296 }
1297
1298 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1299 HyperliquidExchangeResponse::Status {
1300 status: "ok".to_string(),
1301 response: inner,
1302 }
1303 }
1304
1305 #[rstest]
1306 fn test_extract_inner_error_order_with_error() {
1307 let response = ok_response(serde_json::json!({
1308 "type": "order",
1309 "data": {"statuses": [{"error": "Order has invalid price."}]}
1310 }));
1311 assert_eq!(
1312 extract_inner_error(&response),
1313 Some("Order has invalid price.".to_string()),
1314 );
1315 }
1316
1317 #[rstest]
1318 fn test_extract_inner_error_order_resting() {
1319 let response = ok_response(serde_json::json!({
1320 "type": "order",
1321 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1322 }));
1323 assert_eq!(extract_inner_error(&response), None);
1324 }
1325
1326 #[rstest]
1327 fn test_extract_inner_error_order_filled() {
1328 let response = ok_response(serde_json::json!({
1329 "type": "order",
1330 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1331 }));
1332 assert_eq!(extract_inner_error(&response), None);
1333 }
1334
1335 #[rstest]
1336 fn test_extract_inner_error_cancel_error() {
1337 let response = ok_response(serde_json::json!({
1338 "type": "cancel",
1339 "data": {"statuses": [{"error": "Order not found"}]}
1340 }));
1341 assert_eq!(
1342 extract_inner_error(&response),
1343 Some("Order not found".to_string()),
1344 );
1345 }
1346
1347 #[rstest]
1348 fn test_extract_inner_error_cancel_success() {
1349 let response = ok_response(serde_json::json!({
1350 "type": "cancel",
1351 "data": {"statuses": ["success"]}
1352 }));
1353 assert_eq!(extract_inner_error(&response), None);
1354 }
1355
1356 #[rstest]
1357 fn test_extract_inner_error_modify_error() {
1358 let response = ok_response(serde_json::json!({
1359 "type": "modify",
1360 "data": {"statuses": [{"error": "Invalid modify"}]}
1361 }));
1362 assert_eq!(
1363 extract_inner_error(&response),
1364 Some("Invalid modify".to_string()),
1365 );
1366 }
1367
1368 #[rstest]
1369 fn test_extract_inner_error_modify_success() {
1370 let response = ok_response(serde_json::json!({
1371 "type": "modify",
1372 "data": {"statuses": ["success"]}
1373 }));
1374 assert_eq!(extract_inner_error(&response), None);
1375 }
1376
1377 #[rstest]
1378 fn test_extract_inner_error_non_status_response() {
1379 let response = HyperliquidExchangeResponse::Error {
1380 error: "top-level error".to_string(),
1381 };
1382 assert_eq!(extract_inner_error(&response), None);
1383 }
1384
1385 #[rstest]
1386 fn test_extract_inner_error_unparsable_response() {
1387 let response = ok_response(serde_json::json!({"unknown": "data"}));
1388 assert_eq!(extract_inner_error(&response), None);
1389 }
1390
1391 #[rstest]
1392 fn test_extract_inner_error_returns_first_error_in_batch() {
1393 let response = ok_response(serde_json::json!({
1394 "type": "order",
1395 "data": {"statuses": [
1396 {"resting": {"oid": 1}},
1397 {"error": "Second failed"},
1398 {"error": "Third failed"},
1399 ]}
1400 }));
1401 assert_eq!(
1402 extract_inner_error(&response),
1403 Some("Second failed".to_string()),
1404 );
1405 }
1406
1407 #[rstest]
1408 fn test_extract_inner_errors_mixed_batch() {
1409 let response = ok_response(serde_json::json!({
1410 "type": "order",
1411 "data": {"statuses": [
1412 {"resting": {"oid": 1}},
1413 {"error": "Failed order"},
1414 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1415 ]}
1416 }));
1417 let errors = extract_inner_errors(&response);
1418 assert_eq!(errors.len(), 3);
1419 assert_eq!(errors[0], None);
1420 assert_eq!(errors[1], Some("Failed order".to_string()));
1421 assert_eq!(errors[2], None);
1422 }
1423
1424 #[rstest]
1425 fn test_extract_inner_errors_all_success() {
1426 let response = ok_response(serde_json::json!({
1427 "type": "order",
1428 "data": {"statuses": [
1429 {"resting": {"oid": 1}},
1430 {"resting": {"oid": 2}},
1431 ]}
1432 }));
1433 let errors = extract_inner_errors(&response);
1434 assert_eq!(errors.len(), 2);
1435 assert!(errors.iter().all(|e| e.is_none()));
1436 }
1437
1438 #[rstest]
1439 fn test_extract_inner_errors_non_order_response() {
1440 let response = ok_response(serde_json::json!({
1441 "type": "cancel",
1442 "data": {"statuses": ["success"]}
1443 }));
1444 let errors = extract_inner_errors(&response);
1445 assert!(errors.is_empty());
1446 }
1447
1448 #[rstest]
1449 fn test_extract_inner_errors_unparsable() {
1450 let response = ok_response(serde_json::json!({"foo": "bar"}));
1451 let errors = extract_inner_errors(&response);
1452 assert!(errors.is_empty());
1453 }
1454
1455 fn count_sig_figs(s: &str) -> usize {
1456 let s = s.trim_start_matches('-');
1457 if s.contains('.') {
1458 let digits: String = s.replace('.', "");
1460 digits.trim_start_matches('0').len()
1461 } else {
1462 let s = s.trim_start_matches('0');
1464 s.trim_end_matches('0').len()
1465 }
1466 }
1467
1468 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1469 QuoteTick::new(
1470 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1471 Price::from(bid),
1472 Price::from(ask),
1473 Quantity::from("1"),
1474 Quantity::from("1"),
1475 Default::default(),
1476 Default::default(),
1477 )
1478 }
1479
1480 #[rstest]
1481 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1487 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1489 #[case("104500.0", "104567.3", true, 1, "105090")]
1493 #[case("104500.0", "104567.3", false, 1, "103980")]
1495 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1499 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1501 #[case("49900", "50000", true, 0, "50250")]
1505 #[case("49900", "50000", false, 0, "49650")]
1507 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1511 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1513 fn test_derive_market_order_price(
1514 #[case] bid: &str,
1515 #[case] ask: &str,
1516 #[case] is_buy: bool,
1517 #[case] price_decimals: u8,
1518 #[case] expected: &str,
1519 ) {
1520 let quote = make_quote(bid, ask);
1521 let result = derive_market_order_price("e, is_buy, price_decimals);
1522 let expected_dec = Decimal::from_str(expected).unwrap();
1523 assert_eq!(result, expected_dec);
1524
1525 let base = if is_buy {
1527 quote.ask_price.as_decimal()
1528 } else {
1529 quote.bid_price.as_decimal()
1530 };
1531 let derived = derive_limit_from_trigger(base, is_buy);
1532 let sig_rounded = round_to_sig_figs(derived, 5);
1533 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1534 assert_eq!(result, pipeline);
1535
1536 let s = result.to_string();
1538 if s.contains('.') {
1539 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
1540 }
1541
1542 let sig_count = count_sig_figs(&s);
1544 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
1545
1546 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
1548 assert!(
1549 actual_decimals <= price_decimals as usize,
1550 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
1551 );
1552 }
1553}