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, HyperliquidExchangeResponse,
90 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelStatus, HyperliquidExecGrouping,
91 HyperliquidExecLimitParams, HyperliquidExecModifyStatus, HyperliquidExecOrderKind,
92 HyperliquidExecOrderStatus, HyperliquidExecPlaceOrderRequest, HyperliquidExecResponseData,
93 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
94 SpotClearinghouseState,
95 },
96 websocket::messages::TrailingOffsetType,
97};
98
99pub fn make_fill_trade_id(
107 hash: &str,
108 oid: u64,
109 px: Decimal,
110 sz: Decimal,
111 time: u64,
112 start_position: Decimal,
113) -> TradeId {
114 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
116 for &b in hash.as_bytes() {
117 h ^= b as u64;
118 h = h.wrapping_mul(0x0100_0000_01b3);
119 }
120
121 for b in oid.to_le_bytes() {
122 h ^= b as u64;
123 h = h.wrapping_mul(0x0100_0000_01b3);
124 }
125
126 for &b in px.to_string().as_bytes() {
127 h ^= b as u64;
128 h = h.wrapping_mul(0x0100_0000_01b3);
129 }
130
131 for &b in sz.to_string().as_bytes() {
132 h ^= b as u64;
133 h = h.wrapping_mul(0x0100_0000_01b3);
134 }
135
136 for b in time.to_le_bytes() {
137 h ^= b as u64;
138 h = h.wrapping_mul(0x0100_0000_01b3);
139 }
140
141 for &b in start_position.to_string().as_bytes() {
142 h ^= b as u64;
143 h = h.wrapping_mul(0x0100_0000_01b3);
144 }
145 TradeId::new(format!("{h:016x}-{oid:016x}"))
146}
147
148#[inline]
150pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
151 if tick_size.is_zero() {
152 return price;
153 }
154 (price / tick_size).floor() * tick_size
155}
156
157#[inline]
159pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
160 if step_size.is_zero() {
161 return qty;
162 }
163 (qty / step_size).floor() * step_size
164}
165
166#[inline]
168pub fn ensure_min_notional(
169 price: Decimal,
170 qty: Decimal,
171 min_notional: Decimal,
172) -> Result<(), String> {
173 let notional = price * qty;
174 if notional < min_notional {
175 Err(format!(
176 "Notional value {notional} is less than minimum required {min_notional}"
177 ))
178 } else {
179 Ok(())
180 }
181}
182
183pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
186 if value.is_zero() {
187 return Decimal::ZERO;
188 }
189
190 let mantissa = value.mantissa().unsigned_abs();
192 let magnitude = mantissa.ilog10() as i32 - value.scale() as i32;
193
194 let shift = sig_figs as i32 - 1 - magnitude;
195 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
196
197 if shift >= 0 {
198 (value * factor).round() / factor
199 } else {
200 (value / factor).round() * factor
201 }
202}
203
204pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
206 let sig_fig_price = round_to_sig_figs(price, 5);
208 let scale = Decimal::from(10_u64.pow(decimals as u32));
210 (sig_fig_price * scale).floor() / scale
211}
212
213pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
215 let scale = Decimal::from(10_u64.pow(decimals as u32));
216 (qty * scale).floor() / scale
217}
218
219pub fn normalize_order(
221 price: Decimal,
222 qty: Decimal,
223 tick_size: Decimal,
224 step_size: Decimal,
225 min_notional: Decimal,
226 price_decimals: u8,
227 size_decimals: u8,
228) -> Result<(Decimal, Decimal), String> {
229 let normalized_price = normalize_price(price, price_decimals);
231 let normalized_qty = normalize_quantity(qty, size_decimals);
232
233 let final_price = round_down_to_tick(normalized_price, tick_size);
235 let final_qty = round_down_to_step(normalized_qty, step_size);
236
237 ensure_min_notional(final_price, final_qty, min_notional)?;
239
240 Ok((final_price, final_qty))
241}
242
243#[inline]
245pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
246 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
247 Ok(UnixNanos::from(value))
248}
249
250pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
260 let encoding = parse_outcome_symbol_encoding(symbol)?;
261 HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
262 format!(
263 "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
264 )
265 })
266}
267
268fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
269 let encoding = symbol
270 .strip_prefix('#')
271 .or_else(|| symbol.strip_prefix('+'))
272 .with_context(|| {
273 format!(
274 "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
275 )
276 })?;
277
278 if encoding.is_empty() {
279 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
280 }
281
282 if !encoding.bytes().all(|b| b.is_ascii_digit()) {
283 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
284 }
285
286 encoding
287 .parse::<u32>()
288 .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
289}
290
291pub const OUTCOME_SYMBOL_SUFFIX: &str = "-OUTCOME";
293pub const OUTCOME_SIDE_YES: &str = "YES";
295pub const OUTCOME_SIDE_NO: &str = "NO";
297
298#[must_use]
309pub fn parse_outcome_nautilus_symbol(symbol: &str) -> Option<(u32, u8)> {
310 let rest = symbol.strip_suffix(OUTCOME_SYMBOL_SUFFIX)?;
311 let (index_str, side_str) = rest.rsplit_once('-')?;
312 let outcome_index = index_str.parse::<u32>().ok()?;
313 let side = match side_str {
314 OUTCOME_SIDE_YES => 0,
315 OUTCOME_SIDE_NO => 1,
316 _ => return None,
317 };
318 let encoding = outcome_index
319 .checked_mul(10)?
320 .checked_add(u32::from(side))?;
321 HyperliquidAssetId::from_outcome_encoding(encoding)?;
322 Some((outcome_index, side))
323}
324
325#[must_use]
328pub fn format_outcome_nautilus_symbol(outcome_index: u32, side: u8) -> String {
329 let side_label = match side {
330 0 => OUTCOME_SIDE_YES,
331 _ => OUTCOME_SIDE_NO,
332 };
333 format!("{outcome_index}-{side_label}{OUTCOME_SYMBOL_SUFFIX}")
334}
335
336#[must_use]
339pub fn outcome_token_from_nautilus_symbol(symbol: &str) -> Option<String> {
340 let (outcome_index, side) = parse_outcome_nautilus_symbol(symbol)?;
341 let encoding = 10 * outcome_index + u32::from(side);
342 Some(format!("+{encoding}"))
343}
344
345#[must_use]
356pub fn cache_alias_for_symbol(symbol: &str) -> Option<String> {
357 if let Some(token) = outcome_token_from_nautilus_symbol(symbol) {
358 return Some(token);
359 }
360
361 let leading = symbol.split('-').next()?;
362 if leading.is_empty() {
363 None
364 } else {
365 Some(leading.to_string())
366 }
367}
368
369pub fn time_in_force_to_hyperliquid_tif(
375 tif: TimeInForce,
376 is_post_only: bool,
377) -> anyhow::Result<HyperliquidExecTif> {
378 match (tif, is_post_only) {
379 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
381 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
382 (TimeInForce::Fok, false) => {
383 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
384 }
385 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
386 }
387}
388
389fn determine_tpsl_type(
390 order_type: OrderType,
391 order_side: OrderSide,
392 trigger_price: Decimal,
393 current_price: Option<Decimal>,
394) -> HyperliquidExecTpSl {
395 match order_type {
396 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
398
399 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
401
402 _ => {
404 if let Some(current) = current_price {
405 match order_side {
406 OrderSide::Buy => {
407 if trigger_price > current {
409 HyperliquidExecTpSl::Sl
410 } else {
411 HyperliquidExecTpSl::Tp
412 }
413 }
414 OrderSide::Sell => {
415 if trigger_price < current {
417 HyperliquidExecTpSl::Sl
418 } else {
419 HyperliquidExecTpSl::Tp
420 }
421 }
422 _ => HyperliquidExecTpSl::Sl, }
424 } else {
425 HyperliquidExecTpSl::Sl
427 }
428 }
429 }
430}
431
432pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
438 let spec = bar_type.spec();
439 let step = spec.step.get();
440
441 anyhow::ensure!(
442 bar_type.aggregation_source() == AggregationSource::External,
443 "Only EXTERNAL aggregation is supported"
444 );
445
446 let interval = match spec.aggregation {
447 BarAggregation::Minute => match step {
448 1 => OneMinute,
449 3 => ThreeMinutes,
450 5 => FiveMinutes,
451 15 => FifteenMinutes,
452 30 => ThirtyMinutes,
453 _ => anyhow::bail!("Unsupported minute step: {step}"),
454 },
455 BarAggregation::Hour => match step {
456 1 => OneHour,
457 2 => TwoHours,
458 4 => FourHours,
459 8 => EightHours,
460 12 => TwelveHours,
461 _ => anyhow::bail!("Unsupported hour step: {step}"),
462 },
463 BarAggregation::Day => match step {
464 1 => OneDay,
465 3 => ThreeDays,
466 _ => anyhow::bail!("Unsupported day step: {step}"),
467 },
468 BarAggregation::Week if step == 1 => OneWeek,
469 BarAggregation::Month if step == 1 => OneMonth,
470 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
471 };
472
473 Ok(interval)
474}
475
476pub fn order_to_hyperliquid_request_with_asset(
483 order: &OrderAny,
484 asset: u32,
485 price_decimals: u8,
486 should_normalize_prices: bool,
487 slippage_bps: u32,
488) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
489 order_to_hyperliquid_request_with_asset_and_cloid(
490 order,
491 asset,
492 price_decimals,
493 should_normalize_prices,
494 slippage_bps,
495 Some(Cloid::from_client_order_id(order.client_order_id())),
496 )
497}
498
499pub fn order_to_hyperliquid_request_with_asset_and_cloid(
501 order: &OrderAny,
502 asset: u32,
503 price_decimals: u8,
504 should_normalize_prices: bool,
505 slippage_bps: u32,
506 cloid: Option<Cloid>,
507) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
508 let is_buy = matches!(order.order_side(), OrderSide::Buy);
509 let reduce_only = order.is_reduce_only();
510 let order_side = order.order_side();
511 let order_type = order.order_type();
512
513 let price_decimal = if let Some(price) = order.price() {
516 let raw = price.as_decimal();
517
518 if should_normalize_prices {
519 normalize_price(raw, price_decimals).normalize()
520 } else {
521 raw.normalize()
522 }
523 } else if matches!(order_type, OrderType::Market) {
524 Decimal::ZERO
525 } else if matches!(
526 order_type,
527 OrderType::StopMarket | OrderType::MarketIfTouched
528 ) {
529 match order.trigger_price() {
530 Some(tp) => {
531 let base = tp.as_decimal().normalize();
532 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
533 let sig_rounded = round_to_sig_figs(derived, 5);
534 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
535 }
536 None => Decimal::ZERO,
537 }
538 } else {
539 anyhow::bail!("Limit orders require a price")
540 };
541
542 let size_decimal = order.quantity().as_decimal().normalize();
543
544 let kind = match order_type {
546 OrderType::Market => HyperliquidExecOrderKind::Limit {
547 limit: HyperliquidExecLimitParams {
548 tif: HyperliquidExecTif::Ioc,
549 },
550 },
551 OrderType::Limit => {
552 let tif =
553 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
554 HyperliquidExecOrderKind::Limit {
555 limit: HyperliquidExecLimitParams { tif },
556 }
557 }
558 OrderType::StopMarket => {
559 if let Some(trigger_price) = order.trigger_price() {
560 let raw = trigger_price.as_decimal();
561 let trigger_price_decimal = if should_normalize_prices {
562 normalize_price(raw, price_decimals).normalize()
563 } else {
564 raw.normalize()
565 };
566 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
567 HyperliquidExecOrderKind::Trigger {
568 trigger: HyperliquidExecTriggerParams {
569 is_market: true,
570 trigger_px: trigger_price_decimal,
571 tpsl,
572 },
573 }
574 } else {
575 anyhow::bail!("Stop market orders require a trigger price")
576 }
577 }
578 OrderType::StopLimit => {
579 if let Some(trigger_price) = order.trigger_price() {
580 let raw = trigger_price.as_decimal();
581 let trigger_price_decimal = if should_normalize_prices {
582 normalize_price(raw, price_decimals).normalize()
583 } else {
584 raw.normalize()
585 };
586 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
587 HyperliquidExecOrderKind::Trigger {
588 trigger: HyperliquidExecTriggerParams {
589 is_market: false,
590 trigger_px: trigger_price_decimal,
591 tpsl,
592 },
593 }
594 } else {
595 anyhow::bail!("Stop limit orders require a trigger price")
596 }
597 }
598 OrderType::MarketIfTouched => {
599 if let Some(trigger_price) = order.trigger_price() {
600 let raw = trigger_price.as_decimal();
601 let trigger_price_decimal = if should_normalize_prices {
602 normalize_price(raw, price_decimals).normalize()
603 } else {
604 raw.normalize()
605 };
606 HyperliquidExecOrderKind::Trigger {
607 trigger: HyperliquidExecTriggerParams {
608 is_market: true,
609 trigger_px: trigger_price_decimal,
610 tpsl: HyperliquidExecTpSl::Tp,
611 },
612 }
613 } else {
614 anyhow::bail!("Market-if-touched orders require a trigger price")
615 }
616 }
617 OrderType::LimitIfTouched => {
618 if let Some(trigger_price) = order.trigger_price() {
619 let raw = trigger_price.as_decimal();
620 let trigger_price_decimal = if should_normalize_prices {
621 normalize_price(raw, price_decimals).normalize()
622 } else {
623 raw.normalize()
624 };
625 HyperliquidExecOrderKind::Trigger {
626 trigger: HyperliquidExecTriggerParams {
627 is_market: false,
628 trigger_px: trigger_price_decimal,
629 tpsl: HyperliquidExecTpSl::Tp,
630 },
631 }
632 } else {
633 anyhow::bail!("Limit-if-touched orders require a trigger price")
634 }
635 }
636 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
637 };
638
639 Ok(HyperliquidExecPlaceOrderRequest {
640 asset,
641 is_buy,
642 price: price_decimal,
643 size: size_decimal,
644 reduce_only,
645 kind,
646 cloid,
647 })
648}
649
650pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
652
653pub fn derive_market_order_price(
657 quote: &QuoteTick,
658 is_buy: bool,
659 price_decimals: u8,
660 slippage_bps: u32,
661) -> Decimal {
662 let base = if is_buy {
663 quote.ask_price.as_decimal()
664 } else {
665 quote.bid_price.as_decimal()
666 };
667 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
668 let sig_rounded = round_to_sig_figs(derived, 5);
669 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
670}
671
672pub fn derive_limit_from_trigger(
676 trigger_price: Decimal,
677 is_buy: bool,
678 slippage_bps: u32,
679) -> Decimal {
680 let slippage = Decimal::new(slippage_bps as i64, 4);
682 let price = if is_buy {
683 trigger_price * (Decimal::ONE + slippage)
684 } else {
685 trigger_price * (Decimal::ONE - slippage)
686 };
687
688 price.normalize()
690}
691
692pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
695 let scale = Decimal::from(10_u64.pow(decimals as u32));
696
697 if is_buy {
698 (price * scale).ceil() / scale
699 } else {
700 (price * scale).floor() / scale
701 }
702}
703
704pub fn client_order_id_to_cancel_request_with_asset(
706 client_order_id: &str,
707 asset: u32,
708) -> HyperliquidExecCancelByCloidRequest {
709 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
710 HyperliquidExecCancelByCloidRequest { asset, cloid }
711}
712
713pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
719 let HyperliquidExchangeResponse::Status { response, .. } = response else {
720 return None;
721 };
722 let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
723 match data {
724 HyperliquidExecResponseData::Order { data } => {
725 for status in &data.statuses {
726 if let HyperliquidExecOrderStatus::Error { error } = status {
727 return Some(error.clone());
728 }
729 }
730 None
731 }
732 HyperliquidExecResponseData::Cancel { data } => {
733 for status in &data.statuses {
734 if let HyperliquidExecCancelStatus::Error { error } = status {
735 return Some(error.clone());
736 }
737 }
738 None
739 }
740 HyperliquidExecResponseData::Modify { data } => {
741 for status in &data.statuses {
742 if let HyperliquidExecModifyStatus::Error { error } = status {
743 return Some(error.clone());
744 }
745 }
746 None
747 }
748 _ => None,
749 }
750}
751
752pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
758 let HyperliquidExchangeResponse::Status { response, .. } = response else {
759 return Vec::new();
760 };
761 let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
762 return Vec::new();
763 };
764
765 match data {
766 HyperliquidExecResponseData::Order { data } => data
767 .statuses
768 .into_iter()
769 .map(|s| match s {
770 HyperliquidExecOrderStatus::Error { error } => Some(error),
771 _ => None,
772 })
773 .collect(),
774 HyperliquidExecResponseData::Cancel { data } => data
775 .statuses
776 .into_iter()
777 .map(|s| match s {
778 HyperliquidExecCancelStatus::Error { error } => Some(error),
779 HyperliquidExecCancelStatus::Success(_) => None,
780 })
781 .collect(),
782 HyperliquidExecResponseData::Modify { data } => data
783 .statuses
784 .into_iter()
785 .map(|s| match s {
786 HyperliquidExecModifyStatus::Error { error } => Some(error),
787 HyperliquidExecModifyStatus::Success(_) => None,
788 })
789 .collect(),
790 _ => Vec::new(),
791 }
792}
793
794pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
796 match response {
797 HyperliquidExchangeResponse::Status { status, response } => {
798 if status == RESPONSE_STATUS_OK {
799 "Operation successful".to_string()
800 } else {
801 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
803 error_msg.to_string()
804 } else {
805 format!("Request failed with status: {status}")
806 }
807 }
808 }
809 HyperliquidExchangeResponse::Error { error } => error.clone(),
810 }
811}
812
813pub fn is_conditional_order_data(
819 trigger_px: Option<Decimal>,
820 tpsl: Option<&HyperliquidTpSl>,
821) -> bool {
822 trigger_px.is_some() && tpsl.is_some()
823}
824
825pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
831 match (is_market, tpsl) {
832 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
833 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
834 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
835 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
836 }
837}
838
839pub fn parse_order_status_with_trigger(
845 status: HyperliquidOrderStatus,
846 trigger_activated: Option<bool>,
847) -> (OrderStatus, Option<String>) {
848 let base_status = OrderStatus::from(status);
849
850 if let Some(activated) = trigger_activated {
852 let trigger_status = if activated {
853 Some("activated".to_string())
854 } else {
855 Some("pending".to_string())
856 };
857 (base_status, trigger_status)
858 } else {
859 (base_status, None)
860 }
861}
862
863pub fn format_trailing_stop_info(
865 offset: &str,
866 offset_type: TrailingOffsetType,
867 callback_price: Option<&str>,
868) -> String {
869 let offset_desc = offset_type.format_offset(offset);
870
871 if let Some(callback) = callback_price {
872 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
873 } else {
874 format!("Trailing stop: {offset_desc} offset")
875 }
876}
877
878pub fn validate_conditional_order_params(
884 trigger_px: Option<&str>,
885 tpsl: Option<&HyperliquidTpSl>,
886 is_market: Option<bool>,
887) -> anyhow::Result<()> {
888 if trigger_px.is_none() {
889 anyhow::bail!("Conditional order missing trigger price");
890 }
891
892 if tpsl.is_none() {
893 anyhow::bail!("Conditional order missing tpsl indicator");
894 }
895
896 if is_market.is_none() {
899 anyhow::bail!("Conditional order missing is_market flag");
900 }
901
902 Ok(())
903}
904
905pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
911 Decimal::from_str_exact(trigger_px)
912 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
913}
914
915pub fn parse_account_balances_and_margins(
926 state: &ClearinghouseState,
927) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
928 let mut balances = Vec::new();
929 let mut margins = Vec::new();
930
931 let currency = Currency::USDC();
932
933 let cross_margin_summary = match &state.cross_margin_summary {
934 Some(summary) => summary,
935 None => return Ok((balances, margins)),
936 };
937
938 let mut total_value = cross_margin_summary.total_raw_usd;
939 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
940
941 if total_value >= Decimal::ZERO && free_value > total_value {
944 total_value = free_value;
945 }
946
947 balances.push(AccountBalance::from_total_and_free(
948 total_value,
949 free_value,
950 currency,
951 )?);
952
953 let margin_used = cross_margin_summary.total_margin_used;
954
955 if margin_used > Decimal::ZERO {
956 let initial_margin = Money::from_decimal(margin_used, currency)?;
959 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
960 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
961 }
962
963 Ok((balances, margins))
964}
965
966pub fn parse_combined_account_balances_and_margins(
977 perp_state: &ClearinghouseState,
978 spot_state: &SpotClearinghouseState,
979) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
980 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
981
982 let perp_reflects_usdc = perp_state
983 .cross_margin_summary
984 .as_ref()
985 .is_some_and(|summary| {
986 summary.total_raw_usd != Decimal::ZERO
987 || summary.total_margin_used > Decimal::ZERO
988 || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
989 });
990
991 if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
992 balances.retain(|balance| balance.currency.code.as_str() != "USDC");
993 }
994
995 let spot_balances = parse_spot_account_balances(spot_state)?;
996
997 for balance in spot_balances {
998 let is_usdc = balance.currency.code.as_str() == "USDC";
999 if perp_reflects_usdc && is_usdc {
1000 continue;
1001 }
1002 balances.push(balance);
1003 }
1004
1005 Ok((balances, margins))
1006}
1007
1008pub fn parse_spot_account_balances(
1018 state: &SpotClearinghouseState,
1019) -> anyhow::Result<Vec<AccountBalance>> {
1020 let mut balances = Vec::with_capacity(state.balances.len());
1021
1022 for balance in &state.balances {
1023 if balance.total.is_zero() {
1024 continue;
1025 }
1026
1027 let currency = crate::http::parse::get_currency(balance.coin.as_str());
1028
1029 balances.push(AccountBalance::from_total_and_locked(
1033 balance.total,
1034 balance.hold,
1035 currency,
1036 )?);
1037 }
1038
1039 Ok(balances)
1040}
1041
1042pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExecGrouping {
1054 if orders.len() >= 2 {
1055 let entry = &orders[0];
1056 let children = &orders[1..];
1057 let entry_id = entry.client_order_id();
1058 let entry_is_oto =
1059 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1060 let children_are_linked = children.iter().all(|o| {
1061 matches!(
1062 o.contingency_type(),
1063 Some(ContingencyType::Oco | ContingencyType::Ouo)
1064 ) && o.is_reduce_only()
1065 && o.parent_order_id() == Some(entry_id)
1066 });
1067
1068 if entry_is_oto && children_are_linked {
1069 return HyperliquidExecGrouping::NormalTpsl;
1070 }
1071 }
1072
1073 let all_oco_linked = orders.len() >= 2
1074 && orders.iter().all(|o| {
1075 matches!(
1076 o.contingency_type(),
1077 Some(ContingencyType::Oco | ContingencyType::Ouo)
1078 ) && o.is_reduce_only()
1079 })
1080 && orders.iter().all(|o| {
1081 o.linked_order_ids().is_some_and(|ids| {
1082 ids.iter()
1083 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1084 })
1085 });
1086
1087 if all_oco_linked {
1088 HyperliquidExecGrouping::PositionTpsl
1089 } else {
1090 HyperliquidExecGrouping::Na
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use std::str::FromStr;
1097
1098 use nautilus_model::{
1099 enums::{OrderSide, TimeInForce, TriggerType},
1100 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1101 orders::{OrderAny, StopMarketOrder},
1102 types::{Price, Quantity},
1103 };
1104 use rstest::rstest;
1105 use rust_decimal::Decimal;
1106 use rust_decimal_macros::dec;
1107 use serde::{Deserialize, Serialize};
1108
1109 use super::*;
1110
1111 #[rstest]
1112 fn test_make_fill_trade_id_is_stable() {
1113 let id = make_fill_trade_id(
1116 "0xabc123",
1117 12345,
1118 dec!(50000.0),
1119 dec!(0.1),
1120 1704470400000,
1121 dec!(0.0),
1122 );
1123 assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1124 }
1125
1126 #[derive(Serialize, Deserialize)]
1127 struct TestStruct {
1128 #[serde(
1129 serialize_with = "serialize_decimal_as_str",
1130 deserialize_with = "deserialize_decimal_from_str"
1131 )]
1132 value: Decimal,
1133 #[serde(
1134 serialize_with = "serialize_optional_decimal_as_str",
1135 deserialize_with = "deserialize_optional_decimal_from_str"
1136 )]
1137 optional_value: Option<Decimal>,
1138 }
1139
1140 #[rstest]
1141 #[case("#10", 100_000_010, 1, 0)]
1142 #[case("+10", 100_000_010, 1, 0)]
1143 #[case("#31", 100_000_031, 3, 1)]
1144 #[case("+31", 100_000_031, 3, 1)]
1145 fn test_parse_outcome_symbol(
1146 #[case] symbol: &str,
1147 #[case] raw_asset_id: u32,
1148 #[case] outcome: u32,
1149 #[case] side: u8,
1150 ) {
1151 let asset_id = parse_outcome_symbol(symbol).unwrap();
1152 assert_eq!(asset_id.to_raw(), raw_asset_id);
1153 assert_eq!(asset_id.outcome_index(), Some(outcome));
1154 assert_eq!(asset_id.outcome_side(), Some(side));
1155 }
1156
1157 #[rstest]
1158 #[case("25-YES-OUTCOME", 25, 0)]
1159 #[case("25-NO-OUTCOME", 25, 1)]
1160 #[case("0-YES-OUTCOME", 0, 0)]
1161 #[case("999-NO-OUTCOME", 999, 1)]
1162 fn test_parse_outcome_nautilus_symbol(
1163 #[case] symbol: &str,
1164 #[case] outcome_index: u32,
1165 #[case] side: u8,
1166 ) {
1167 let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1168 assert_eq!(parsed, (outcome_index, side));
1169 }
1170
1171 #[rstest]
1172 #[case("25-OUTCOME")]
1173 #[case("25-MAYBE-OUTCOME")]
1174 #[case("25-yes-OUTCOME")]
1175 #[case("-YES-OUTCOME")]
1176 #[case("YES-25-OUTCOME")]
1177 #[case("25-YES-outcome")]
1178 #[case("25-YES")]
1179 fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1180 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1181 }
1182
1183 #[rstest]
1184 #[case("999999999-YES-OUTCOME")]
1186 #[case("429496729-YES-OUTCOME")]
1188 #[case("4294967295-NO-OUTCOME")]
1190 fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1191 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1192 }
1193
1194 #[rstest]
1195 #[case(25, 0, "25-YES-OUTCOME")]
1196 #[case(25, 1, "25-NO-OUTCOME")]
1197 #[case(0, 0, "0-YES-OUTCOME")]
1198 fn test_format_outcome_nautilus_symbol(
1199 #[case] outcome_index: u32,
1200 #[case] side: u8,
1201 #[case] expected: &str,
1202 ) {
1203 assert_eq!(
1204 format_outcome_nautilus_symbol(outcome_index, side),
1205 expected,
1206 );
1207 }
1208
1209 #[rstest]
1210 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1211 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1212 #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1213 #[case("BTC-USD-PERP", None)]
1214 #[case("+250", None)]
1215 fn test_outcome_token_from_nautilus_symbol(
1216 #[case] symbol: &str,
1217 #[case] expected: Option<String>,
1218 ) {
1219 assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1220 }
1221
1222 #[rstest]
1223 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1224 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1225 #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1226 #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1227 #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1228 #[case("+250", Some("+250".to_string()))]
1229 #[case("#250", Some("#250".to_string()))]
1230 #[case("", None)]
1231 fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1232 assert_eq!(cache_alias_for_symbol(symbol), expected);
1233 }
1234
1235 #[rstest]
1236 #[case("10", "expected #<encoding> or +<encoding>")]
1237 #[case("#", "encoding must not be empty")]
1238 #[case("#1a", "encoding must be numeric")]
1239 #[case("#12", "side digit 0 or 1")]
1240 #[case("#4294967295", "fit u32")]
1241 fn test_parse_outcome_symbol_rejects_invalid_values(
1242 #[case] symbol: &str,
1243 #[case] expected_error: &str,
1244 ) {
1245 let err = parse_outcome_symbol(symbol).unwrap_err();
1246 assert!(
1247 err.to_string().contains(expected_error),
1248 "expected error to contain '{expected_error}', received '{err}'",
1249 );
1250 }
1251
1252 #[rstest]
1253 fn test_decimal_serialization_roundtrip() {
1254 let original = TestStruct {
1255 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1256 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1257 };
1258
1259 let json = serde_json::to_string(&original).unwrap();
1260 println!("Serialized: {json}");
1261
1262 assert!(json.contains("\"123.45678901234567890123456789\""));
1264 assert!(json.contains("\"0.000000001\""));
1265
1266 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1267 assert_eq!(original.value, deserialized.value);
1268 assert_eq!(original.optional_value, deserialized.optional_value);
1269 }
1270
1271 #[rstest]
1272 fn test_decimal_precision_preservation() {
1273 let test_cases = [
1274 "0",
1275 "1",
1276 "0.1",
1277 "0.01",
1278 "0.001",
1279 "123.456789012345678901234567890",
1280 "999999999999999999.999999999999999999",
1281 ];
1282
1283 for case in test_cases {
1284 let decimal = Decimal::from_str(case).unwrap();
1285 let test_struct = TestStruct {
1286 value: decimal,
1287 optional_value: Some(decimal),
1288 };
1289
1290 let json = serde_json::to_string(&test_struct).unwrap();
1291 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1292
1293 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1294 assert_eq!(
1295 Some(decimal),
1296 parsed.optional_value,
1297 "Failed for case: {case}"
1298 );
1299 }
1300 }
1301
1302 #[rstest]
1303 fn test_optional_none_handling() {
1304 let test_struct = TestStruct {
1305 value: Decimal::from_str("42.0").unwrap(),
1306 optional_value: None,
1307 };
1308
1309 let json = serde_json::to_string(&test_struct).unwrap();
1310 assert!(json.contains("null"));
1311
1312 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1313 assert_eq!(test_struct.value, parsed.value);
1314 assert_eq!(None, parsed.optional_value);
1315 }
1316
1317 #[rstest]
1318 fn test_round_down_to_tick() {
1319 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1320 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1321 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1322
1323 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1325 }
1326
1327 #[rstest]
1328 fn test_round_down_to_step() {
1329 assert_eq!(
1330 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1331 dec!(0.1234)
1332 );
1333 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1334 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1335
1336 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1338 }
1339
1340 #[rstest]
1341 fn test_min_notional_validation() {
1342 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1344 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1345
1346 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1348 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1349
1350 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1352 }
1353
1354 #[rstest]
1355 fn test_round_to_sig_figs() {
1356 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1358 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1359 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1360
1361 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1363 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1364 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1365
1366 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1368 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1372
1373 assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1374 assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1375 assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1376 assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1377 }
1378
1379 #[rstest]
1380 fn test_normalize_price() {
1381 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1383 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));
1389 }
1390
1391 #[rstest]
1392 fn test_normalize_quantity() {
1393 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1394 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1395 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1396 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1397 }
1398
1399 #[rstest]
1400 fn test_normalize_order_complete() {
1401 let result = normalize_order(
1402 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1410
1411 assert!(result.is_ok());
1412 let (price, qty) = result.unwrap();
1413 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1416
1417 #[rstest]
1418 fn test_normalize_order_min_notional_fail() {
1419 let result = normalize_order(
1420 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1428
1429 assert!(result.is_err());
1430 assert!(result.unwrap_err().contains("Notional value"));
1431 }
1432
1433 #[rstest]
1434 fn test_edge_cases() {
1435 assert_eq!(
1437 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1438 dec!(0.000001)
1439 );
1440
1441 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1443
1444 assert_eq!(
1446 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1447 dec!(100.00)
1448 );
1449 }
1450
1451 #[rstest]
1452 fn test_is_conditional_order_data() {
1453 assert!(is_conditional_order_data(
1455 Some(dec!(50000.0)),
1456 Some(&HyperliquidTpSl::Sl)
1457 ));
1458
1459 assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1461
1462 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1464
1465 assert!(!is_conditional_order_data(None, None));
1467 }
1468
1469 #[rstest]
1470 fn test_parse_trigger_order_type() {
1471 assert_eq!(
1473 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1474 OrderType::StopMarket
1475 );
1476
1477 assert_eq!(
1479 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1480 OrderType::StopLimit
1481 );
1482
1483 assert_eq!(
1485 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1486 OrderType::MarketIfTouched
1487 );
1488
1489 assert_eq!(
1491 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1492 OrderType::LimitIfTouched
1493 );
1494 }
1495
1496 #[rstest]
1497 fn test_parse_order_status_with_trigger() {
1498 let (status, trigger_status) =
1500 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1501 assert_eq!(status, OrderStatus::Accepted);
1502 assert_eq!(trigger_status, Some("activated".to_string()));
1503
1504 let (status, trigger_status) =
1506 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1507 assert_eq!(status, OrderStatus::Accepted);
1508 assert_eq!(trigger_status, Some("pending".to_string()));
1509
1510 let (status, trigger_status) =
1512 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1513 assert_eq!(status, OrderStatus::Accepted);
1514 assert_eq!(trigger_status, None);
1515 }
1516
1517 #[rstest]
1518 fn test_format_trailing_stop_info() {
1519 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1521 assert!(info.contains("100.0"));
1522 assert!(info.contains("callback at 50000.0"));
1523
1524 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1526 assert!(info.contains("5.0%"));
1527 assert!(info.contains("Trailing stop"));
1528
1529 let info =
1531 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1532 assert!(info.contains("250 bps"));
1533 assert!(info.contains("49000.0"));
1534 }
1535
1536 #[rstest]
1537 fn test_parse_trigger_price() {
1538 let result = parse_trigger_price("50000.0");
1540 assert!(result.is_ok());
1541 assert_eq!(result.unwrap(), dec!(50000.0));
1542
1543 let result = parse_trigger_price("49000");
1545 assert!(result.is_ok());
1546 assert_eq!(result.unwrap(), dec!(49000));
1547
1548 let result = parse_trigger_price("invalid");
1550 assert!(result.is_err());
1551
1552 let result = parse_trigger_price("");
1554 assert!(result.is_err());
1555 }
1556
1557 #[rstest]
1558 #[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(
1569 #[case] trigger_price: Decimal,
1570 #[case] is_buy: bool,
1571 #[case] expected: Decimal,
1572 ) {
1573 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1574 assert_eq!(result, expected);
1575
1576 if is_buy {
1578 assert!(result >= trigger_price);
1579 } else {
1580 assert!(result <= trigger_price);
1581 }
1582 }
1583
1584 #[rstest]
1585 #[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))]
1595 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1596 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1598 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1599 fn test_clamp_price_to_precision(
1600 #[case] price: Decimal,
1601 #[case] decimals: u8,
1602 #[case] is_buy: bool,
1603 #[case] expected: Decimal,
1604 ) {
1605 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1606 }
1607
1608 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1609 OrderAny::StopMarket(StopMarketOrder::new(
1610 TraderId::from("TESTER-001"),
1611 StrategyId::from("S-001"),
1612 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1613 ClientOrderId::from("O-001"),
1614 side,
1615 Quantity::from(1),
1616 Price::from(trigger_price),
1617 TriggerType::LastPrice,
1618 TimeInForce::Gtc,
1619 None,
1620 false,
1621 false,
1622 None,
1623 None,
1624 None,
1625 None,
1626 None,
1627 None,
1628 None,
1629 None,
1630 None,
1631 None,
1632 None,
1633 Default::default(),
1634 Default::default(),
1635 ))
1636 }
1637
1638 #[rstest]
1639 #[case(OrderSide::Sell, "2470.00", 2)]
1641 #[case(OrderSide::Buy, "2470.00", 2)]
1642 #[case(OrderSide::Sell, "104567.3", 1)]
1644 #[case(OrderSide::Buy, "104567.3", 1)]
1645 #[case(OrderSide::Sell, "0.50", 4)]
1647 #[case(OrderSide::Buy, "0.50", 4)]
1648 #[case(OrderSide::Sell, "2470.00", 1)]
1652 #[case(OrderSide::Buy, "2470.00", 1)]
1653 #[case(OrderSide::Sell, "2470.00", 0)]
1657 #[case(OrderSide::Buy, "2470.00", 0)]
1658 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1659 #[case] side: OrderSide,
1660 #[case] trigger_str: &str,
1661 #[case] price_decimals: u8,
1662 ) {
1663 let order = stop_market_order(side, trigger_str);
1664 let request = order_to_hyperliquid_request_with_asset(
1665 &order,
1666 0,
1667 price_decimals,
1668 true,
1669 DEFAULT_MARKET_SLIPPAGE_BPS,
1670 )
1671 .unwrap();
1672 let trigger = Decimal::from_str(trigger_str).unwrap();
1673 let is_buy = matches!(side, OrderSide::Buy);
1674
1675 if is_buy {
1677 assert!(
1678 request.price >= trigger,
1679 "BUY limit {} must be >= trigger {trigger}",
1680 request.price,
1681 );
1682 assert!(request.is_buy);
1683 } else {
1684 assert!(
1685 request.price <= trigger,
1686 "SELL limit {} must be <= trigger {trigger}",
1687 request.price,
1688 );
1689 assert!(!request.is_buy);
1690 }
1691
1692 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1694 let sig_rounded = round_to_sig_figs(derived, 5);
1695 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1696 assert_eq!(request.price, expected);
1697
1698 let price_str = request.price.to_string();
1700 let actual_decimals = price_str
1701 .find('.')
1702 .map_or(0, |dot| price_str.len() - dot - 1);
1703 assert!(
1704 actual_decimals <= price_decimals as usize,
1705 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1706 );
1707
1708 if price_str.contains('.') {
1710 assert!(
1711 !price_str.ends_with('0'),
1712 "Price {price_str} has decimal trailing zeros",
1713 );
1714 }
1715
1716 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1717 assert_eq!(
1718 request.kind,
1719 HyperliquidExecOrderKind::Trigger {
1720 trigger: HyperliquidExecTriggerParams {
1721 is_market: true,
1722 trigger_px: expected_trigger,
1723 tpsl: HyperliquidExecTpSl::Sl,
1724 },
1725 },
1726 );
1727 }
1728
1729 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1730 HyperliquidExchangeResponse::Status {
1731 status: "ok".to_string(),
1732 response: inner,
1733 }
1734 }
1735
1736 #[rstest]
1737 fn test_extract_inner_error_order_with_error() {
1738 let response = ok_response(serde_json::json!({
1739 "type": "order",
1740 "data": {"statuses": [{"error": "Order has invalid price."}]}
1741 }));
1742 assert_eq!(
1743 extract_inner_error(&response),
1744 Some("Order has invalid price.".to_string()),
1745 );
1746 }
1747
1748 #[rstest]
1749 fn test_extract_inner_error_order_resting() {
1750 let response = ok_response(serde_json::json!({
1751 "type": "order",
1752 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1753 }));
1754 assert_eq!(extract_inner_error(&response), None);
1755 }
1756
1757 #[rstest]
1758 fn test_extract_inner_error_order_filled() {
1759 let response = ok_response(serde_json::json!({
1760 "type": "order",
1761 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1762 }));
1763 assert_eq!(extract_inner_error(&response), None);
1764 }
1765
1766 #[rstest]
1767 fn test_extract_inner_error_cancel_error() {
1768 let response = ok_response(serde_json::json!({
1769 "type": "cancel",
1770 "data": {"statuses": [{"error": "Order not found"}]}
1771 }));
1772 assert_eq!(
1773 extract_inner_error(&response),
1774 Some("Order not found".to_string()),
1775 );
1776 }
1777
1778 #[rstest]
1779 fn test_extract_inner_error_cancel_success() {
1780 let response = ok_response(serde_json::json!({
1781 "type": "cancel",
1782 "data": {"statuses": ["success"]}
1783 }));
1784 assert_eq!(extract_inner_error(&response), None);
1785 }
1786
1787 #[rstest]
1788 fn test_extract_inner_error_modify_error() {
1789 let response = ok_response(serde_json::json!({
1790 "type": "modify",
1791 "data": {"statuses": [{"error": "Invalid modify"}]}
1792 }));
1793 assert_eq!(
1794 extract_inner_error(&response),
1795 Some("Invalid modify".to_string()),
1796 );
1797 }
1798
1799 #[rstest]
1800 fn test_extract_inner_error_modify_success() {
1801 let response = ok_response(serde_json::json!({
1802 "type": "modify",
1803 "data": {"statuses": ["success"]}
1804 }));
1805 assert_eq!(extract_inner_error(&response), None);
1806 }
1807
1808 #[rstest]
1809 fn test_extract_inner_error_non_status_response() {
1810 let response = HyperliquidExchangeResponse::Error {
1811 error: "top-level error".to_string(),
1812 };
1813 assert_eq!(extract_inner_error(&response), None);
1814 }
1815
1816 #[rstest]
1817 fn test_extract_inner_error_unparsable_response() {
1818 let response = ok_response(serde_json::json!({"unknown": "data"}));
1819 assert_eq!(extract_inner_error(&response), None);
1820 }
1821
1822 #[rstest]
1823 fn test_extract_inner_error_returns_first_error_in_batch() {
1824 let response = ok_response(serde_json::json!({
1825 "type": "order",
1826 "data": {"statuses": [
1827 {"resting": {"oid": 1}},
1828 {"error": "Second failed"},
1829 {"error": "Third failed"},
1830 ]}
1831 }));
1832 assert_eq!(
1833 extract_inner_error(&response),
1834 Some("Second failed".to_string()),
1835 );
1836 }
1837
1838 #[rstest]
1839 fn test_extract_inner_errors_mixed_batch() {
1840 let response = ok_response(serde_json::json!({
1841 "type": "order",
1842 "data": {"statuses": [
1843 {"resting": {"oid": 1}},
1844 {"error": "Failed order"},
1845 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1846 ]}
1847 }));
1848 let errors = extract_inner_errors(&response);
1849 assert_eq!(errors.len(), 3);
1850 assert_eq!(errors[0], None);
1851 assert_eq!(errors[1], Some("Failed order".to_string()));
1852 assert_eq!(errors[2], None);
1853 }
1854
1855 #[rstest]
1856 fn test_extract_inner_errors_all_success() {
1857 let response = ok_response(serde_json::json!({
1858 "type": "order",
1859 "data": {"statuses": [
1860 {"resting": {"oid": 1}},
1861 {"resting": {"oid": 2}},
1862 ]}
1863 }));
1864 let errors = extract_inner_errors(&response);
1865 assert_eq!(errors.len(), 2);
1866 assert!(errors.iter().all(|e| e.is_none()));
1867 }
1868
1869 #[rstest]
1870 fn test_extract_inner_errors_cancel_success() {
1871 let response = ok_response(serde_json::json!({
1872 "type": "cancel",
1873 "data": {"statuses": ["success"]}
1874 }));
1875 let errors = extract_inner_errors(&response);
1876 assert_eq!(errors.len(), 1);
1877 assert!(errors[0].is_none());
1878 }
1879
1880 #[rstest]
1881 fn test_extract_inner_errors_cancel_mixed() {
1882 let response = ok_response(serde_json::json!({
1883 "type": "cancel",
1884 "data": {"statuses": [
1885 "success",
1886 {"error": "Order was never placed, already canceled, or filled."},
1887 "success",
1888 ]}
1889 }));
1890 let errors = extract_inner_errors(&response);
1891 assert_eq!(errors.len(), 3);
1892 assert_eq!(errors[0], None);
1893 assert_eq!(
1894 errors[1],
1895 Some("Order was never placed, already canceled, or filled.".to_string())
1896 );
1897 assert_eq!(errors[2], None);
1898 }
1899
1900 #[rstest]
1901 fn test_extract_inner_errors_modify_mixed() {
1902 let response = ok_response(serde_json::json!({
1903 "type": "modify",
1904 "data": {"statuses": [
1905 "success",
1906 {"error": "Order does not exist"},
1907 ]}
1908 }));
1909 let errors = extract_inner_errors(&response);
1910 assert_eq!(errors.len(), 2);
1911 assert_eq!(errors[0], None);
1912 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1913 }
1914
1915 #[rstest]
1916 fn test_extract_inner_errors_unparsable() {
1917 let response = ok_response(serde_json::json!({"foo": "bar"}));
1918 let errors = extract_inner_errors(&response);
1919 assert!(errors.is_empty());
1920 }
1921
1922 fn count_sig_figs(s: &str) -> usize {
1923 let s = s.trim_start_matches('-');
1924 if s.contains('.') {
1925 let digits: String = s.replace('.', "");
1927 digits.trim_start_matches('0').len()
1928 } else {
1929 let s = s.trim_start_matches('0');
1931 s.trim_end_matches('0').len()
1932 }
1933 }
1934
1935 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1936 QuoteTick::new(
1937 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1938 Price::from(bid),
1939 Price::from(ask),
1940 Quantity::from("1"),
1941 Quantity::from("1"),
1942 Default::default(),
1943 Default::default(),
1944 )
1945 }
1946
1947 #[rstest]
1948 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1954 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1956 #[case("104500.0", "104567.3", true, 1, "105090")]
1960 #[case("104500.0", "104567.3", false, 1, "103980")]
1962 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1966 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1968 #[case("49900", "50000", true, 0, "50250")]
1972 #[case("49900", "50000", false, 0, "49650")]
1974 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1978 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1980 fn test_derive_market_order_price(
1981 #[case] bid: &str,
1982 #[case] ask: &str,
1983 #[case] is_buy: bool,
1984 #[case] price_decimals: u8,
1985 #[case] expected: &str,
1986 ) {
1987 let quote = make_quote(bid, ask);
1988 let result =
1989 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1990 let expected_dec = Decimal::from_str(expected).unwrap();
1991 assert_eq!(result, expected_dec);
1992
1993 let base = if is_buy {
1995 quote.ask_price.as_decimal()
1996 } else {
1997 quote.bid_price.as_decimal()
1998 };
1999 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2000 let sig_rounded = round_to_sig_figs(derived, 5);
2001 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2002 assert_eq!(result, pipeline);
2003
2004 let s = result.to_string();
2006 if s.contains('.') {
2007 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2008 }
2009
2010 let sig_count = count_sig_figs(&s);
2012 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2013
2014 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2016 assert!(
2017 actual_decimals <= price_decimals as usize,
2018 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2019 );
2020 }
2021
2022 #[rstest]
2023 #[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(
2031 #[case] slippage_bps: u32,
2032 #[case] trigger: Decimal,
2033 #[case] is_buy: bool,
2034 #[case] expected: Decimal,
2035 ) {
2036 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2037 assert_eq!(result, expected);
2038 }
2039
2040 #[rstest]
2041 fn test_derive_market_order_price_respects_slippage_override() {
2042 let quote = make_quote("100.00", "100.10");
2043 let tight = derive_market_order_price("e, true, 2, 50);
2044 let wide = derive_market_order_price("e, true, 2, 800);
2045 assert_eq!(tight, dec!(100.6));
2046 assert_eq!(wide, dec!(108.11));
2047 assert!(wide > tight);
2048 }
2049
2050 #[rstest]
2054 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2055 let json = r#"{
2056 "assetPositions": [],
2057 "crossMarginSummary": {
2058 "accountValue": "150",
2059 "totalNtlPos": "0",
2060 "totalRawUsd": "100",
2061 "totalMarginUsed": "20",
2062 "withdrawable": "120"
2063 },
2064 "withdrawable": "80",
2065 "time": 1700000000000
2066 }"#;
2067
2068 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2069 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2070
2071 assert_eq!(balances.len(), 1);
2072 let balance = &balances[0];
2073 assert_eq!(balance.total.as_decimal(), dec!(100));
2076 assert_eq!(balance.free.as_decimal(), dec!(80));
2077 assert_eq!(balance.locked.as_decimal(), dec!(20));
2078
2079 assert_eq!(margins.len(), 1);
2080 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2081 }
2082
2083 #[rstest]
2084 fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2085 let json =
2086 include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2087
2088 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2089 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2090
2091 assert_eq!(balances.len(), 1);
2092 let balance = &balances[0];
2093 assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2094 assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2095 assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2096
2097 assert_eq!(margins.len(), 1);
2098 assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2099 }
2100
2101 #[rstest]
2102 fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2103 let json = r#"{
2104 "assetPositions": [],
2105 "crossMarginSummary": {
2106 "accountValue": "100",
2107 "totalNtlPos": "0",
2108 "totalRawUsd": "100",
2109 "totalMarginUsed": "0",
2110 "withdrawable": "100"
2111 },
2112 "withdrawable": "150",
2113 "time": 1700000000000
2114 }"#;
2115
2116 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2117 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2118
2119 assert_eq!(balances.len(), 1);
2120 let balance = &balances[0];
2121 assert_eq!(balance.total.as_decimal(), dec!(150));
2122 assert_eq!(balance.free.as_decimal(), dec!(150));
2123 assert_eq!(balance.locked.as_decimal(), dec!(0));
2124 }
2125
2126 #[rstest]
2127 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2128 let json = r#"{
2129 "assetPositions": [],
2130 "withdrawable": "100",
2131 "time": 1700000000000
2132 }"#;
2133
2134 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2135 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2136 assert!(balances.is_empty());
2137 assert!(margins.is_empty());
2138 }
2139
2140 #[rstest]
2141 fn test_parse_spot_account_balances_emits_one_per_token() {
2142 let json = r#"{
2143 "balances": [
2144 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2145 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2146 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2147 ]
2148 }"#;
2149
2150 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2151 let balances = parse_spot_account_balances(&state).unwrap();
2152
2153 assert_eq!(balances.len(), 2);
2154
2155 let usdc = &balances[0];
2156 assert_eq!(usdc.currency.code.as_str(), "USDC");
2157 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2158 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2159 assert_eq!(usdc.locked.as_decimal(), dec!(10));
2160
2161 let purr = &balances[1];
2162 assert_eq!(purr.currency.code.as_str(), "PURR");
2163 assert_eq!(purr.total.as_decimal(), dec!(50));
2164 assert_eq!(purr.free.as_decimal(), dec!(50));
2165 }
2166
2167 #[rstest]
2168 fn test_parse_spot_account_balances_clamps_hold_to_total() {
2169 let json = r#"{
2170 "balances": [
2171 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2172 ]
2173 }"#;
2174
2175 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2176 let balances = parse_spot_account_balances(&state).unwrap();
2177
2178 assert_eq!(balances.len(), 1);
2179 let hype = &balances[0];
2180 assert_eq!(hype.total.as_decimal(), dec!(5));
2181 assert_eq!(hype.free.as_decimal(), dec!(0));
2182 assert_eq!(hype.locked.as_decimal(), dec!(5));
2183 }
2184
2185 #[rstest]
2186 fn test_parse_spot_account_balances_empty() {
2187 let state = SpotClearinghouseState::default();
2188 let balances = parse_spot_account_balances(&state).unwrap();
2189 assert!(balances.is_empty());
2190 }
2191
2192 #[rstest]
2193 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2194 let perp_json = r#"{
2195 "assetPositions": [],
2196 "crossMarginSummary": {
2197 "accountValue": "500",
2198 "totalNtlPos": "0",
2199 "totalRawUsd": "500",
2200 "totalMarginUsed": "0",
2201 "withdrawable": "500"
2202 },
2203 "withdrawable": "500"
2204 }"#;
2205 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2206
2207 let spot_json = r#"{
2208 "balances": [
2209 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2210 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2211 ]
2212 }"#;
2213 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2214
2215 let (balances, margins) =
2216 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2217
2218 assert!(margins.is_empty());
2219 assert_eq!(balances.len(), 2);
2220 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2221 assert_eq!(balances[0].total.as_decimal(), dec!(500));
2222 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2223 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2224 }
2225
2226 #[rstest]
2227 fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2228 let perp_json = r#"{
2229 "assetPositions": [],
2230 "crossMarginSummary": {
2231 "accountValue": "0",
2232 "totalNtlPos": "0",
2233 "totalRawUsd": "0",
2234 "totalMarginUsed": "0",
2235 "withdrawable": "0"
2236 },
2237 "withdrawable": "0"
2238 }"#;
2239 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2240
2241 let spot_json = r#"{
2242 "balances": [
2243 {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2244 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2245 ]
2246 }"#;
2247 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2248
2249 let (balances, margins) =
2250 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2251
2252 assert!(margins.is_empty());
2253 assert_eq!(balances.len(), 2);
2254 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2255 assert_eq!(balances[0].total.as_decimal(), dec!(75));
2256 assert_eq!(balances[0].free.as_decimal(), dec!(70));
2257 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2258 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2259 }
2260
2261 #[rstest]
2262 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2263 let perp_json = r#"{
2264 "assetPositions": [],
2265 "crossMarginSummary": {
2266 "accountValue": "50",
2267 "totalNtlPos": "0",
2268 "totalRawUsd": "50",
2269 "totalMarginUsed": "0",
2270 "withdrawable": "0"
2271 },
2272 "withdrawable": "0"
2273 }"#;
2274 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2275
2276 let spot_json = r#"{
2277 "balances": [
2278 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2279 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2280 ]
2281 }"#;
2282 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2283
2284 let (balances, margins) =
2285 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2286
2287 assert!(margins.is_empty());
2288 assert_eq!(balances.len(), 2);
2289 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2290 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2291 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2292 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2293 }
2294
2295 #[rstest]
2296 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2297 let perp_json = r#"{
2298 "assetPositions": [],
2299 "crossMarginSummary": {
2300 "accountValue": "-50",
2301 "totalNtlPos": "0",
2302 "totalRawUsd": "-50",
2303 "totalMarginUsed": "0",
2304 "withdrawable": "0"
2305 },
2306 "withdrawable": "0"
2307 }"#;
2308 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2309
2310 let spot_json = r#"{
2311 "balances": [
2312 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2313 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2314 ]
2315 }"#;
2316 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2317
2318 let (balances, margins) =
2319 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2320
2321 assert!(margins.is_empty());
2322 assert_eq!(balances.len(), 2);
2323 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2324 assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2325 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2326 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2327 }
2328
2329 #[rstest]
2330 fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2331 let perp_json = r#"{
2332 "assetPositions": [],
2333 "crossMarginSummary": {
2334 "accountValue": "0",
2335 "totalNtlPos": "0",
2336 "totalRawUsd": "0",
2337 "totalMarginUsed": "25",
2338 "withdrawable": "0"
2339 },
2340 "withdrawable": "0"
2341 }"#;
2342 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2343
2344 let spot_json = r#"{
2345 "balances": [
2346 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2347 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2348 ]
2349 }"#;
2350 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2351
2352 let (balances, margins) =
2353 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2354
2355 assert_eq!(margins.len(), 1);
2356 assert_eq!(balances.len(), 2);
2357 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2358 assert_eq!(balances[0].total.as_decimal(), dec!(0));
2359 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2360 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2361 }
2362
2363 #[rstest]
2364 fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2365 let perp_json = r#"{
2366 "assetPositions": [],
2367 "crossMarginSummary": {
2368 "accountValue": "0",
2369 "totalNtlPos": "0",
2370 "totalRawUsd": "0",
2371 "totalMarginUsed": "0",
2372 "withdrawable": "50"
2373 },
2374 "withdrawable": "50"
2375 }"#;
2376 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2377
2378 let spot_json = r#"{
2379 "balances": [
2380 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2381 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2382 ]
2383 }"#;
2384 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2385
2386 let (balances, margins) =
2387 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2388
2389 assert!(margins.is_empty());
2390 assert_eq!(balances.len(), 2);
2391 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2392 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2393 assert_eq!(balances[0].free.as_decimal(), dec!(50));
2394 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2395 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2396 }
2397
2398 #[rstest]
2399 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2400 let perp_json = r#"{"assetPositions": []}"#;
2401 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2402
2403 let spot_json = r#"{
2404 "balances": [
2405 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2406 ]
2407 }"#;
2408 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2409
2410 let (balances, _) =
2411 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2412
2413 assert_eq!(balances.len(), 1);
2414 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2415 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2416 }
2417}