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::{
71 AggregationSource, BarAggregation, ContingencyType, OrderSide, OrderStatus, OrderType,
72 TimeInForce,
73 },
74 identifiers::{ClientOrderId, TradeId},
75 orders::{Order, any::OrderAny},
76 types::{AccountBalance, Currency, MarginBalance, Money},
77};
78use rust_decimal::Decimal;
79
80use crate::{
81 common::{
82 enums::{
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
893pub fn parse_order_status_with_trigger(
899 status: HyperliquidOrderStatus,
900 trigger_activated: Option<bool>,
901) -> (OrderStatus, Option<String>) {
902 let base_status = OrderStatus::from(status);
903
904 if let Some(activated) = trigger_activated {
906 let trigger_status = if activated {
907 Some("activated".to_string())
908 } else {
909 Some("pending".to_string())
910 };
911 (base_status, trigger_status)
912 } else {
913 (base_status, None)
914 }
915}
916
917pub fn format_trailing_stop_info(
919 offset: &str,
920 offset_type: TrailingOffsetType,
921 callback_price: Option<&str>,
922) -> String {
923 let offset_desc = offset_type.format_offset(offset);
924
925 if let Some(callback) = callback_price {
926 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
927 } else {
928 format!("Trailing stop: {offset_desc} offset")
929 }
930}
931
932pub fn validate_conditional_order_params(
938 trigger_px: Option<&str>,
939 tpsl: Option<&HyperliquidTpSl>,
940 is_market: Option<bool>,
941) -> anyhow::Result<()> {
942 if trigger_px.is_none() {
943 anyhow::bail!("Conditional order missing trigger price");
944 }
945
946 if tpsl.is_none() {
947 anyhow::bail!("Conditional order missing tpsl indicator");
948 }
949
950 if is_market.is_none() {
953 anyhow::bail!("Conditional order missing is_market flag");
954 }
955
956 Ok(())
957}
958
959pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
965 Decimal::from_str_exact(trigger_px)
966 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
967}
968
969pub fn parse_account_balances_and_margins(
980 state: &ClearinghouseState,
981) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
982 let mut balances = Vec::new();
983 let mut margins = Vec::new();
984
985 let currency = Currency::USDC();
986
987 let cross_margin_summary = match &state.cross_margin_summary {
988 Some(summary) => summary,
989 None => return Ok((balances, margins)),
990 };
991
992 let mut total_value = cross_margin_summary.total_raw_usd;
993 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
994
995 if total_value >= Decimal::ZERO && free_value > total_value {
998 total_value = free_value;
999 }
1000
1001 balances.push(AccountBalance::from_total_and_free(
1002 total_value,
1003 free_value,
1004 currency,
1005 )?);
1006
1007 let margin_used = cross_margin_summary.total_margin_used;
1008
1009 if margin_used > Decimal::ZERO {
1010 let initial_margin = Money::from_decimal(margin_used, currency)?;
1013 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
1014 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
1015 }
1016
1017 Ok((balances, margins))
1018}
1019
1020pub fn parse_combined_account_balances_and_margins(
1031 perp_state: &ClearinghouseState,
1032 spot_state: &SpotClearinghouseState,
1033) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
1034 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
1035
1036 let perp_reflects_usdc = perp_state
1037 .cross_margin_summary
1038 .as_ref()
1039 .is_some_and(|summary| {
1040 summary.total_raw_usd != Decimal::ZERO
1041 || summary.total_margin_used > Decimal::ZERO
1042 || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
1043 });
1044
1045 if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
1046 balances.retain(|balance| balance.currency.code != "USDC");
1047 }
1048
1049 let spot_balances = parse_spot_account_balances(spot_state)?;
1050
1051 for balance in spot_balances {
1052 let is_usdc = balance.currency.code == "USDC";
1053 if perp_reflects_usdc && is_usdc {
1054 continue;
1055 }
1056 balances.push(balance);
1057 }
1058
1059 Ok((balances, margins))
1060}
1061
1062pub fn parse_spot_account_balances(
1072 state: &SpotClearinghouseState,
1073) -> anyhow::Result<Vec<AccountBalance>> {
1074 let mut balances = Vec::with_capacity(state.balances.len());
1075
1076 for balance in &state.balances {
1077 if balance.total.is_zero() {
1078 continue;
1079 }
1080
1081 let currency = crate::http::parse::get_currency(balance.coin.as_str());
1082
1083 balances.push(AccountBalance::from_total_and_locked(
1087 balance.total,
1088 balance.hold,
1089 currency,
1090 )?);
1091 }
1092
1093 Ok(balances)
1094}
1095
1096pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExchangeGrouping {
1108 if orders.len() >= 2 {
1109 let entry = &orders[0];
1110 let children = &orders[1..];
1111 let entry_id = entry.client_order_id();
1112 let entry_is_oto =
1113 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1114 let children_are_linked = children.iter().all(|o| {
1115 matches!(
1116 o.contingency_type(),
1117 Some(ContingencyType::Oco | ContingencyType::Ouo)
1118 ) && o.is_reduce_only()
1119 && o.parent_order_id() == Some(entry_id)
1120 });
1121
1122 if entry_is_oto && children_are_linked {
1123 return HyperliquidExchangeGrouping::NormalTpsl;
1124 }
1125 }
1126
1127 let all_oco_linked = orders.len() >= 2
1128 && orders.iter().all(|o| {
1129 matches!(
1130 o.contingency_type(),
1131 Some(ContingencyType::Oco | ContingencyType::Ouo)
1132 ) && o.is_reduce_only()
1133 })
1134 && orders.iter().all(|o| {
1135 o.linked_order_ids().is_some_and(|ids| {
1136 ids.iter()
1137 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1138 })
1139 });
1140
1141 if all_oco_linked {
1142 HyperliquidExchangeGrouping::PositionTpsl
1143 } else {
1144 HyperliquidExchangeGrouping::Na
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use std::str::FromStr;
1151
1152 use nautilus_model::{
1153 enums::{OrderSide, TimeInForce, TriggerType},
1154 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1155 orders::{LimitOrder, OrderAny, StopMarketOrder},
1156 types::{Price, Quantity},
1157 };
1158 use rstest::rstest;
1159 use rust_decimal::Decimal;
1160 use rust_decimal_macros::dec;
1161 use serde::{Deserialize, Serialize};
1162
1163 use super::*;
1164
1165 #[rstest]
1166 fn test_make_fill_trade_id_is_stable() {
1167 let id = make_fill_trade_id(
1170 "0xabc123",
1171 12345,
1172 dec!(50000.0),
1173 dec!(0.1),
1174 1704470400000,
1175 dec!(0.0),
1176 );
1177 assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1178 }
1179
1180 #[derive(Serialize, Deserialize)]
1181 struct TestStruct {
1182 #[serde(
1183 serialize_with = "serialize_decimal_as_str",
1184 deserialize_with = "deserialize_decimal_from_str"
1185 )]
1186 value: Decimal,
1187 #[serde(
1188 serialize_with = "serialize_optional_decimal_as_str",
1189 deserialize_with = "deserialize_optional_decimal_from_str"
1190 )]
1191 optional_value: Option<Decimal>,
1192 }
1193
1194 #[rstest]
1195 #[case("#10", 100_000_010, 1, 0)]
1196 #[case("+10", 100_000_010, 1, 0)]
1197 #[case("#31", 100_000_031, 3, 1)]
1198 #[case("+31", 100_000_031, 3, 1)]
1199 fn test_parse_outcome_symbol(
1200 #[case] symbol: &str,
1201 #[case] raw_asset_id: u32,
1202 #[case] outcome: u32,
1203 #[case] side: u8,
1204 ) {
1205 let asset_id = parse_outcome_symbol(symbol).unwrap();
1206 assert_eq!(asset_id.to_raw(), raw_asset_id);
1207 assert_eq!(asset_id.outcome_index(), Some(outcome));
1208 assert_eq!(asset_id.outcome_side(), Some(side));
1209 }
1210
1211 #[rstest]
1212 #[case("25-YES-OUTCOME", 25, 0)]
1213 #[case("25-NO-OUTCOME", 25, 1)]
1214 #[case("0-YES-OUTCOME", 0, 0)]
1215 #[case("999-NO-OUTCOME", 999, 1)]
1216 fn test_parse_outcome_nautilus_symbol(
1217 #[case] symbol: &str,
1218 #[case] outcome_index: u32,
1219 #[case] side: u8,
1220 ) {
1221 let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1222 assert_eq!(parsed, (outcome_index, side));
1223 }
1224
1225 #[rstest]
1226 #[case("25-OUTCOME")]
1227 #[case("25-MAYBE-OUTCOME")]
1228 #[case("25-yes-OUTCOME")]
1229 #[case("-YES-OUTCOME")]
1230 #[case("YES-25-OUTCOME")]
1231 #[case("25-YES-outcome")]
1232 #[case("25-YES")]
1233 fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1234 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1235 }
1236
1237 #[rstest]
1238 #[case("999999999-YES-OUTCOME")]
1240 #[case("429496729-YES-OUTCOME")]
1242 #[case("4294967295-NO-OUTCOME")]
1244 fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1245 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1246 }
1247
1248 #[rstest]
1249 #[case(25, 0, "25-YES-OUTCOME")]
1250 #[case(25, 1, "25-NO-OUTCOME")]
1251 #[case(0, 0, "0-YES-OUTCOME")]
1252 fn test_format_outcome_nautilus_symbol(
1253 #[case] outcome_index: u32,
1254 #[case] side: u8,
1255 #[case] expected: &str,
1256 ) {
1257 assert_eq!(
1258 format_outcome_nautilus_symbol(outcome_index, side),
1259 expected,
1260 );
1261 }
1262
1263 #[rstest]
1264 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1265 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1266 #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1267 #[case("BTC-USD-PERP", None)]
1268 #[case("+250", None)]
1269 fn test_outcome_token_from_nautilus_symbol(
1270 #[case] symbol: &str,
1271 #[case] expected: Option<String>,
1272 ) {
1273 assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1274 }
1275
1276 #[rstest]
1277 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1278 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1279 #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1280 #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1281 #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1282 #[case("+250", Some("+250".to_string()))]
1283 #[case("#250", Some("#250".to_string()))]
1284 #[case("", None)]
1285 fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1286 assert_eq!(cache_alias_for_symbol(symbol), expected);
1287 }
1288
1289 #[rstest]
1290 #[case("10", "expected #<encoding> or +<encoding>")]
1291 #[case("#", "encoding must not be empty")]
1292 #[case("#1a", "encoding must be numeric")]
1293 #[case("#12", "side digit 0 or 1")]
1294 #[case("#4294967295", "fit u32")]
1295 fn test_parse_outcome_symbol_rejects_invalid_values(
1296 #[case] symbol: &str,
1297 #[case] expected_error: &str,
1298 ) {
1299 let err = parse_outcome_symbol(symbol).unwrap_err();
1300 assert!(
1301 err.to_string().contains(expected_error),
1302 "expected error to contain '{expected_error}', received '{err}'",
1303 );
1304 }
1305
1306 #[rstest]
1307 fn test_decimal_serialization_roundtrip() {
1308 let original = TestStruct {
1309 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1310 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1311 };
1312
1313 let json = serde_json::to_string(&original).unwrap();
1314 println!("Serialized: {json}");
1315
1316 assert!(json.contains("\"123.45678901234567890123456789\""));
1318 assert!(json.contains("\"0.000000001\""));
1319
1320 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1321 assert_eq!(original.value, deserialized.value);
1322 assert_eq!(original.optional_value, deserialized.optional_value);
1323 }
1324
1325 #[rstest]
1326 fn test_decimal_precision_preservation() {
1327 let test_cases = [
1328 "0",
1329 "1",
1330 "0.1",
1331 "0.01",
1332 "0.001",
1333 "123.456789012345678901234567890",
1334 "999999999999999999.999999999999999999",
1335 ];
1336
1337 for case in test_cases {
1338 let decimal = Decimal::from_str(case).unwrap();
1339 let test_struct = TestStruct {
1340 value: decimal,
1341 optional_value: Some(decimal),
1342 };
1343
1344 let json = serde_json::to_string(&test_struct).unwrap();
1345 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1346
1347 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1348 assert_eq!(
1349 Some(decimal),
1350 parsed.optional_value,
1351 "Failed for case: {case}"
1352 );
1353 }
1354 }
1355
1356 #[rstest]
1357 fn test_optional_none_handling() {
1358 let test_struct = TestStruct {
1359 value: Decimal::from_str("42.0").unwrap(),
1360 optional_value: None,
1361 };
1362
1363 let json = serde_json::to_string(&test_struct).unwrap();
1364 assert!(json.contains("null"));
1365
1366 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1367 assert_eq!(test_struct.value, parsed.value);
1368 assert_eq!(None, parsed.optional_value);
1369 }
1370
1371 #[rstest]
1372 fn test_round_down_to_tick() {
1373 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1374 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1375 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1376
1377 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1379 }
1380
1381 #[rstest]
1382 fn test_round_down_to_step() {
1383 assert_eq!(
1384 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1385 dec!(0.1234)
1386 );
1387 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1388 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1389
1390 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1392 }
1393
1394 #[rstest]
1395 fn test_min_notional_validation() {
1396 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1398 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1399
1400 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1402 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1403
1404 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1406 }
1407
1408 #[rstest]
1409 fn test_round_to_sig_figs() {
1410 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1412 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1413 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1414
1415 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1417 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1418 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1419
1420 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1422 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1426
1427 assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1428 assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1429 assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1430 assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1431 }
1432
1433 #[rstest]
1434 fn test_normalize_price() {
1435 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1437 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));
1443 }
1444
1445 #[rstest]
1446 fn test_normalize_quantity() {
1447 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1448 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1449 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1450 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1451 }
1452
1453 #[rstest]
1454 fn test_normalize_order_complete() {
1455 let result = normalize_order(
1456 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1464
1465 assert!(result.is_ok());
1466 let (price, qty) = result.unwrap();
1467 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1470
1471 #[rstest]
1472 fn test_normalize_order_min_notional_fail() {
1473 let result = normalize_order(
1474 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1482
1483 assert!(result.is_err());
1484 assert!(result.unwrap_err().contains("Notional value"));
1485 }
1486
1487 #[rstest]
1488 fn test_edge_cases() {
1489 assert_eq!(
1491 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1492 dec!(0.000001)
1493 );
1494
1495 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1497
1498 assert_eq!(
1500 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1501 dec!(100.00)
1502 );
1503 }
1504
1505 #[rstest]
1506 fn test_is_conditional_order_data() {
1507 assert!(is_conditional_order_data(
1509 Some(dec!(50000.0)),
1510 Some(&HyperliquidTpSl::Sl)
1511 ));
1512
1513 assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1515
1516 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1518
1519 assert!(!is_conditional_order_data(None, None));
1521 }
1522
1523 #[rstest]
1524 fn test_parse_trigger_order_type() {
1525 assert_eq!(
1527 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1528 OrderType::StopMarket
1529 );
1530
1531 assert_eq!(
1533 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1534 OrderType::StopLimit
1535 );
1536
1537 assert_eq!(
1539 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1540 OrderType::MarketIfTouched
1541 );
1542
1543 assert_eq!(
1545 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1546 OrderType::LimitIfTouched
1547 );
1548 }
1549
1550 #[rstest]
1551 fn test_parse_order_status_with_trigger() {
1552 let (status, trigger_status) =
1554 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1555 assert_eq!(status, OrderStatus::Accepted);
1556 assert_eq!(trigger_status, Some("activated".to_string()));
1557
1558 let (status, trigger_status) =
1560 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1561 assert_eq!(status, OrderStatus::Accepted);
1562 assert_eq!(trigger_status, Some("pending".to_string()));
1563
1564 let (status, trigger_status) =
1566 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1567 assert_eq!(status, OrderStatus::Accepted);
1568 assert_eq!(trigger_status, None);
1569 }
1570
1571 #[rstest]
1572 fn test_format_trailing_stop_info() {
1573 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1575 assert!(info.contains("100.0"));
1576 assert!(info.contains("callback at 50000.0"));
1577
1578 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1580 assert!(info.contains("5.0%"));
1581 assert!(info.contains("Trailing stop"));
1582
1583 let info =
1585 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1586 assert!(info.contains("250 bps"));
1587 assert!(info.contains("49000.0"));
1588 }
1589
1590 #[rstest]
1591 fn test_parse_trigger_price() {
1592 let result = parse_trigger_price("50000.0");
1594 assert!(result.is_ok());
1595 assert_eq!(result.unwrap(), dec!(50000.0));
1596
1597 let result = parse_trigger_price("49000");
1599 assert!(result.is_ok());
1600 assert_eq!(result.unwrap(), dec!(49000));
1601
1602 let result = parse_trigger_price("invalid");
1604 assert!(result.is_err());
1605
1606 let result = parse_trigger_price("");
1608 assert!(result.is_err());
1609 }
1610
1611 #[rstest]
1612 #[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(
1623 #[case] trigger_price: Decimal,
1624 #[case] is_buy: bool,
1625 #[case] expected: Decimal,
1626 ) {
1627 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1628 assert_eq!(result, expected);
1629
1630 if is_buy {
1632 assert!(result >= trigger_price);
1633 } else {
1634 assert!(result <= trigger_price);
1635 }
1636 }
1637
1638 #[rstest]
1639 #[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))]
1649 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1650 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1652 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1653 fn test_clamp_price_to_precision(
1654 #[case] price: Decimal,
1655 #[case] decimals: u8,
1656 #[case] is_buy: bool,
1657 #[case] expected: Decimal,
1658 ) {
1659 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1660 }
1661
1662 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1663 OrderAny::StopMarket(StopMarketOrder::new(
1664 TraderId::from("TESTER-001"),
1665 StrategyId::from("S-001"),
1666 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1667 ClientOrderId::from("O-001"),
1668 side,
1669 Quantity::from(1),
1670 Price::from(trigger_price),
1671 TriggerType::LastPrice,
1672 TimeInForce::Gtc,
1673 None,
1674 false,
1675 false,
1676 None,
1677 None,
1678 None,
1679 None,
1680 None,
1681 None,
1682 None,
1683 None,
1684 None,
1685 None,
1686 None,
1687 Default::default(),
1688 Default::default(),
1689 ))
1690 }
1691
1692 #[rstest]
1693 #[case(OrderSide::Sell, "2470.00", 2)]
1695 #[case(OrderSide::Buy, "2470.00", 2)]
1696 #[case(OrderSide::Sell, "104567.3", 1)]
1698 #[case(OrderSide::Buy, "104567.3", 1)]
1699 #[case(OrderSide::Sell, "0.50", 4)]
1701 #[case(OrderSide::Buy, "0.50", 4)]
1702 #[case(OrderSide::Sell, "2470.00", 1)]
1706 #[case(OrderSide::Buy, "2470.00", 1)]
1707 #[case(OrderSide::Sell, "2470.00", 0)]
1711 #[case(OrderSide::Buy, "2470.00", 0)]
1712 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1713 #[case] side: OrderSide,
1714 #[case] trigger_str: &str,
1715 #[case] price_decimals: u8,
1716 ) {
1717 let order = stop_market_order(side, trigger_str);
1718 let request = order_to_hyperliquid_request_with_asset(
1719 &order,
1720 0,
1721 price_decimals,
1722 true,
1723 DEFAULT_MARKET_SLIPPAGE_BPS,
1724 )
1725 .unwrap();
1726 let trigger = Decimal::from_str(trigger_str).unwrap();
1727 let is_buy = matches!(side, OrderSide::Buy);
1728
1729 if is_buy {
1731 assert!(
1732 request.price >= trigger,
1733 "BUY limit {} must be >= trigger {trigger}",
1734 request.price,
1735 );
1736 assert!(request.is_buy);
1737 } else {
1738 assert!(
1739 request.price <= trigger,
1740 "SELL limit {} must be <= trigger {trigger}",
1741 request.price,
1742 );
1743 assert!(!request.is_buy);
1744 }
1745
1746 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1748 let sig_rounded = round_to_sig_figs(derived, 5);
1749 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1750 assert_eq!(request.price, expected);
1751
1752 let price_str = request.price.to_string();
1754 let actual_decimals = price_str
1755 .find('.')
1756 .map_or(0, |dot| price_str.len() - dot - 1);
1757 assert!(
1758 actual_decimals <= price_decimals as usize,
1759 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1760 );
1761
1762 if price_str.contains('.') {
1764 assert!(
1765 !price_str.ends_with('0'),
1766 "Price {price_str} has decimal trailing zeros",
1767 );
1768 }
1769
1770 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1771 assert_eq!(
1772 request.kind,
1773 HyperliquidExchangeOrderKind::Trigger {
1774 trigger: HyperliquidExchangeTriggerParams {
1775 is_market: true,
1776 trigger_px: expected_trigger,
1777 tpsl: HyperliquidExchangeTpSl::Sl,
1778 },
1779 },
1780 );
1781 }
1782
1783 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1784 HyperliquidExchangeResponse::Status {
1785 status: "ok".to_string(),
1786 response: inner,
1787 }
1788 }
1789
1790 #[rstest]
1791 fn test_extract_inner_error_order_with_error() {
1792 let response = ok_response(serde_json::json!({
1793 "type": "order",
1794 "data": {"statuses": [{"error": "Order has invalid price."}]}
1795 }));
1796 assert_eq!(
1797 extract_inner_error(&response),
1798 Some("Order has invalid price.".to_string()),
1799 );
1800 }
1801
1802 #[rstest]
1803 fn test_extract_inner_error_order_resting() {
1804 let response = ok_response(serde_json::json!({
1805 "type": "order",
1806 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1807 }));
1808 assert_eq!(extract_inner_error(&response), None);
1809 }
1810
1811 #[rstest]
1812 fn test_extract_inner_error_order_filled() {
1813 let response = ok_response(serde_json::json!({
1814 "type": "order",
1815 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1816 }));
1817 assert_eq!(extract_inner_error(&response), None);
1818 }
1819
1820 #[rstest]
1821 fn test_extract_inner_error_cancel_error() {
1822 let response = ok_response(serde_json::json!({
1823 "type": "cancel",
1824 "data": {"statuses": [{"error": "Order not found"}]}
1825 }));
1826 assert_eq!(
1827 extract_inner_error(&response),
1828 Some("Order not found".to_string()),
1829 );
1830 }
1831
1832 #[rstest]
1833 fn test_extract_inner_error_cancel_success() {
1834 let response = ok_response(serde_json::json!({
1835 "type": "cancel",
1836 "data": {"statuses": ["success"]}
1837 }));
1838 assert_eq!(extract_inner_error(&response), None);
1839 }
1840
1841 #[rstest]
1842 fn test_extract_inner_error_modify_error() {
1843 let response = ok_response(serde_json::json!({
1844 "type": "modify",
1845 "data": {"statuses": [{"error": "Invalid modify"}]}
1846 }));
1847 assert_eq!(
1848 extract_inner_error(&response),
1849 Some("Invalid modify".to_string()),
1850 );
1851 }
1852
1853 #[rstest]
1854 fn test_extract_inner_error_modify_success() {
1855 let response = ok_response(serde_json::json!({
1856 "type": "modify",
1857 "data": {"statuses": ["success"]}
1858 }));
1859 assert_eq!(extract_inner_error(&response), None);
1860 }
1861
1862 #[rstest]
1863 fn test_extract_inner_error_non_status_response() {
1864 let response = HyperliquidExchangeResponse::Error {
1865 error: "top-level error".to_string(),
1866 };
1867 assert_eq!(extract_inner_error(&response), None);
1868 }
1869
1870 #[rstest]
1871 fn test_extract_inner_error_unparsable_response() {
1872 let response = ok_response(serde_json::json!({"unknown": "data"}));
1873 assert_eq!(extract_inner_error(&response), None);
1874 }
1875
1876 #[rstest]
1877 fn test_extract_inner_error_returns_first_error_in_batch() {
1878 let response = ok_response(serde_json::json!({
1879 "type": "order",
1880 "data": {"statuses": [
1881 {"resting": {"oid": 1}},
1882 {"error": "Second failed"},
1883 {"error": "Third failed"},
1884 ]}
1885 }));
1886 assert_eq!(
1887 extract_inner_error(&response),
1888 Some("Second failed".to_string()),
1889 );
1890 }
1891
1892 #[rstest]
1893 fn test_extract_inner_errors_mixed_batch() {
1894 let response = ok_response(serde_json::json!({
1895 "type": "order",
1896 "data": {"statuses": [
1897 {"resting": {"oid": 1}},
1898 {"error": "Failed order"},
1899 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1900 ]}
1901 }));
1902 let errors = extract_inner_errors(&response);
1903 assert_eq!(errors.len(), 3);
1904 assert_eq!(errors[0], None);
1905 assert_eq!(errors[1], Some("Failed order".to_string()));
1906 assert_eq!(errors[2], None);
1907 }
1908
1909 #[rstest]
1910 fn test_extract_inner_errors_all_success() {
1911 let response = ok_response(serde_json::json!({
1912 "type": "order",
1913 "data": {"statuses": [
1914 {"resting": {"oid": 1}},
1915 {"resting": {"oid": 2}},
1916 ]}
1917 }));
1918 let errors = extract_inner_errors(&response);
1919 assert_eq!(errors.len(), 2);
1920 assert!(errors.iter().all(|e| e.is_none()));
1921 }
1922
1923 #[rstest]
1924 fn test_extract_inner_errors_cancel_success() {
1925 let response = ok_response(serde_json::json!({
1926 "type": "cancel",
1927 "data": {"statuses": ["success"]}
1928 }));
1929 let errors = extract_inner_errors(&response);
1930 assert_eq!(errors.len(), 1);
1931 assert!(errors[0].is_none());
1932 }
1933
1934 #[rstest]
1935 fn test_extract_inner_errors_cancel_mixed() {
1936 let response = ok_response(serde_json::json!({
1937 "type": "cancel",
1938 "data": {"statuses": [
1939 "success",
1940 {"error": "Order was never placed, already canceled, or filled."},
1941 "success",
1942 ]}
1943 }));
1944 let errors = extract_inner_errors(&response);
1945 assert_eq!(errors.len(), 3);
1946 assert_eq!(errors[0], None);
1947 assert_eq!(
1948 errors[1],
1949 Some("Order was never placed, already canceled, or filled.".to_string())
1950 );
1951 assert_eq!(errors[2], None);
1952 }
1953
1954 #[rstest]
1955 fn test_extract_inner_errors_modify_mixed() {
1956 let response = ok_response(serde_json::json!({
1957 "type": "modify",
1958 "data": {"statuses": [
1959 "success",
1960 {"error": "Order does not exist"},
1961 ]}
1962 }));
1963 let errors = extract_inner_errors(&response);
1964 assert_eq!(errors.len(), 2);
1965 assert_eq!(errors[0], None);
1966 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1967 }
1968
1969 #[rstest]
1970 fn test_extract_inner_errors_unparsable() {
1971 let response = ok_response(serde_json::json!({"foo": "bar"}));
1972 let errors = extract_inner_errors(&response);
1973 assert!(errors.is_empty());
1974 }
1975
1976 fn count_sig_figs(s: &str) -> usize {
1977 let s = s.trim_start_matches('-');
1978 if s.contains('.') {
1979 let digits: String = s.replace('.', "");
1981 digits.trim_start_matches('0').len()
1982 } else {
1983 let s = s.trim_start_matches('0');
1985 s.trim_end_matches('0').len()
1986 }
1987 }
1988
1989 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1990 QuoteTick::new(
1991 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1992 Price::from(bid),
1993 Price::from(ask),
1994 Quantity::from("1"),
1995 Quantity::from("1"),
1996 Default::default(),
1997 Default::default(),
1998 )
1999 }
2000
2001 #[rstest]
2002 #[case("2460.00", "2470.00", true, 2, "2482.4")]
2008 #[case("2460.00", "2470.00", false, 2, "2447.7")]
2010 #[case("104500.0", "104567.3", true, 1, "105090")]
2014 #[case("104500.0", "104567.3", false, 1, "103980")]
2016 #[case("0.4900", "0.5000", true, 4, "0.5025")]
2020 #[case("0.4900", "0.5000", false, 4, "0.4875")]
2022 #[case("49900", "50000", true, 0, "50250")]
2026 #[case("49900", "50000", false, 0, "49650")]
2028 #[case("0.001200", "0.001234", true, 6, "0.001241")]
2032 #[case("0.001200", "0.001234", false, 6, "0.001194")]
2034 fn test_derive_market_order_price(
2035 #[case] bid: &str,
2036 #[case] ask: &str,
2037 #[case] is_buy: bool,
2038 #[case] price_decimals: u8,
2039 #[case] expected: &str,
2040 ) {
2041 let quote = make_quote(bid, ask);
2042 let result =
2043 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
2044 let expected_dec = Decimal::from_str(expected).unwrap();
2045 assert_eq!(result, expected_dec);
2046
2047 let base = if is_buy {
2049 quote.ask_price.as_decimal()
2050 } else {
2051 quote.bid_price.as_decimal()
2052 };
2053 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2054 let sig_rounded = round_to_sig_figs(derived, 5);
2055 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2056 assert_eq!(result, pipeline);
2057
2058 let s = result.to_string();
2060 if s.contains('.') {
2061 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2062 }
2063
2064 let sig_count = count_sig_figs(&s);
2066 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2067
2068 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2070 assert!(
2071 actual_decimals <= price_decimals as usize,
2072 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2073 );
2074 }
2075
2076 #[rstest]
2077 #[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(
2085 #[case] slippage_bps: u32,
2086 #[case] trigger: Decimal,
2087 #[case] is_buy: bool,
2088 #[case] expected: Decimal,
2089 ) {
2090 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2091 assert_eq!(result, expected);
2092 }
2093
2094 #[rstest]
2095 fn test_derive_market_order_price_respects_slippage_override() {
2096 let quote = make_quote("100.00", "100.10");
2097 let tight = derive_market_order_price("e, true, 2, 50);
2098 let wide = derive_market_order_price("e, true, 2, 800);
2099 assert_eq!(tight, dec!(100.6));
2100 assert_eq!(wide, dec!(108.11));
2101 assert!(wide > tight);
2102 }
2103
2104 #[rstest]
2108 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2109 let json = r#"{
2110 "assetPositions": [],
2111 "crossMarginSummary": {
2112 "accountValue": "150",
2113 "totalNtlPos": "0",
2114 "totalRawUsd": "100",
2115 "totalMarginUsed": "20",
2116 "withdrawable": "120"
2117 },
2118 "withdrawable": "80",
2119 "time": 1700000000000
2120 }"#;
2121
2122 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2123 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2124
2125 assert_eq!(balances.len(), 1);
2126 let balance = &balances[0];
2127 assert_eq!(balance.total.as_decimal(), dec!(100));
2130 assert_eq!(balance.free.as_decimal(), dec!(80));
2131 assert_eq!(balance.locked.as_decimal(), dec!(20));
2132
2133 assert_eq!(margins.len(), 1);
2134 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2135 }
2136
2137 #[rstest]
2138 fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2139 let json =
2140 include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2141
2142 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2143 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2144
2145 assert_eq!(balances.len(), 1);
2146 let balance = &balances[0];
2147 assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2148 assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2149 assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2150
2151 assert_eq!(margins.len(), 1);
2152 assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2153 }
2154
2155 #[rstest]
2156 fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2157 let json = r#"{
2158 "assetPositions": [],
2159 "crossMarginSummary": {
2160 "accountValue": "100",
2161 "totalNtlPos": "0",
2162 "totalRawUsd": "100",
2163 "totalMarginUsed": "0",
2164 "withdrawable": "100"
2165 },
2166 "withdrawable": "150",
2167 "time": 1700000000000
2168 }"#;
2169
2170 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2171 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2172
2173 assert_eq!(balances.len(), 1);
2174 let balance = &balances[0];
2175 assert_eq!(balance.total.as_decimal(), dec!(150));
2176 assert_eq!(balance.free.as_decimal(), dec!(150));
2177 assert_eq!(balance.locked.as_decimal(), dec!(0));
2178 }
2179
2180 #[rstest]
2181 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2182 let json = r#"{
2183 "assetPositions": [],
2184 "withdrawable": "100",
2185 "time": 1700000000000
2186 }"#;
2187
2188 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2189 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2190 assert!(balances.is_empty());
2191 assert!(margins.is_empty());
2192 }
2193
2194 #[rstest]
2195 fn test_parse_spot_account_balances_emits_one_per_token() {
2196 let json = r#"{
2197 "balances": [
2198 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2199 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2200 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2201 ]
2202 }"#;
2203
2204 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2205 let balances = parse_spot_account_balances(&state).unwrap();
2206
2207 assert_eq!(balances.len(), 2);
2208
2209 let usdc = &balances[0];
2210 assert_eq!(usdc.currency.code, "USDC");
2211 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2212 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2213 assert_eq!(usdc.locked.as_decimal(), dec!(10));
2214
2215 let purr = &balances[1];
2216 assert_eq!(purr.currency.code, "PURR");
2217 assert_eq!(purr.total.as_decimal(), dec!(50));
2218 assert_eq!(purr.free.as_decimal(), dec!(50));
2219 }
2220
2221 #[rstest]
2222 fn test_parse_spot_account_balances_clamps_hold_to_total() {
2223 let json = r#"{
2224 "balances": [
2225 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2226 ]
2227 }"#;
2228
2229 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2230 let balances = parse_spot_account_balances(&state).unwrap();
2231
2232 assert_eq!(balances.len(), 1);
2233 let hype = &balances[0];
2234 assert_eq!(hype.total.as_decimal(), dec!(5));
2235 assert_eq!(hype.free.as_decimal(), dec!(0));
2236 assert_eq!(hype.locked.as_decimal(), dec!(5));
2237 }
2238
2239 #[rstest]
2240 fn test_parse_spot_account_balances_empty() {
2241 let state = SpotClearinghouseState::default();
2242 let balances = parse_spot_account_balances(&state).unwrap();
2243 assert!(balances.is_empty());
2244 }
2245
2246 #[rstest]
2247 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2248 let perp_json = r#"{
2249 "assetPositions": [],
2250 "crossMarginSummary": {
2251 "accountValue": "500",
2252 "totalNtlPos": "0",
2253 "totalRawUsd": "500",
2254 "totalMarginUsed": "0",
2255 "withdrawable": "500"
2256 },
2257 "withdrawable": "500"
2258 }"#;
2259 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2260
2261 let spot_json = r#"{
2262 "balances": [
2263 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2264 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2265 ]
2266 }"#;
2267 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2268
2269 let (balances, margins) =
2270 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2271
2272 assert!(margins.is_empty());
2273 assert_eq!(balances.len(), 2);
2274 assert_eq!(balances[0].currency.code, "USDC");
2275 assert_eq!(balances[0].total.as_decimal(), dec!(500));
2276 assert_eq!(balances[1].currency.code, "PURR");
2277 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2278 }
2279
2280 #[rstest]
2281 fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2282 let perp_json = r#"{
2283 "assetPositions": [],
2284 "crossMarginSummary": {
2285 "accountValue": "0",
2286 "totalNtlPos": "0",
2287 "totalRawUsd": "0",
2288 "totalMarginUsed": "0",
2289 "withdrawable": "0"
2290 },
2291 "withdrawable": "0"
2292 }"#;
2293 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2294
2295 let spot_json = r#"{
2296 "balances": [
2297 {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2298 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2299 ]
2300 }"#;
2301 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2302
2303 let (balances, margins) =
2304 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2305
2306 assert!(margins.is_empty());
2307 assert_eq!(balances.len(), 2);
2308 assert_eq!(balances[0].currency.code, "USDC");
2309 assert_eq!(balances[0].total.as_decimal(), dec!(75));
2310 assert_eq!(balances[0].free.as_decimal(), dec!(70));
2311 assert_eq!(balances[1].currency.code, "PURR");
2312 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2313 }
2314
2315 #[rstest]
2316 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2317 let perp_json = r#"{
2318 "assetPositions": [],
2319 "crossMarginSummary": {
2320 "accountValue": "50",
2321 "totalNtlPos": "0",
2322 "totalRawUsd": "50",
2323 "totalMarginUsed": "0",
2324 "withdrawable": "0"
2325 },
2326 "withdrawable": "0"
2327 }"#;
2328 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2329
2330 let spot_json = r#"{
2331 "balances": [
2332 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2333 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2334 ]
2335 }"#;
2336 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2337
2338 let (balances, margins) =
2339 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2340
2341 assert!(margins.is_empty());
2342 assert_eq!(balances.len(), 2);
2343 assert_eq!(balances[0].currency.code, "USDC");
2344 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2345 assert_eq!(balances[1].currency.code, "PURR");
2346 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2347 }
2348
2349 #[rstest]
2350 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2351 let perp_json = r#"{
2352 "assetPositions": [],
2353 "crossMarginSummary": {
2354 "accountValue": "-50",
2355 "totalNtlPos": "0",
2356 "totalRawUsd": "-50",
2357 "totalMarginUsed": "0",
2358 "withdrawable": "0"
2359 },
2360 "withdrawable": "0"
2361 }"#;
2362 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2363
2364 let spot_json = r#"{
2365 "balances": [
2366 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2367 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2368 ]
2369 }"#;
2370 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2371
2372 let (balances, margins) =
2373 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2374
2375 assert!(margins.is_empty());
2376 assert_eq!(balances.len(), 2);
2377 assert_eq!(balances[0].currency.code, "USDC");
2378 assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2379 assert_eq!(balances[1].currency.code, "PURR");
2380 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2381 }
2382
2383 #[rstest]
2384 fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2385 let perp_json = r#"{
2386 "assetPositions": [],
2387 "crossMarginSummary": {
2388 "accountValue": "0",
2389 "totalNtlPos": "0",
2390 "totalRawUsd": "0",
2391 "totalMarginUsed": "25",
2392 "withdrawable": "0"
2393 },
2394 "withdrawable": "0"
2395 }"#;
2396 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2397
2398 let spot_json = r#"{
2399 "balances": [
2400 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2401 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2402 ]
2403 }"#;
2404 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2405
2406 let (balances, margins) =
2407 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2408
2409 assert_eq!(margins.len(), 1);
2410 assert_eq!(balances.len(), 2);
2411 assert_eq!(balances[0].currency.code, "USDC");
2412 assert_eq!(balances[0].total.as_decimal(), dec!(0));
2413 assert_eq!(balances[1].currency.code, "PURR");
2414 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2415 }
2416
2417 #[rstest]
2418 fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2419 let perp_json = r#"{
2420 "assetPositions": [],
2421 "crossMarginSummary": {
2422 "accountValue": "0",
2423 "totalNtlPos": "0",
2424 "totalRawUsd": "0",
2425 "totalMarginUsed": "0",
2426 "withdrawable": "50"
2427 },
2428 "withdrawable": "50"
2429 }"#;
2430 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2431
2432 let spot_json = r#"{
2433 "balances": [
2434 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2435 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2436 ]
2437 }"#;
2438 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2439
2440 let (balances, margins) =
2441 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2442
2443 assert!(margins.is_empty());
2444 assert_eq!(balances.len(), 2);
2445 assert_eq!(balances[0].currency.code, "USDC");
2446 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2447 assert_eq!(balances[0].free.as_decimal(), dec!(50));
2448 assert_eq!(balances[1].currency.code, "PURR");
2449 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2450 }
2451
2452 #[rstest]
2453 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2454 let perp_json = r#"{"assetPositions": []}"#;
2455 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2456
2457 let spot_json = r#"{
2458 "balances": [
2459 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2460 ]
2461 }"#;
2462 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2463
2464 let (balances, _) =
2465 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2466
2467 assert_eq!(balances.len(), 1);
2468 assert_eq!(balances[0].currency.code, "USDC");
2469 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2470 }
2471
2472 fn limit_order(price: &str) -> OrderAny {
2473 OrderAny::Limit(LimitOrder::new(
2474 TraderId::from("TESTER-001"),
2475 StrategyId::from("S-001"),
2476 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"),
2477 ClientOrderId::from("O-1"),
2478 OrderSide::Buy,
2479 Quantity::from(1),
2480 Price::from(price),
2481 TimeInForce::Gtc,
2482 None,
2483 false,
2484 false,
2485 false,
2486 None,
2487 None,
2488 None,
2489 None,
2490 None,
2491 None,
2492 None,
2493 None,
2494 None,
2495 None,
2496 None,
2497 Default::default(),
2498 Default::default(),
2499 ))
2500 }
2501
2502 #[rstest]
2503 #[case("78764.5", 1)]
2505 #[case("102393", 1)]
2506 #[case("0", 0)]
2507 #[case("0.11525", 5)]
2508 #[case("0.10", 1)]
2509 fn test_ensure_canonical_wire_price_accepts(#[case] price: &str, #[case] decimals: u8) {
2510 let value = Decimal::from_str(price).unwrap().normalize();
2511 ensure_canonical_wire_price("Price", value, decimals).unwrap();
2512 }
2513
2514 #[rstest]
2515 #[case("78764.5", 1)]
2518 #[case("102393", 1)]
2519 fn test_order_to_request_raw_price_accepts_canonical_boundary(
2520 #[case] price: &str,
2521 #[case] decimals: u8,
2522 ) {
2523 let request =
2524 order_to_hyperliquid_request_with_asset(&limit_order(price), 0, decimals, false, 50)
2525 .unwrap();
2526 assert_eq!(request.price, Decimal::from_str(price).unwrap());
2527 }
2528
2529 #[rstest]
2530 fn test_order_to_request_raw_price_rejects_excess_decimals() {
2531 let err = order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, false, 50)
2532 .unwrap_err();
2533 let msg = err.to_string();
2534 assert!(msg.contains("Price 0.62201"), "unexpected message: {msg}");
2535 assert!(
2536 msg.contains("4 decimal places"),
2537 "unexpected message: {msg}"
2538 );
2539 }
2540
2541 #[rstest]
2542 fn test_order_to_request_normalize_still_accepts_excess_decimals() {
2543 let request =
2544 order_to_hyperliquid_request_with_asset(&limit_order("0.62201"), 0, 4, true, 50)
2545 .unwrap();
2546 assert_eq!(request.price, dec!(0.622));
2547 }
2548
2549 #[rstest]
2550 fn test_order_to_request_raw_trigger_price_rejects_excess_decimals() {
2551 let order = stop_market_order(OrderSide::Sell, "0.62201");
2552 let err = order_to_hyperliquid_request_with_asset(&order, 0, 4, false, 50).unwrap_err();
2553 let msg = err.to_string();
2554 assert!(
2555 msg.contains("Trigger price 0.62201"),
2556 "unexpected message: {msg}"
2557 );
2558 assert!(
2559 msg.contains("4 decimal places"),
2560 "unexpected message: {msg}"
2561 );
2562 }
2563
2564 #[rstest]
2565 #[case(false)]
2568 #[case(true)]
2570 fn test_order_to_request_optional_decimals_unknown_cap(#[case] normalize: bool) {
2571 let request = order_to_hyperliquid_request_with_optional_decimals(
2572 &limit_order("0.123456"),
2573 0,
2574 None,
2575 normalize,
2576 50,
2577 None,
2578 )
2579 .unwrap();
2580 let expected = if normalize {
2581 dec!(0.12)
2582 } else {
2583 dec!(0.123456)
2584 };
2585 assert_eq!(request.price, expected);
2586 }
2587}