1use anyhow::Context;
61use nautilus_core::UnixNanos;
62pub use nautilus_core::serialization::{
63 deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
64 deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
65 serialize_vec_decimal_as_str,
66};
67use nautilus_model::{
68 data::{bar::BarType, quote::QuoteTick},
69 enums::{
70 AggregationSource, BarAggregation, ContingencyType, OrderSide, OrderStatus, OrderType,
71 TimeInForce,
72 },
73 identifiers::{ClientOrderId, TradeId},
74 orders::{Order, any::OrderAny},
75 types::{AccountBalance, Currency, MarginBalance, Money},
76};
77use rust_decimal::Decimal;
78
79use crate::{
80 common::{
81 enums::{
82 HyperliquidAccountAbstraction,
83 HyperliquidBarInterval::{self, *},
84 HyperliquidOrderStatus, HyperliquidTpSl,
85 },
86 types::HyperliquidAssetId,
87 },
88 http::models::{
89 ClearinghouseState, Cloid, HyperliquidExchangeCancelByCloidRequest,
90 HyperliquidExchangeCancelStatus, HyperliquidExchangeGrouping,
91 HyperliquidExchangeLimitParams, HyperliquidExchangeModifyStatus,
92 HyperliquidExchangeOrderKind, HyperliquidExchangeOrderStatus,
93 HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
94 HyperliquidExchangeResponseData, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
95 HyperliquidExchangeTriggerParams, RESPONSE_STATUS_OK, SpotClearinghouseState,
96 },
97 websocket::messages::TrailingOffsetType,
98};
99
100pub fn make_fill_trade_id(
108 hash: &str,
109 oid: u64,
110 px: Decimal,
111 sz: Decimal,
112 time: u64,
113 start_position: Decimal,
114) -> TradeId {
115 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
117 for &b in hash.as_bytes() {
118 h ^= b as u64;
119 h = h.wrapping_mul(0x0100_0000_01b3);
120 }
121
122 for b in oid.to_le_bytes() {
123 h ^= b as u64;
124 h = h.wrapping_mul(0x0100_0000_01b3);
125 }
126
127 for &b in px.to_string().as_bytes() {
128 h ^= b as u64;
129 h = h.wrapping_mul(0x0100_0000_01b3);
130 }
131
132 for &b in sz.to_string().as_bytes() {
133 h ^= b as u64;
134 h = h.wrapping_mul(0x0100_0000_01b3);
135 }
136
137 for b in time.to_le_bytes() {
138 h ^= b as u64;
139 h = h.wrapping_mul(0x0100_0000_01b3);
140 }
141
142 for &b in start_position.to_string().as_bytes() {
143 h ^= b as u64;
144 h = h.wrapping_mul(0x0100_0000_01b3);
145 }
146 TradeId::new(format!("{h:016x}-{oid:016x}"))
147}
148
149#[inline]
151pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
152 if tick_size.is_zero() {
153 return price;
154 }
155 (price / tick_size).floor() * tick_size
156}
157
158#[inline]
160pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
161 if step_size.is_zero() {
162 return qty;
163 }
164 (qty / step_size).floor() * step_size
165}
166
167#[inline]
169pub fn ensure_min_notional(
170 price: Decimal,
171 qty: Decimal,
172 min_notional: Decimal,
173) -> Result<(), String> {
174 let notional = price * qty;
175 if notional < min_notional {
176 Err(format!(
177 "Notional value {notional} is less than minimum required {min_notional}"
178 ))
179 } else {
180 Ok(())
181 }
182}
183
184pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
187 if value.is_zero() {
188 return Decimal::ZERO;
189 }
190
191 let mantissa = value.mantissa().unsigned_abs();
193 let magnitude = mantissa.ilog10() as i32 - value.scale() as i32;
194
195 let shift = sig_figs as i32 - 1 - magnitude;
196 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
197
198 if shift >= 0 {
199 (value * factor).round() / factor
200 } else {
201 (value / factor).round() * factor
202 }
203}
204
205pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
207 let sig_fig_price = round_to_sig_figs(price, 5);
209 let scale = Decimal::from(10_u64.pow(decimals as u32));
211 (sig_fig_price * scale).floor() / scale
212}
213
214pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
216 let scale = Decimal::from(10_u64.pow(decimals as u32));
217 (qty * scale).floor() / scale
218}
219
220fn ensure_canonical_wire_price(
227 label: &str,
228 price: Decimal,
229 price_decimals: u8,
230) -> anyhow::Result<()> {
231 anyhow::ensure!(
232 price.scale() <= u32::from(price_decimals),
233 "{label} {price} exceeds the instrument maximum of {price_decimals} decimal places; \
234 enable normalize_prices or adjust the price"
235 );
236 Ok(())
237}
238
239pub(crate) fn normalize_or_validate_wire_price(
245 raw: Decimal,
246 label: &str,
247 price_decimals: Option<u8>,
248 should_normalize_prices: bool,
249) -> anyhow::Result<Decimal> {
250 if should_normalize_prices {
251 Ok(normalize_price(raw, price_decimals.unwrap_or(2)).normalize())
252 } else {
253 let value = raw.normalize();
254 if let Some(decimals) = price_decimals {
255 ensure_canonical_wire_price(label, value, decimals)?;
256 }
257 Ok(value)
258 }
259}
260
261pub fn normalize_order(
263 price: Decimal,
264 qty: Decimal,
265 tick_size: Decimal,
266 step_size: Decimal,
267 min_notional: Decimal,
268 price_decimals: u8,
269 size_decimals: u8,
270) -> Result<(Decimal, Decimal), String> {
271 let normalized_price = normalize_price(price, price_decimals);
273 let normalized_qty = normalize_quantity(qty, size_decimals);
274
275 let final_price = round_down_to_tick(normalized_price, tick_size);
277 let final_qty = round_down_to_step(normalized_qty, step_size);
278
279 ensure_min_notional(final_price, final_qty, min_notional)?;
281
282 Ok((final_price, final_qty))
283}
284
285#[inline]
287pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
288 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
289 Ok(UnixNanos::from(value))
290}
291
292pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
302 let encoding = parse_outcome_symbol_encoding(symbol)?;
303 HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
304 format!(
305 "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
306 )
307 })
308}
309
310fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
311 let encoding = symbol
312 .strip_prefix('#')
313 .or_else(|| symbol.strip_prefix('+'))
314 .with_context(|| {
315 format!(
316 "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
317 )
318 })?;
319
320 if encoding.is_empty() {
321 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
322 }
323
324 if !encoding.bytes().all(|b| b.is_ascii_digit()) {
325 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
326 }
327
328 encoding
329 .parse::<u32>()
330 .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
331}
332
333pub const OUTCOME_SYMBOL_SUFFIX: &str = "-OUTCOME";
335pub const OUTCOME_SIDE_YES: &str = "YES";
337pub const OUTCOME_SIDE_NO: &str = "NO";
339
340#[must_use]
351pub fn parse_outcome_nautilus_symbol(symbol: &str) -> Option<(u32, u8)> {
352 let rest = symbol.strip_suffix(OUTCOME_SYMBOL_SUFFIX)?;
353 let (index_str, side_str) = rest.rsplit_once('-')?;
354 let outcome_index = index_str.parse::<u32>().ok()?;
355 let side = match side_str {
356 OUTCOME_SIDE_YES => 0,
357 OUTCOME_SIDE_NO => 1,
358 _ => return None,
359 };
360 let encoding = outcome_index
361 .checked_mul(10)?
362 .checked_add(u32::from(side))?;
363 HyperliquidAssetId::from_outcome_encoding(encoding)?;
364 Some((outcome_index, side))
365}
366
367#[must_use]
370pub fn format_outcome_nautilus_symbol(outcome_index: u32, side: u8) -> String {
371 let side_label = match side {
372 0 => OUTCOME_SIDE_YES,
373 _ => OUTCOME_SIDE_NO,
374 };
375 format!("{outcome_index}-{side_label}{OUTCOME_SYMBOL_SUFFIX}")
376}
377
378#[must_use]
381pub fn outcome_token_from_nautilus_symbol(symbol: &str) -> Option<String> {
382 let (outcome_index, side) = parse_outcome_nautilus_symbol(symbol)?;
383 let encoding = 10 * outcome_index + u32::from(side);
384 Some(format!("+{encoding}"))
385}
386
387#[must_use]
398pub fn cache_alias_for_symbol(symbol: &str) -> Option<String> {
399 if let Some(token) = outcome_token_from_nautilus_symbol(symbol) {
400 return Some(token);
401 }
402
403 let leading = symbol.split('-').next()?;
404 if leading.is_empty() {
405 None
406 } else {
407 Some(leading.to_string())
408 }
409}
410
411pub fn time_in_force_to_hyperliquid_tif(
417 tif: TimeInForce,
418 is_post_only: bool,
419) -> anyhow::Result<HyperliquidExchangeTif> {
420 match (tif, is_post_only) {
421 (_, true) => Ok(HyperliquidExchangeTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExchangeTif::Gtc),
423 (TimeInForce::Ioc, false) => Ok(HyperliquidExchangeTif::Ioc),
424 (TimeInForce::Fok, false) => {
425 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
426 }
427 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
428 }
429}
430
431fn determine_tpsl_type(
432 order_type: OrderType,
433 order_side: OrderSide,
434 trigger_price: Decimal,
435 current_price: Option<Decimal>,
436) -> HyperliquidExchangeTpSl {
437 match order_type {
438 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
440
441 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExchangeTpSl::Tp,
443
444 _ => {
446 if let Some(current) = current_price {
447 match order_side {
448 OrderSide::Buy => {
449 if trigger_price > current {
451 HyperliquidExchangeTpSl::Sl
452 } else {
453 HyperliquidExchangeTpSl::Tp
454 }
455 }
456 OrderSide::Sell => {
457 if trigger_price < current {
459 HyperliquidExchangeTpSl::Sl
460 } else {
461 HyperliquidExchangeTpSl::Tp
462 }
463 }
464 }
465 } else {
466 HyperliquidExchangeTpSl::Sl
468 }
469 }
470 }
471}
472
473pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
479 let spec = bar_type.spec();
480 let step = spec.step.get();
481
482 anyhow::ensure!(
483 bar_type.aggregation_source() == AggregationSource::External,
484 "Only EXTERNAL aggregation is supported"
485 );
486
487 let interval = match spec.aggregation {
488 BarAggregation::Minute => match step {
489 1 => OneMinute,
490 3 => ThreeMinutes,
491 5 => FiveMinutes,
492 15 => FifteenMinutes,
493 30 => ThirtyMinutes,
494 _ => anyhow::bail!("Unsupported minute step: {step}"),
495 },
496 BarAggregation::Hour => match step {
497 1 => OneHour,
498 2 => TwoHours,
499 4 => FourHours,
500 8 => EightHours,
501 12 => TwelveHours,
502 _ => anyhow::bail!("Unsupported hour step: {step}"),
503 },
504 BarAggregation::Day => match step {
505 1 => OneDay,
506 3 => ThreeDays,
507 _ => anyhow::bail!("Unsupported day step: {step}"),
508 },
509 BarAggregation::Week if step == 1 => OneWeek,
510 BarAggregation::Month if step == 1 => OneMonth,
511 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
512 };
513
514 Ok(interval)
515}
516
517pub fn order_to_hyperliquid_request_with_asset(
524 order: &OrderAny,
525 asset: u32,
526 price_decimals: u8,
527 should_normalize_prices: bool,
528 slippage_bps: u32,
529) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
530 order_to_hyperliquid_request_with_asset_and_cloid(
531 order,
532 asset,
533 price_decimals,
534 should_normalize_prices,
535 slippage_bps,
536 Some(Cloid::from_client_order_id(order.client_order_id())),
537 )
538}
539
540pub fn order_to_hyperliquid_request_with_asset_and_cloid(
542 order: &OrderAny,
543 asset: u32,
544 price_decimals: u8,
545 should_normalize_prices: bool,
546 slippage_bps: u32,
547 cloid: Option<Cloid>,
548) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
549 order_to_hyperliquid_request_with_optional_decimals(
550 order,
551 asset,
552 Some(price_decimals),
553 should_normalize_prices,
554 slippage_bps,
555 cloid,
556 )
557}
558
559pub(crate) fn order_to_hyperliquid_request_with_optional_decimals(
563 order: &OrderAny,
564 asset: u32,
565 price_decimals: Option<u8>,
566 should_normalize_prices: bool,
567 slippage_bps: u32,
568 cloid: Option<Cloid>,
569) -> anyhow::Result<HyperliquidExchangePlaceOrderRequest> {
570 let is_buy = matches!(order.order_side(), OrderSide::Buy);
571 let reduce_only = order.is_reduce_only();
572 let order_side = order.order_side();
573 let order_type = order.order_type();
574
575 let normalize_or_validate = |raw: Decimal, label: &str| {
576 normalize_or_validate_wire_price(raw, label, price_decimals, should_normalize_prices)
577 };
578
579 let price_decimal = if let Some(price) = order.price() {
582 normalize_or_validate(price.as_decimal(), "Price")?
583 } else if matches!(order_type, OrderType::Market) {
584 Decimal::ZERO
585 } else if matches!(
586 order_type,
587 OrderType::StopMarket | OrderType::MarketIfTouched
588 ) {
589 match order.trigger_price() {
590 Some(tp) => {
591 let base = tp.as_decimal().normalize();
592 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
593 let sig_rounded = round_to_sig_figs(derived, 5);
594 clamp_price_to_precision(sig_rounded, price_decimals.unwrap_or(2), is_buy)
595 .normalize()
596 }
597 None => Decimal::ZERO,
598 }
599 } else {
600 anyhow::bail!("Limit orders require a price")
601 };
602
603 let size_decimal = order.quantity().as_decimal().normalize();
604
605 let kind = match order_type {
607 OrderType::Market => HyperliquidExchangeOrderKind::Limit {
608 limit: HyperliquidExchangeLimitParams {
609 tif: HyperliquidExchangeTif::Ioc,
610 },
611 },
612 OrderType::Limit => {
613 let tif =
614 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
615 HyperliquidExchangeOrderKind::Limit {
616 limit: HyperliquidExchangeLimitParams { tif },
617 }
618 }
619 OrderType::StopMarket => {
620 if let Some(trigger_price) = order.trigger_price() {
621 let trigger_price_decimal =
622 normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
623 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
624 HyperliquidExchangeOrderKind::Trigger {
625 trigger: HyperliquidExchangeTriggerParams {
626 is_market: true,
627 trigger_px: trigger_price_decimal,
628 tpsl,
629 },
630 }
631 } else {
632 anyhow::bail!("Stop market orders require a trigger price")
633 }
634 }
635 OrderType::StopLimit => {
636 if let Some(trigger_price) = order.trigger_price() {
637 let trigger_price_decimal =
638 normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
639 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
640 HyperliquidExchangeOrderKind::Trigger {
641 trigger: HyperliquidExchangeTriggerParams {
642 is_market: false,
643 trigger_px: trigger_price_decimal,
644 tpsl,
645 },
646 }
647 } else {
648 anyhow::bail!("Stop limit orders require a trigger price")
649 }
650 }
651 OrderType::MarketIfTouched => {
652 if let Some(trigger_price) = order.trigger_price() {
653 let trigger_price_decimal =
654 normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
655 HyperliquidExchangeOrderKind::Trigger {
656 trigger: HyperliquidExchangeTriggerParams {
657 is_market: true,
658 trigger_px: trigger_price_decimal,
659 tpsl: HyperliquidExchangeTpSl::Tp,
660 },
661 }
662 } else {
663 anyhow::bail!("Market-if-touched orders require a trigger price")
664 }
665 }
666 OrderType::LimitIfTouched => {
667 if let Some(trigger_price) = order.trigger_price() {
668 let trigger_price_decimal =
669 normalize_or_validate(trigger_price.as_decimal(), "Trigger price")?;
670 HyperliquidExchangeOrderKind::Trigger {
671 trigger: HyperliquidExchangeTriggerParams {
672 is_market: false,
673 trigger_px: trigger_price_decimal,
674 tpsl: HyperliquidExchangeTpSl::Tp,
675 },
676 }
677 } else {
678 anyhow::bail!("Limit-if-touched orders require a trigger price")
679 }
680 }
681 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
682 };
683
684 Ok(HyperliquidExchangePlaceOrderRequest {
685 asset,
686 is_buy,
687 price: price_decimal,
688 size: size_decimal,
689 reduce_only,
690 kind,
691 cloid,
692 })
693}
694
695pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
697
698pub fn derive_market_order_price(
702 quote: &QuoteTick,
703 is_buy: bool,
704 price_decimals: u8,
705 slippage_bps: u32,
706) -> Decimal {
707 let base = if is_buy {
708 quote.ask_price.as_decimal()
709 } else {
710 quote.bid_price.as_decimal()
711 };
712 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
713 let sig_rounded = round_to_sig_figs(derived, 5);
714 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
715}
716
717pub fn derive_limit_from_trigger(
721 trigger_price: Decimal,
722 is_buy: bool,
723 slippage_bps: u32,
724) -> Decimal {
725 let slippage = Decimal::new(slippage_bps as i64, 4);
727 let price = if is_buy {
728 trigger_price * (Decimal::ONE + slippage)
729 } else {
730 trigger_price * (Decimal::ONE - slippage)
731 };
732
733 price.normalize()
735}
736
737pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
740 let scale = Decimal::from(10_u64.pow(decimals as u32));
741
742 if is_buy {
743 (price * scale).ceil() / scale
744 } else {
745 (price * scale).floor() / scale
746 }
747}
748
749pub fn client_order_id_to_cancel_request_with_asset(
751 client_order_id: &str,
752 asset: u32,
753) -> HyperliquidExchangeCancelByCloidRequest {
754 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
755 HyperliquidExchangeCancelByCloidRequest { asset, cloid }
756}
757
758pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
764 let HyperliquidExchangeResponse::Status { response, .. } = response else {
765 return None;
766 };
767 let data: HyperliquidExchangeResponseData = serde_json::from_value(response.clone()).ok()?;
768 match data {
769 HyperliquidExchangeResponseData::Order { data } => {
770 for status in &data.statuses {
771 if let HyperliquidExchangeOrderStatus::Error { error } = status {
772 return Some(error.clone());
773 }
774 }
775 None
776 }
777 HyperliquidExchangeResponseData::Cancel { data } => {
778 for status in &data.statuses {
779 if let HyperliquidExchangeCancelStatus::Error { error } = status {
780 return Some(error.clone());
781 }
782 }
783 None
784 }
785 HyperliquidExchangeResponseData::Modify { data } => {
786 for status in &data.statuses {
787 if let HyperliquidExchangeModifyStatus::Error { error } = status {
788 return Some(error.clone());
789 }
790 }
791 None
792 }
793 _ => None,
794 }
795}
796
797pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
803 let HyperliquidExchangeResponse::Status { response, .. } = response else {
804 return Vec::new();
805 };
806 let Ok(data) = serde_json::from_value::<HyperliquidExchangeResponseData>(response.clone())
807 else {
808 return Vec::new();
809 };
810
811 match data {
812 HyperliquidExchangeResponseData::Order { data } => data
813 .statuses
814 .into_iter()
815 .map(|s| match s {
816 HyperliquidExchangeOrderStatus::Error { error } => Some(error),
817 _ => None,
818 })
819 .collect(),
820 HyperliquidExchangeResponseData::Cancel { data } => data
821 .statuses
822 .into_iter()
823 .map(|s| match s {
824 HyperliquidExchangeCancelStatus::Error { error } => Some(error),
825 HyperliquidExchangeCancelStatus::Success(_) => None,
826 })
827 .collect(),
828 HyperliquidExchangeResponseData::Modify { data } => data
829 .statuses
830 .into_iter()
831 .map(|s| match s {
832 HyperliquidExchangeModifyStatus::Error { error } => Some(error),
833 HyperliquidExchangeModifyStatus::Success(_) => None,
834 })
835 .collect(),
836 _ => Vec::new(),
837 }
838}
839
840pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
842 match response {
843 HyperliquidExchangeResponse::Status { status, response } => {
844 if status == RESPONSE_STATUS_OK {
845 "Operation successful".to_string()
846 } else {
847 if let Some(error_msg) = response
849 .as_str()
850 .or_else(|| response.get("error").and_then(|v| v.as_str()))
851 .or_else(|| {
852 (response.get("type").and_then(|v| v.as_str()) == Some("error"))
853 .then(|| response.get("data").and_then(|v| v.as_str()))
854 .flatten()
855 })
856 {
857 error_msg.to_string()
858 } else {
859 format!("Request failed with status: {status}")
860 }
861 }
862 }
863 HyperliquidExchangeResponse::Error { error } => error.clone(),
864 }
865}
866
867pub fn is_conditional_order_data(
873 trigger_px: Option<Decimal>,
874 tpsl: Option<&HyperliquidTpSl>,
875) -> bool {
876 trigger_px.is_some() && tpsl.is_some()
877}
878
879pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
885 match (is_market, tpsl) {
886 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
887 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
888 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
889 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
890 }
891}
892
893#[must_use]
904pub fn parse_trigger_order_type_label(label: &str) -> Option<(HyperliquidTpSl, bool)> {
905 let tpsl = if label.starts_with("Take Profit") {
906 HyperliquidTpSl::Tp
907 } else if label.starts_with("Stop") {
908 HyperliquidTpSl::Sl
909 } else {
910 return None;
911 };
912 Some((tpsl, label.ends_with("Market")))
913}
914
915pub fn parse_order_status_with_trigger(
921 status: HyperliquidOrderStatus,
922 trigger_activated: Option<bool>,
923) -> (OrderStatus, Option<String>) {
924 let base_status = OrderStatus::from(status);
925
926 if let Some(activated) = trigger_activated {
928 let trigger_status = if activated {
929 Some("activated".to_string())
930 } else {
931 Some("pending".to_string())
932 };
933 (base_status, trigger_status)
934 } else {
935 (base_status, None)
936 }
937}
938
939pub fn format_trailing_stop_info(
941 offset: &str,
942 offset_type: TrailingOffsetType,
943 callback_price: Option<&str>,
944) -> String {
945 let offset_desc = offset_type.format_offset(offset);
946
947 if let Some(callback) = callback_price {
948 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
949 } else {
950 format!("Trailing stop: {offset_desc} offset")
951 }
952}
953
954pub fn validate_conditional_order_params(
960 trigger_px: Option<&str>,
961 tpsl: Option<&HyperliquidTpSl>,
962 is_market: Option<bool>,
963) -> anyhow::Result<()> {
964 if trigger_px.is_none() {
965 anyhow::bail!("Conditional order missing trigger price");
966 }
967
968 if tpsl.is_none() {
969 anyhow::bail!("Conditional order missing tpsl indicator");
970 }
971
972 if is_market.is_none() {
975 anyhow::bail!("Conditional order missing is_market flag");
976 }
977
978 Ok(())
979}
980
981pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
987 Decimal::from_str_exact(trigger_px)
988 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
989}
990
991pub fn parse_account_balances_and_margins(
1002 state: &ClearinghouseState,
1003) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1004 let mut balances = Vec::new();
1005 let mut margins = Vec::new();
1006
1007 let currency = Currency::USDC();
1008
1009 let cross_margin_summary = match &state.cross_margin_summary {
1010 Some(summary) => summary,
1011 None => return Ok((balances, margins)),
1012 };
1013
1014 let mut total_value = cross_margin_summary.total_raw_usd;
1015 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
1016
1017 if total_value >= Decimal::ZERO && free_value > total_value {
1020 total_value = free_value;
1021 }
1022
1023 balances.push(AccountBalance::from_total_and_free(
1024 total_value,
1025 free_value,
1026 currency,
1027 )?);
1028
1029 let margin_used = cross_margin_summary.total_margin_used;
1030
1031 if margin_used > Decimal::ZERO {
1032 let initial_margin = Money::from_decimal(margin_used, currency)?;
1035 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
1036 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1037 }
1038
1039 Ok((balances, margins))
1040}
1041
1042pub fn parse_combined_account_balances_and_margins(
1065 perp_state: &ClearinghouseState,
1066 spot_state: &SpotClearinghouseState,
1067 abstraction: HyperliquidAccountAbstraction,
1068) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1069 if abstraction.uses_spot_collateral() {
1070 let mut balances = parse_spot_account_balances(spot_state)?;
1071 push_zeroed_balances(&mut balances, spot_state);
1072
1073 let mut margins = Vec::new();
1074
1075 if let Some(usdc) = spot_state
1076 .balances
1077 .iter()
1078 .find(|balance| balance.coin.as_str() == "USDC")
1079 && usdc.hold > Decimal::ZERO
1080 {
1081 let margin_used = Money::from_decimal(usdc.hold, Currency::USDC())?;
1082 margins.push(MarginBalance::new(margin_used, margin_used, None));
1083 }
1084
1085 return Ok((balances, margins));
1086 }
1087
1088 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
1089
1090 let perp_reflects_usdc = perp_state
1091 .cross_margin_summary
1092 .as_ref()
1093 .is_some_and(|summary| {
1094 summary.total_raw_usd != Decimal::ZERO
1095 || summary.total_margin_used > Decimal::ZERO
1096 || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
1097 });
1098
1099 if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
1100 balances.retain(|balance| balance.currency.code != "USDC");
1101 }
1102
1103 let spot_balances = parse_spot_account_balances(spot_state)?;
1104
1105 for balance in spot_balances {
1106 let is_usdc = balance.currency.code == "USDC";
1107 if perp_reflects_usdc && is_usdc {
1108 continue;
1109 }
1110 balances.push(balance);
1111 }
1112
1113 if perp_state.cross_margin_summary.is_some() {
1116 push_zeroed_balances(&mut balances, spot_state);
1117 }
1118
1119 Ok((balances, margins))
1120}
1121
1122fn push_zeroed_balances(balances: &mut Vec<AccountBalance>, spot_state: &SpotClearinghouseState) {
1128 let zeroed_spot = spot_state
1129 .balances
1130 .iter()
1131 .filter(|balance| balance.total.is_zero())
1132 .map(|balance| crate::http::parse::get_currency(balance.coin.as_str()));
1133
1134 for currency in zeroed_spot.chain(std::iter::once(Currency::USDC())) {
1135 if !balances.iter().any(|balance| balance.currency == currency) {
1136 let zero = Money::zero(currency);
1137 balances.push(AccountBalance::new(zero, zero, zero));
1138 }
1139 }
1140}
1141
1142pub fn parse_spot_account_balances(
1152 state: &SpotClearinghouseState,
1153) -> anyhow::Result<Vec<AccountBalance>> {
1154 let mut balances = Vec::with_capacity(state.balances.len());
1155
1156 for balance in &state.balances {
1157 if balance.total.is_zero() {
1158 continue;
1159 }
1160
1161 let currency = crate::http::parse::get_currency(balance.coin.as_str());
1162
1163 balances.push(AccountBalance::from_total_and_locked(
1167 balance.total,
1168 balance.hold,
1169 currency,
1170 )?);
1171 }
1172
1173 Ok(balances)
1174}
1175
1176pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExchangeGrouping {
1188 if orders.len() >= 2 {
1189 let entry = &orders[0];
1190 let children = &orders[1..];
1191 let entry_id = entry.client_order_id();
1192 let entry_is_oto =
1193 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1194 let children_are_linked = children.iter().all(|o| {
1195 matches!(
1196 o.contingency_type(),
1197 Some(ContingencyType::Oco | ContingencyType::Ouo)
1198 ) && o.is_reduce_only()
1199 && o.parent_order_id() == Some(entry_id)
1200 });
1201
1202 if entry_is_oto && children_are_linked {
1203 return HyperliquidExchangeGrouping::NormalTpsl;
1204 }
1205 }
1206
1207 let all_oco_linked = orders.len() >= 2
1208 && orders.iter().all(|o| {
1209 matches!(
1210 o.contingency_type(),
1211 Some(ContingencyType::Oco | ContingencyType::Ouo)
1212 ) && o.is_reduce_only()
1213 })
1214 && orders.iter().all(|o| {
1215 o.linked_order_ids().is_some_and(|ids| {
1216 ids.iter()
1217 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1218 })
1219 });
1220
1221 if all_oco_linked {
1222 HyperliquidExchangeGrouping::PositionTpsl
1223 } else {
1224 HyperliquidExchangeGrouping::Na
1225 }
1226}
1227
1228pub(crate) fn is_false_or_none(value: &Option<bool>) -> bool {
1229 *value != Some(true)
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use std::str::FromStr;
1236
1237 use nautilus_model::{
1238 enums::{OrderSide, TimeInForce, TriggerType},
1239 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1240 orders::{LimitOrder, OrderAny, StopMarketOrder},
1241 types::{Price, Quantity},
1242 };
1243 use rstest::rstest;
1244 use rust_decimal::Decimal;
1245 use rust_decimal_macros::dec;
1246 use serde::{Deserialize, Serialize};
1247
1248 use super::*;
1249
1250 #[rstest]
1251 fn test_make_fill_trade_id_is_stable() {
1252 let id = make_fill_trade_id(
1255 "0xabc123",
1256 12345,
1257 dec!(50000.0),
1258 dec!(0.1),
1259 1704470400000,
1260 dec!(0.0),
1261 );
1262 assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1263 }
1264
1265 #[derive(Serialize, Deserialize)]
1266 struct TestStruct {
1267 #[serde(
1268 serialize_with = "serialize_decimal_as_str",
1269 deserialize_with = "deserialize_decimal_from_str"
1270 )]
1271 value: Decimal,
1272 #[serde(
1273 serialize_with = "serialize_optional_decimal_as_str",
1274 deserialize_with = "deserialize_optional_decimal_from_str"
1275 )]
1276 optional_value: Option<Decimal>,
1277 }
1278
1279 #[rstest]
1280 #[case("#10", 100_000_010, 1, 0)]
1281 #[case("+10", 100_000_010, 1, 0)]
1282 #[case("#31", 100_000_031, 3, 1)]
1283 #[case("+31", 100_000_031, 3, 1)]
1284 fn test_parse_outcome_symbol(
1285 #[case] symbol: &str,
1286 #[case] raw_asset_id: u32,
1287 #[case] outcome: u32,
1288 #[case] side: u8,
1289 ) {
1290 let asset_id = parse_outcome_symbol(symbol).unwrap();
1291 assert_eq!(asset_id.to_raw(), raw_asset_id);
1292 assert_eq!(asset_id.outcome_index(), Some(outcome));
1293 assert_eq!(asset_id.outcome_side(), Some(side));
1294 }
1295
1296 #[rstest]
1297 #[case("25-YES-OUTCOME", 25, 0)]
1298 #[case("25-NO-OUTCOME", 25, 1)]
1299 #[case("0-YES-OUTCOME", 0, 0)]
1300 #[case("999-NO-OUTCOME", 999, 1)]
1301 fn test_parse_outcome_nautilus_symbol(
1302 #[case] symbol: &str,
1303 #[case] outcome_index: u32,
1304 #[case] side: u8,
1305 ) {
1306 let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1307 assert_eq!(parsed, (outcome_index, side));
1308 }
1309
1310 #[rstest]
1311 #[case("25-OUTCOME")]
1312 #[case("25-MAYBE-OUTCOME")]
1313 #[case("25-yes-OUTCOME")]
1314 #[case("-YES-OUTCOME")]
1315 #[case("YES-25-OUTCOME")]
1316 #[case("25-YES-outcome")]
1317 #[case("25-YES")]
1318 fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1319 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1320 }
1321
1322 #[rstest]
1323 #[case("999999999-YES-OUTCOME")]
1325 #[case("429496729-YES-OUTCOME")]
1327 #[case("4294967295-NO-OUTCOME")]
1329 fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1330 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1331 }
1332
1333 #[rstest]
1334 #[case(25, 0, "25-YES-OUTCOME")]
1335 #[case(25, 1, "25-NO-OUTCOME")]
1336 #[case(0, 0, "0-YES-OUTCOME")]
1337 fn test_format_outcome_nautilus_symbol(
1338 #[case] outcome_index: u32,
1339 #[case] side: u8,
1340 #[case] expected: &str,
1341 ) {
1342 assert_eq!(
1343 format_outcome_nautilus_symbol(outcome_index, side),
1344 expected,
1345 );
1346 }
1347
1348 #[rstest]
1349 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1350 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1351 #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1352 #[case("BTC-USD-PERP", None)]
1353 #[case("+250", None)]
1354 fn test_outcome_token_from_nautilus_symbol(
1355 #[case] symbol: &str,
1356 #[case] expected: Option<String>,
1357 ) {
1358 assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1359 }
1360
1361 #[rstest]
1362 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1363 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1364 #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1365 #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1366 #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1367 #[case("+250", Some("+250".to_string()))]
1368 #[case("#250", Some("#250".to_string()))]
1369 #[case("", None)]
1370 fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1371 assert_eq!(cache_alias_for_symbol(symbol), expected);
1372 }
1373
1374 #[rstest]
1375 #[case("10", "expected #<encoding> or +<encoding>")]
1376 #[case("#", "encoding must not be empty")]
1377 #[case("#1a", "encoding must be numeric")]
1378 #[case("#12", "side digit 0 or 1")]
1379 #[case("#4294967295", "fit u32")]
1380 fn test_parse_outcome_symbol_rejects_invalid_values(
1381 #[case] symbol: &str,
1382 #[case] expected_error: &str,
1383 ) {
1384 let err = parse_outcome_symbol(symbol).unwrap_err();
1385 assert!(
1386 err.to_string().contains(expected_error),
1387 "expected error to contain '{expected_error}', received '{err}'",
1388 );
1389 }
1390
1391 #[rstest]
1392 fn test_decimal_serialization_roundtrip() {
1393 let original = TestStruct {
1394 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1395 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1396 };
1397
1398 let json = serde_json::to_string(&original).unwrap();
1399 println!("Serialized: {json}");
1400
1401 assert!(json.contains("\"123.45678901234567890123456789\""));
1403 assert!(json.contains("\"0.000000001\""));
1404
1405 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1406 assert_eq!(original.value, deserialized.value);
1407 assert_eq!(original.optional_value, deserialized.optional_value);
1408 }
1409
1410 #[rstest]
1411 fn test_decimal_precision_preservation() {
1412 let test_cases = [
1413 "0",
1414 "1",
1415 "0.1",
1416 "0.01",
1417 "0.001",
1418 "123.456789012345678901234567890",
1419 "999999999999999999.999999999999999999",
1420 ];
1421
1422 for case in test_cases {
1423 let decimal = Decimal::from_str(case).unwrap();
1424 let test_struct = TestStruct {
1425 value: decimal,
1426 optional_value: Some(decimal),
1427 };
1428
1429 let json = serde_json::to_string(&test_struct).unwrap();
1430 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1431
1432 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1433 assert_eq!(
1434 Some(decimal),
1435 parsed.optional_value,
1436 "Failed for case: {case}"
1437 );
1438 }
1439 }
1440
1441 #[rstest]
1442 fn test_optional_none_handling() {
1443 let test_struct = TestStruct {
1444 value: Decimal::from_str("42.0").unwrap(),
1445 optional_value: None,
1446 };
1447
1448 let json = serde_json::to_string(&test_struct).unwrap();
1449 assert!(json.contains("null"));
1450
1451 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1452 assert_eq!(test_struct.value, parsed.value);
1453 assert_eq!(None, parsed.optional_value);
1454 }
1455
1456 #[rstest]
1457 fn test_round_down_to_tick() {
1458 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1459 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1460 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1461
1462 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1464 }
1465
1466 #[rstest]
1467 fn test_round_down_to_step() {
1468 assert_eq!(
1469 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1470 dec!(0.1234)
1471 );
1472 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1473 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1474
1475 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1477 }
1478
1479 #[rstest]
1480 fn test_min_notional_validation() {
1481 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1483 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1484
1485 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1487 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1488
1489 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1491 }
1492
1493 #[rstest]
1494 fn test_round_to_sig_figs() {
1495 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1497 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1498 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1499
1500 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1502 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1503 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1504
1505 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1507 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1511
1512 assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1513 assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1514 assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1515 assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1516 }
1517
1518 #[rstest]
1519 fn test_normalize_price() {
1520 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1522 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));
1528 }
1529
1530 #[rstest]
1531 fn test_normalize_quantity() {
1532 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1533 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1534 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1535 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1536 }
1537
1538 #[rstest]
1539 fn test_normalize_order_complete() {
1540 let result = normalize_order(
1541 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1549
1550 assert!(result.is_ok());
1551 let (price, qty) = result.unwrap();
1552 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1555
1556 #[rstest]
1557 fn test_normalize_order_min_notional_fail() {
1558 let result = normalize_order(
1559 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1567
1568 assert!(result.is_err());
1569 assert!(result.unwrap_err().contains("Notional value"));
1570 }
1571
1572 #[rstest]
1573 fn test_edge_cases() {
1574 assert_eq!(
1576 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1577 dec!(0.000001)
1578 );
1579
1580 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1582
1583 assert_eq!(
1585 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1586 dec!(100.00)
1587 );
1588 }
1589
1590 #[rstest]
1591 fn test_is_conditional_order_data() {
1592 assert!(is_conditional_order_data(
1594 Some(dec!(50000.0)),
1595 Some(&HyperliquidTpSl::Sl)
1596 ));
1597
1598 assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1600
1601 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1603
1604 assert!(!is_conditional_order_data(None, None));
1606 }
1607
1608 #[rstest]
1609 fn test_parse_trigger_order_type() {
1610 assert_eq!(
1612 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1613 OrderType::StopMarket
1614 );
1615
1616 assert_eq!(
1618 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1619 OrderType::StopLimit
1620 );
1621
1622 assert_eq!(
1624 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1625 OrderType::MarketIfTouched
1626 );
1627
1628 assert_eq!(
1630 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1631 OrderType::LimitIfTouched
1632 );
1633 }
1634
1635 #[rstest]
1636 #[case("Stop Market", Some((HyperliquidTpSl::Sl, true)))]
1637 #[case("Stop Limit", Some((HyperliquidTpSl::Sl, false)))]
1638 #[case("Take Profit Market", Some((HyperliquidTpSl::Tp, true)))]
1639 #[case("Take Profit Limit", Some((HyperliquidTpSl::Tp, false)))]
1640 #[case("Limit", None)]
1641 #[case("Market", None)]
1642 #[case("", None)]
1643 fn test_parse_trigger_order_type_label(
1644 #[case] label: &str,
1645 #[case] expected: Option<(HyperliquidTpSl, bool)>,
1646 ) {
1647 assert_eq!(parse_trigger_order_type_label(label), expected);
1648 }
1649
1650 #[rstest]
1651 fn test_parse_order_status_with_trigger() {
1652 let (status, trigger_status) =
1654 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1655 assert_eq!(status, OrderStatus::Accepted);
1656 assert_eq!(trigger_status, Some("activated".to_string()));
1657
1658 let (status, trigger_status) =
1660 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1661 assert_eq!(status, OrderStatus::Accepted);
1662 assert_eq!(trigger_status, Some("pending".to_string()));
1663
1664 let (status, trigger_status) =
1666 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1667 assert_eq!(status, OrderStatus::Accepted);
1668 assert_eq!(trigger_status, None);
1669 }
1670
1671 #[rstest]
1672 fn test_format_trailing_stop_info() {
1673 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1675 assert!(info.contains("100.0"));
1676 assert!(info.contains("callback at 50000.0"));
1677
1678 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1680 assert!(info.contains("5.0%"));
1681 assert!(info.contains("Trailing stop"));
1682
1683 let info =
1685 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1686 assert!(info.contains("250 bps"));
1687 assert!(info.contains("49000.0"));
1688 }
1689
1690 #[rstest]
1691 fn test_parse_trigger_price() {
1692 let result = parse_trigger_price("50000.0");
1694 assert!(result.is_ok());
1695 assert_eq!(result.unwrap(), dec!(50000.0));
1696
1697 let result = parse_trigger_price("49000");
1699 assert!(result.is_ok());
1700 assert_eq!(result.unwrap(), dec!(49000));
1701
1702 let result = parse_trigger_price("invalid");
1704 assert!(result.is_err());
1705
1706 let result = parse_trigger_price("");
1708 assert!(result.is_err());
1709 }
1710
1711 #[rstest]
1712 #[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(
1723 #[case] trigger_price: Decimal,
1724 #[case] is_buy: bool,
1725 #[case] expected: Decimal,
1726 ) {
1727 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1728 assert_eq!(result, expected);
1729
1730 if is_buy {
1732 assert!(result >= trigger_price);
1733 } else {
1734 assert!(result <= trigger_price);
1735 }
1736 }
1737
1738 #[rstest]
1739 #[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))]
1749 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1750 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1752 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1753 fn test_clamp_price_to_precision(
1754 #[case] price: Decimal,
1755 #[case] decimals: u8,
1756 #[case] is_buy: bool,
1757 #[case] expected: Decimal,
1758 ) {
1759 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1760 }
1761
1762 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1763 OrderAny::StopMarket(StopMarketOrder::new(
1764 TraderId::from("TESTER-001"),
1765 StrategyId::from("S-001"),
1766 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1767 ClientOrderId::from("O-001"),
1768 side,
1769 Quantity::from(1),
1770 Price::from(trigger_price),
1771 TriggerType::LastPrice,
1772 TimeInForce::Gtc,
1773 None,
1774 false,
1775 false,
1776 None,
1777 None,
1778 None,
1779 None,
1780 None,
1781 None,
1782 None,
1783 None,
1784 None,
1785 None,
1786 None,
1787 Default::default(),
1788 Default::default(),
1789 ))
1790 }
1791
1792 #[rstest]
1793 #[case(OrderSide::Sell, "2470.00", 2)]
1795 #[case(OrderSide::Buy, "2470.00", 2)]
1796 #[case(OrderSide::Sell, "104567.3", 1)]
1798 #[case(OrderSide::Buy, "104567.3", 1)]
1799 #[case(OrderSide::Sell, "0.50", 4)]
1801 #[case(OrderSide::Buy, "0.50", 4)]
1802 #[case(OrderSide::Sell, "2470.00", 1)]
1806 #[case(OrderSide::Buy, "2470.00", 1)]
1807 #[case(OrderSide::Sell, "2470.00", 0)]
1811 #[case(OrderSide::Buy, "2470.00", 0)]
1812 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1813 #[case] side: OrderSide,
1814 #[case] trigger_str: &str,
1815 #[case] price_decimals: u8,
1816 ) {
1817 let order = stop_market_order(side, trigger_str);
1818 let request = order_to_hyperliquid_request_with_asset(
1819 &order,
1820 0,
1821 price_decimals,
1822 true,
1823 DEFAULT_MARKET_SLIPPAGE_BPS,
1824 )
1825 .unwrap();
1826 let trigger = Decimal::from_str(trigger_str).unwrap();
1827 let is_buy = matches!(side, OrderSide::Buy);
1828
1829 if is_buy {
1831 assert!(
1832 request.price >= trigger,
1833 "BUY limit {} must be >= trigger {trigger}",
1834 request.price,
1835 );
1836 assert!(request.is_buy);
1837 } else {
1838 assert!(
1839 request.price <= trigger,
1840 "SELL limit {} must be <= trigger {trigger}",
1841 request.price,
1842 );
1843 assert!(!request.is_buy);
1844 }
1845
1846 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1848 let sig_rounded = round_to_sig_figs(derived, 5);
1849 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1850 assert_eq!(request.price, expected);
1851
1852 let price_str = request.price.to_string();
1854 let actual_decimals = price_str
1855 .find('.')
1856 .map_or(0, |dot| price_str.len() - dot - 1);
1857 assert!(
1858 actual_decimals <= price_decimals as usize,
1859 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1860 );
1861
1862 if price_str.contains('.') {
1864 assert!(
1865 !price_str.ends_with('0'),
1866 "Price {price_str} has decimal trailing zeros",
1867 );
1868 }
1869
1870 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1871 assert_eq!(
1872 request.kind,
1873 HyperliquidExchangeOrderKind::Trigger {
1874 trigger: HyperliquidExchangeTriggerParams {
1875 is_market: true,
1876 trigger_px: expected_trigger,
1877 tpsl: HyperliquidExchangeTpSl::Sl,
1878 },
1879 },
1880 );
1881 }
1882
1883 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1884 HyperliquidExchangeResponse::Status {
1885 status: "ok".to_string(),
1886 response: inner,
1887 }
1888 }
1889
1890 #[rstest]
1891 fn test_extract_inner_error_order_with_error() {
1892 let response = ok_response(serde_json::json!({
1893 "type": "order",
1894 "data": {"statuses": [{"error": "Order has invalid price."}]}
1895 }));
1896 assert_eq!(
1897 extract_inner_error(&response),
1898 Some("Order has invalid price.".to_string()),
1899 );
1900 }
1901
1902 #[rstest]
1903 fn test_extract_inner_error_order_resting() {
1904 let response = ok_response(serde_json::json!({
1905 "type": "order",
1906 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1907 }));
1908 assert_eq!(extract_inner_error(&response), None);
1909 }
1910
1911 #[rstest]
1912 fn test_extract_inner_error_order_filled() {
1913 let response = ok_response(serde_json::json!({
1914 "type": "order",
1915 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1916 }));
1917 assert_eq!(extract_inner_error(&response), None);
1918 }
1919
1920 #[rstest]
1921 fn test_extract_inner_error_cancel_error() {
1922 let response = ok_response(serde_json::json!({
1923 "type": "cancel",
1924 "data": {"statuses": [{"error": "Order not found"}]}
1925 }));
1926 assert_eq!(
1927 extract_inner_error(&response),
1928 Some("Order not found".to_string()),
1929 );
1930 }
1931
1932 #[rstest]
1933 fn test_extract_inner_error_cancel_success() {
1934 let response = ok_response(serde_json::json!({
1935 "type": "cancel",
1936 "data": {"statuses": ["success"]}
1937 }));
1938 assert_eq!(extract_inner_error(&response), None);
1939 }
1940
1941 #[rstest]
1942 fn test_extract_inner_error_modify_error() {
1943 let response = ok_response(serde_json::json!({
1944 "type": "modify",
1945 "data": {"statuses": [{"error": "Invalid modify"}]}
1946 }));
1947 assert_eq!(
1948 extract_inner_error(&response),
1949 Some("Invalid modify".to_string()),
1950 );
1951 }
1952
1953 #[rstest]
1954 fn test_extract_inner_error_modify_success() {
1955 let response = ok_response(serde_json::json!({
1956 "type": "modify",
1957 "data": {"statuses": ["success"]}
1958 }));
1959 assert_eq!(extract_inner_error(&response), None);
1960 }
1961
1962 #[rstest]
1963 fn test_extract_inner_error_non_status_response() {
1964 let response = HyperliquidExchangeResponse::Error {
1965 error: "top-level error".to_string(),
1966 };
1967 assert_eq!(extract_inner_error(&response), None);
1968 }
1969
1970 #[rstest]
1971 fn test_extract_inner_error_unparsable_response() {
1972 let response = ok_response(serde_json::json!({"unknown": "data"}));
1973 assert_eq!(extract_inner_error(&response), None);
1974 }
1975
1976 #[rstest]
1977 fn test_extract_inner_error_returns_first_error_in_batch() {
1978 let response = ok_response(serde_json::json!({
1979 "type": "order",
1980 "data": {"statuses": [
1981 {"resting": {"oid": 1}},
1982 {"error": "Second failed"},
1983 {"error": "Third failed"},
1984 ]}
1985 }));
1986 assert_eq!(
1987 extract_inner_error(&response),
1988 Some("Second failed".to_string()),
1989 );
1990 }
1991
1992 #[rstest]
1993 fn test_extract_inner_errors_mixed_batch() {
1994 let response = ok_response(serde_json::json!({
1995 "type": "order",
1996 "data": {"statuses": [
1997 {"resting": {"oid": 1}},
1998 {"error": "Failed order"},
1999 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
2000 ]}
2001 }));
2002 let errors = extract_inner_errors(&response);
2003 assert_eq!(errors.len(), 3);
2004 assert_eq!(errors[0], None);
2005 assert_eq!(errors[1], Some("Failed order".to_string()));
2006 assert_eq!(errors[2], None);
2007 }
2008
2009 #[rstest]
2010 fn test_extract_inner_errors_all_success() {
2011 let response = ok_response(serde_json::json!({
2012 "type": "order",
2013 "data": {"statuses": [
2014 {"resting": {"oid": 1}},
2015 {"resting": {"oid": 2}},
2016 ]}
2017 }));
2018 let errors = extract_inner_errors(&response);
2019 assert_eq!(errors.len(), 2);
2020 assert!(errors.iter().all(|e| e.is_none()));
2021 }
2022
2023 #[rstest]
2024 fn test_extract_inner_errors_cancel_success() {
2025 let response = ok_response(serde_json::json!({
2026 "type": "cancel",
2027 "data": {"statuses": ["success"]}
2028 }));
2029 let errors = extract_inner_errors(&response);
2030 assert_eq!(errors.len(), 1);
2031 assert!(errors[0].is_none());
2032 }
2033
2034 #[rstest]
2035 fn test_extract_inner_errors_cancel_mixed() {
2036 let response = ok_response(serde_json::json!({
2037 "type": "cancel",
2038 "data": {"statuses": [
2039 "success",
2040 {"error": "Order was never placed, already canceled, or filled."},
2041 "success",
2042 ]}
2043 }));
2044 let errors = extract_inner_errors(&response);
2045 assert_eq!(errors.len(), 3);
2046 assert_eq!(errors[0], None);
2047 assert_eq!(
2048 errors[1],
2049 Some("Order was never placed, already canceled, or filled.".to_string())
2050 );
2051 assert_eq!(errors[2], None);
2052 }
2053
2054 #[rstest]
2055 fn test_extract_inner_errors_modify_mixed() {
2056 let response = ok_response(serde_json::json!({
2057 "type": "modify",
2058 "data": {"statuses": [
2059 "success",
2060 {"error": "Order does not exist"},
2061 ]}
2062 }));
2063 let errors = extract_inner_errors(&response);
2064 assert_eq!(errors.len(), 2);
2065 assert_eq!(errors[0], None);
2066 assert_eq!(errors[1], Some("Order does not exist".to_string()));
2067 }
2068
2069 #[rstest]
2070 fn test_extract_inner_errors_unparsable() {
2071 let response = ok_response(serde_json::json!({"foo": "bar"}));
2072 let errors = extract_inner_errors(&response);
2073 assert!(errors.is_empty());
2074 }
2075
2076 fn count_sig_figs(s: &str) -> usize {
2077 let s = s.trim_start_matches('-');
2078 if s.contains('.') {
2079 let digits: String = s.replace('.', "");
2081 digits.trim_start_matches('0').len()
2082 } else {
2083 let s = s.trim_start_matches('0');
2085 s.trim_end_matches('0').len()
2086 }
2087 }
2088
2089 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
2090 QuoteTick::new(
2091 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
2092 Price::from(bid),
2093 Price::from(ask),
2094 Quantity::from("1"),
2095 Quantity::from("1"),
2096 Default::default(),
2097 Default::default(),
2098 )
2099 }
2100
2101 #[rstest]
2102 #[case("2460.00", "2470.00", true, 2, "2482.4")]
2108 #[case("2460.00", "2470.00", false, 2, "2447.7")]
2110 #[case("104500.0", "104567.3", true, 1, "105090")]
2114 #[case("104500.0", "104567.3", false, 1, "103980")]
2116 #[case("0.4900", "0.5000", true, 4, "0.5025")]
2120 #[case("0.4900", "0.5000", false, 4, "0.4875")]
2122 #[case("49900", "50000", true, 0, "50250")]
2126 #[case("49900", "50000", false, 0, "49650")]
2128 #[case("0.001200", "0.001234", true, 6, "0.001241")]
2132 #[case("0.001200", "0.001234", false, 6, "0.001194")]
2134 fn test_derive_market_order_price(
2135 #[case] bid: &str,
2136 #[case] ask: &str,
2137 #[case] is_buy: bool,
2138 #[case] price_decimals: u8,
2139 #[case] expected: &str,
2140 ) {
2141 let quote = make_quote(bid, ask);
2142 let result =
2143 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
2144 let expected_dec = Decimal::from_str(expected).unwrap();
2145 assert_eq!(result, expected_dec);
2146
2147 let base = if is_buy {
2149 quote.ask_price.as_decimal()
2150 } else {
2151 quote.bid_price.as_decimal()
2152 };
2153 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2154 let sig_rounded = round_to_sig_figs(derived, 5);
2155 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2156 assert_eq!(result, pipeline);
2157
2158 let s = result.to_string();
2160 if s.contains('.') {
2161 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2162 }
2163
2164 let sig_count = count_sig_figs(&s);
2166 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2167
2168 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2170 assert!(
2171 actual_decimals <= price_decimals as usize,
2172 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2173 );
2174 }
2175
2176 #[rstest]
2177 #[case(50, dec!(1000), true, dec!(1005))] #[case(50, dec!(1000), false, dec!(995))] #[case(0, dec!(1000), true, dec!(1000))] #[case(100, dec!(1000), true, dec!(1010))] #[case(100, dec!(1000), false, dec!(990))] #[case(800, dec!(1000), true, dec!(1080))] #[case(800, dec!(1000), false, dec!(920))] fn test_derive_limit_from_trigger_respects_bps(
2185 #[case] slippage_bps: u32,
2186 #[case] trigger: Decimal,
2187 #[case] is_buy: bool,
2188 #[case] expected: Decimal,
2189 ) {
2190 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2191 assert_eq!(result, expected);
2192 }
2193
2194 #[rstest]
2195 fn test_derive_market_order_price_respects_slippage_override() {
2196 let quote = make_quote("100.00", "100.10");
2197 let tight = derive_market_order_price("e, true, 2, 50);
2198 let wide = derive_market_order_price("e, true, 2, 800);
2199 assert_eq!(tight, dec!(100.6));
2200 assert_eq!(wide, dec!(108.11));
2201 assert!(wide > tight);
2202 }
2203
2204 #[rstest]
2208 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2209 let json = r#"{
2210 "assetPositions": [],
2211 "crossMarginSummary": {
2212 "accountValue": "150",
2213 "totalNtlPos": "0",
2214 "totalRawUsd": "100",
2215 "totalMarginUsed": "20",
2216 "withdrawable": "120"
2217 },
2218 "withdrawable": "80",
2219 "time": 1700000000000
2220 }"#;
2221
2222 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2223 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2224
2225 assert_eq!(balances.len(), 1);
2226 let balance = &balances[0];
2227 assert_eq!(balance.total.as_decimal(), dec!(100));
2230 assert_eq!(balance.free.as_decimal(), dec!(80));
2231 assert_eq!(balance.locked.as_decimal(), dec!(20));
2232
2233 assert_eq!(margins.len(), 1);
2234 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2235 }
2236
2237 #[rstest]
2238 fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2239 let json =
2240 include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2241
2242 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2243 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2244
2245 assert_eq!(balances.len(), 1);
2246 let balance = &balances[0];
2247 assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2248 assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2249 assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2250
2251 assert_eq!(margins.len(), 1);
2252 assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2253 }
2254
2255 #[rstest]
2256 fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2257 let json = r#"{
2258 "assetPositions": [],
2259 "crossMarginSummary": {
2260 "accountValue": "100",
2261 "totalNtlPos": "0",
2262 "totalRawUsd": "100",
2263 "totalMarginUsed": "0",
2264 "withdrawable": "100"
2265 },
2266 "withdrawable": "150",
2267 "time": 1700000000000
2268 }"#;
2269
2270 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2271 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2272
2273 assert_eq!(balances.len(), 1);
2274 let balance = &balances[0];
2275 assert_eq!(balance.total.as_decimal(), dec!(150));
2276 assert_eq!(balance.free.as_decimal(), dec!(150));
2277 assert_eq!(balance.locked.as_decimal(), dec!(0));
2278 }
2279
2280 #[rstest]
2281 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2282 let json = r#"{
2283 "assetPositions": [],
2284 "withdrawable": "100",
2285 "time": 1700000000000
2286 }"#;
2287
2288 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2289 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2290 assert!(balances.is_empty());
2291 assert!(margins.is_empty());
2292 }
2293
2294 #[rstest]
2295 fn test_parse_spot_account_balances_emits_one_per_token() {
2296 let json = r#"{
2297 "balances": [
2298 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2299 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2300 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2301 ]
2302 }"#;
2303
2304 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2305 let balances = parse_spot_account_balances(&state).unwrap();
2306
2307 assert_eq!(balances.len(), 2);
2308
2309 let usdc = &balances[0];
2310 assert_eq!(usdc.currency.code, "USDC");
2311 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2312 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2313 assert_eq!(usdc.locked.as_decimal(), dec!(10));
2314
2315 let purr = &balances[1];
2316 assert_eq!(purr.currency.code, "PURR");
2317 assert_eq!(purr.total.as_decimal(), dec!(50));
2318 assert_eq!(purr.free.as_decimal(), dec!(50));
2319 }
2320
2321 #[rstest]
2322 fn test_parse_spot_account_balances_clamps_hold_to_total() {
2323 let json = r#"{
2324 "balances": [
2325 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2326 ]
2327 }"#;
2328
2329 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2330 let balances = parse_spot_account_balances(&state).unwrap();
2331
2332 assert_eq!(balances.len(), 1);
2333 let hype = &balances[0];
2334 assert_eq!(hype.total.as_decimal(), dec!(5));
2335 assert_eq!(hype.free.as_decimal(), dec!(0));
2336 assert_eq!(hype.locked.as_decimal(), dec!(5));
2337 }
2338
2339 #[rstest]
2340 fn test_parse_spot_account_balances_empty() {
2341 let state = SpotClearinghouseState::default();
2342 let balances = parse_spot_account_balances(&state).unwrap();
2343 assert!(balances.is_empty());
2344 }
2345
2346 #[rstest]
2347 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2348 let perp_json = r#"{
2349 "assetPositions": [],
2350 "crossMarginSummary": {
2351 "accountValue": "500",
2352 "totalNtlPos": "0",
2353 "totalRawUsd": "500",
2354 "totalMarginUsed": "0",
2355 "withdrawable": "500"
2356 },
2357 "withdrawable": "500"
2358 }"#;
2359 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2360
2361 let spot_json = r#"{
2362 "balances": [
2363 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2364 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2365 ]
2366 }"#;
2367 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2368
2369 let (balances, margins) = parse_combined_account_balances_and_margins(
2370 &perp_state,
2371 &spot_state,
2372 HyperliquidAccountAbstraction::Disabled,
2373 )
2374 .unwrap();
2375
2376 assert!(margins.is_empty());
2377 assert_eq!(balances.len(), 2);
2378 assert_eq!(balances[0].currency.code, "USDC");
2379 assert_eq!(balances[0].total.as_decimal(), dec!(500));
2380 assert_eq!(balances[1].currency.code, "PURR");
2381 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2382 }
2383
2384 #[rstest]
2385 fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2386 let perp_json = r#"{
2387 "assetPositions": [],
2388 "crossMarginSummary": {
2389 "accountValue": "0",
2390 "totalNtlPos": "0",
2391 "totalRawUsd": "0",
2392 "totalMarginUsed": "0",
2393 "withdrawable": "0"
2394 },
2395 "withdrawable": "0"
2396 }"#;
2397 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2398
2399 let spot_json = r#"{
2400 "balances": [
2401 {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2402 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2403 ]
2404 }"#;
2405 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2406
2407 let (balances, margins) = parse_combined_account_balances_and_margins(
2408 &perp_state,
2409 &spot_state,
2410 HyperliquidAccountAbstraction::Disabled,
2411 )
2412 .unwrap();
2413
2414 assert!(margins.is_empty());
2415 assert_eq!(balances.len(), 2);
2416 assert_eq!(balances[0].currency.code, "USDC");
2417 assert_eq!(balances[0].total.as_decimal(), dec!(75));
2418 assert_eq!(balances[0].free.as_decimal(), dec!(70));
2419 assert_eq!(balances[1].currency.code, "PURR");
2420 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2421 }
2422
2423 #[rstest]
2424 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2425 let perp_json = r#"{
2426 "assetPositions": [],
2427 "crossMarginSummary": {
2428 "accountValue": "50",
2429 "totalNtlPos": "0",
2430 "totalRawUsd": "50",
2431 "totalMarginUsed": "0",
2432 "withdrawable": "0"
2433 },
2434 "withdrawable": "0"
2435 }"#;
2436 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2437
2438 let spot_json = r#"{
2439 "balances": [
2440 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2441 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2442 ]
2443 }"#;
2444 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2445
2446 let (balances, margins) = parse_combined_account_balances_and_margins(
2447 &perp_state,
2448 &spot_state,
2449 HyperliquidAccountAbstraction::Disabled,
2450 )
2451 .unwrap();
2452
2453 assert!(margins.is_empty());
2454 assert_eq!(balances.len(), 2);
2455 assert_eq!(balances[0].currency.code, "USDC");
2456 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2457 assert_eq!(balances[1].currency.code, "PURR");
2458 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2459 }
2460
2461 #[rstest]
2462 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2463 let perp_json = r#"{
2464 "assetPositions": [],
2465 "crossMarginSummary": {
2466 "accountValue": "-50",
2467 "totalNtlPos": "0",
2468 "totalRawUsd": "-50",
2469 "totalMarginUsed": "0",
2470 "withdrawable": "0"
2471 },
2472 "withdrawable": "0"
2473 }"#;
2474 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2475
2476 let spot_json = r#"{
2477 "balances": [
2478 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2479 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2480 ]
2481 }"#;
2482 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2483
2484 let (balances, margins) = parse_combined_account_balances_and_margins(
2485 &perp_state,
2486 &spot_state,
2487 HyperliquidAccountAbstraction::Disabled,
2488 )
2489 .unwrap();
2490
2491 assert!(margins.is_empty());
2492 assert_eq!(balances.len(), 2);
2493 assert_eq!(balances[0].currency.code, "USDC");
2494 assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2495 assert_eq!(balances[1].currency.code, "PURR");
2496 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2497 }
2498
2499 #[rstest]
2500 fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2501 let perp_json = r#"{
2502 "assetPositions": [],
2503 "crossMarginSummary": {
2504 "accountValue": "0",
2505 "totalNtlPos": "0",
2506 "totalRawUsd": "0",
2507 "totalMarginUsed": "25",
2508 "withdrawable": "0"
2509 },
2510 "withdrawable": "0"
2511 }"#;
2512 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2513
2514 let spot_json = r#"{
2515 "balances": [
2516 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2517 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2518 ]
2519 }"#;
2520 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2521
2522 let (balances, margins) = parse_combined_account_balances_and_margins(
2523 &perp_state,
2524 &spot_state,
2525 HyperliquidAccountAbstraction::Disabled,
2526 )
2527 .unwrap();
2528
2529 assert_eq!(margins.len(), 1);
2530 assert_eq!(balances.len(), 2);
2531 assert_eq!(balances[0].currency.code, "USDC");
2532 assert_eq!(balances[0].total.as_decimal(), dec!(0));
2533 assert_eq!(balances[1].currency.code, "PURR");
2534 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2535 }
2536
2537 #[rstest]
2538 fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2539 let perp_json = r#"{
2540 "assetPositions": [],
2541 "crossMarginSummary": {
2542 "accountValue": "0",
2543 "totalNtlPos": "0",
2544 "totalRawUsd": "0",
2545 "totalMarginUsed": "0",
2546 "withdrawable": "50"
2547 },
2548 "withdrawable": "50"
2549 }"#;
2550 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2551
2552 let spot_json = r#"{
2553 "balances": [
2554 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2555 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2556 ]
2557 }"#;
2558 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2559
2560 let (balances, margins) = parse_combined_account_balances_and_margins(
2561 &perp_state,
2562 &spot_state,
2563 HyperliquidAccountAbstraction::Disabled,
2564 )
2565 .unwrap();
2566
2567 assert!(margins.is_empty());
2568 assert_eq!(balances.len(), 2);
2569 assert_eq!(balances[0].currency.code, "USDC");
2570 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2571 assert_eq!(balances[0].free.as_decimal(), dec!(50));
2572 assert_eq!(balances[1].currency.code, "PURR");
2573 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2574 }
2575
2576 #[rstest]
2577 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2578 let perp_json = r#"{"assetPositions": []}"#;
2579 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2580
2581 let spot_json = r#"{
2582 "balances": [
2583 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2584 ]
2585 }"#;
2586 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2587
2588 let (balances, _) = parse_combined_account_balances_and_margins(
2589 &perp_state,
2590 &spot_state,
2591 HyperliquidAccountAbstraction::Disabled,
2592 )
2593 .unwrap();
2594
2595 assert_eq!(balances.len(), 1);
2596 assert_eq!(balances[0].currency.code, "USDC");
2597 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2598 }
2599
2600 #[rstest]
2605 fn test_parse_combined_unified_account_with_open_positions_uses_spot_usdc() {
2606 let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2607 "../../test_data/http_clearinghouse_state_unified_open_positions.json"
2608 ))
2609 .unwrap();
2610
2611 let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2612 "../../test_data/http_spot_clearinghouse_state_unified_open_positions.json"
2613 ))
2614 .unwrap();
2615
2616 let (balances, margins) = parse_combined_account_balances_and_margins(
2617 &perp_state,
2618 &spot_state,
2619 HyperliquidAccountAbstraction::UnifiedAccount,
2620 )
2621 .unwrap();
2622
2623 assert_eq!(balances.len(), 2);
2624 assert_eq!(balances[0].currency.code, "USDC");
2625 assert_eq!(balances[0].total.as_decimal(), dec!(512.25));
2626 assert_eq!(balances[0].free.as_decimal(), dec!(92.25));
2627 assert_eq!(balances[0].locked.as_decimal(), dec!(420.0));
2628 assert_eq!(balances[1].currency.code, "PURR");
2629 assert_eq!(margins.len(), 1);
2630 assert_eq!(margins[0].initial.as_decimal(), dec!(420.0));
2631 assert_eq!(margins[0].maintenance.as_decimal(), dec!(420.0));
2632 }
2633
2634 #[rstest]
2635 fn test_parse_combined_unified_account_flat_has_no_margin() {
2636 let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2637 "../../test_data/http_clearinghouse_state_unified_flat.json"
2638 ))
2639 .unwrap();
2640 let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2641 "../../test_data/http_spot_clearinghouse_state_unified_flat.json"
2642 ))
2643 .unwrap();
2644
2645 let (balances, margins) = parse_combined_account_balances_and_margins(
2646 &perp_state,
2647 &spot_state,
2648 HyperliquidAccountAbstraction::UnifiedAccount,
2649 )
2650 .unwrap();
2651
2652 assert_eq!(balances.len(), 1);
2653 assert_eq!(balances[0].total.as_decimal(), dec!(100));
2654 assert_eq!(balances[0].free.as_decimal(), dec!(100));
2655 assert!(margins.is_empty());
2656 }
2657
2658 #[rstest]
2659 fn test_parse_combined_unified_account_without_usdc_ignores_perp_summary() {
2660 let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2661 "../../test_data/http_clearinghouse_state_unified_without_usdc.json"
2662 ))
2663 .unwrap();
2664 let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2665 "../../test_data/http_spot_clearinghouse_state_unified_without_usdc.json"
2666 ))
2667 .unwrap();
2668
2669 let (balances, margins) = parse_combined_account_balances_and_margins(
2670 &perp_state,
2671 &spot_state,
2672 HyperliquidAccountAbstraction::UnifiedAccount,
2673 )
2674 .unwrap();
2675
2676 assert_eq!(balances.len(), 2);
2677 assert_eq!(balances[0].currency.code, "PURR");
2678 assert_eq!(balances[1].currency.code, "USDC");
2680 assert!(balances[1].total.is_zero());
2681 assert!(balances[1].free.is_zero());
2682 assert!(margins.is_empty());
2683 }
2684
2685 #[rstest]
2687 fn test_parse_combined_portfolio_margin_uses_spot_including_negative_usdc() {
2688 let perp_state: ClearinghouseState = serde_json::from_str(include_str!(
2689 "../../test_data/http_clearinghouse_state_portfolio_margin_negative_usdc.json"
2690 ))
2691 .unwrap();
2692 let spot_state: SpotClearinghouseState = serde_json::from_str(include_str!(
2693 "../../test_data/http_spot_clearinghouse_state_portfolio_margin_negative_usdc.json"
2694 ))
2695 .unwrap();
2696
2697 let (balances, margins) = parse_combined_account_balances_and_margins(
2698 &perp_state,
2699 &spot_state,
2700 HyperliquidAccountAbstraction::PortfolioMargin,
2701 )
2702 .unwrap();
2703
2704 assert_eq!(balances.len(), 2);
2705 assert_eq!(balances[0].currency.code, "USDC");
2706 assert_eq!(balances[0].total.as_decimal(), dec!(-25));
2707 assert_eq!(balances[1].currency.code, "HYPE");
2708 assert_eq!(margins.len(), 1);
2709 assert_eq!(margins[0].initial.as_decimal(), dec!(50));
2710 }
2711
2712 fn limit_order(price: &str) -> OrderAny {
2713 OrderAny::Limit(LimitOrder::new(
2714 TraderId::from("TESTER-001"),
2715 StrategyId::from("S-001"),
2716 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2717 ClientOrderId::from("O-1"),
2718 OrderSide::Buy,
2719 Quantity::from(1),
2720 Price::from(price),
2721 TimeInForce::Gtc,
2722 None,
2723 false,
2724 false,
2725 false,
2726 None,
2727 None,
2728 None,
2729 None,
2730 None,
2731 None,
2732 None,
2733 None,
2734 None,
2735 None,
2736 None,
2737 Default::default(),
2738 Default::default(),
2739 ))
2740 }
2741
2742 #[rstest]
2743 #[case("78764.5", 1)]
2745 #[case("102393", 1)]
2746 #[case("0", 0)]
2747 #[case("0.11525", 5)]
2748 #[case("0.10", 1)]
2749 fn test_ensure_canonical_wire_price_accepts(#[case] price: &str, #[case] decimals: u8) {
2750 let value = Decimal::from_str(price).unwrap().normalize();
2751 ensure_canonical_wire_price("Price", value, decimals).unwrap();
2752 }
2753
2754 #[rstest]
2755 #[case("78764.5", 1)]
2758 #[case("102393", 1)]
2759 fn test_order_to_request_raw_price_accepts_canonical_boundary(
2760 #[case] price: &str,
2761 #[case] decimals: u8,
2762 ) {
2763 let request =
2764 order_to_hyperliquid_request_with_asset(&limit_order(price), 0, decimals, false, 50)
2765 .unwrap();
2766 assert_eq!(request.price, Decimal::from_str(price).unwrap());
2767 }
2768
2769 #[rstest]
2770 #[case("limit")]
2771 #[case("trigger")]
2772 fn test_order_conversion_matches_normalized_decimal_fixture(
2773 #[case] case_name: &str,
2774 #[values(false, true)] normalize_prices: bool,
2775 ) {
2776 let fixture: serde_json::Value =
2777 serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
2778 .unwrap();
2779 let case = fixture["cases"]
2780 .as_array()
2781 .unwrap()
2782 .iter()
2783 .find(|case| case["name"] == case_name)
2784 .unwrap();
2785 let input = &case["input"]["orders"][0];
2786 let order = if case_name == "limit" {
2787 limit_order(input["p"].as_str().unwrap())
2788 } else {
2789 stop_market_order(
2790 OrderSide::Buy,
2791 input["t"]["trigger"]["triggerPx"].as_str().unwrap(),
2792 )
2793 };
2794
2795 let converted = order_to_hyperliquid_request_with_asset_and_cloid(
2796 &order,
2797 0,
2798 0,
2799 normalize_prices,
2800 50,
2801 None,
2802 )
2803 .unwrap();
2804 let direct: HyperliquidExchangePlaceOrderRequest =
2805 serde_json::from_value(input.clone()).unwrap();
2806
2807 assert_eq!(
2808 serde_json::to_value(&converted).unwrap(),
2809 case["action"]["orders"][0]
2810 );
2811 assert_eq!(
2812 serde_json::to_value(&direct).unwrap(),
2813 serde_json::to_value(&converted).unwrap()
2814 );
2815 assert_eq!(
2816 rmp_serde::to_vec_named(&direct).unwrap(),
2817 rmp_serde::to_vec_named(&converted).unwrap()
2818 );
2819 }
2820
2821 #[rstest]
2822 fn test_order_to_request_raw_price_rejects_excess_decimals() {
2823 let err = order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, false, 50)
2824 .unwrap_err();
2825 let msg = err.to_string();
2826 assert!(msg.contains("Price 0.62201"), "unexpected message: {msg}");
2827 assert!(
2828 msg.contains("4 decimal places"),
2829 "unexpected message: {msg}"
2830 );
2831 }
2832
2833 #[rstest]
2834 fn test_order_to_request_normalize_still_accepts_excess_decimals() {
2835 let request =
2836 order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, true, 50)
2837 .unwrap();
2838 assert_eq!(request.price, dec!(0.622));
2839 }
2840
2841 #[rstest]
2842 fn test_order_to_request_raw_trigger_price_rejects_excess_decimals() {
2843 let order = stop_market_order(OrderSide::Sell, "0.62201");
2844 let err = order_to_hyperliquid_request_with_asset(&order, 0, 4, false, 50).unwrap_err();
2845 let msg = err.to_string();
2846 assert!(
2847 msg.contains("Trigger price 0.62201"),
2848 "unexpected message: {msg}"
2849 );
2850 assert!(
2851 msg.contains("4 decimal places"),
2852 "unexpected message: {msg}"
2853 );
2854 }
2855
2856 #[rstest]
2857 #[case(false)]
2860 #[case(true)]
2862 fn test_order_to_request_optional_decimals_unknown_cap(#[case] normalize: bool) {
2863 let request = order_to_hyperliquid_request_with_optional_decimals(
2864 &limit_order("0.123456"),
2865 0,
2866 None,
2867 normalize,
2868 50,
2869 None,
2870 )
2871 .unwrap();
2872 let expected = if normalize {
2873 dec!(0.12)
2874 } else {
2875 dec!(0.123456)
2876 };
2877 assert_eq!(request.price, expected);
2878 }
2879}