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 o.contingency_type() == Some(ContingencyType::Oco)
1062 && o.is_reduce_only()
1063 && o.parent_order_id() == Some(entry_id)
1064 });
1065
1066 if entry_is_oto && children_are_linked {
1067 return HyperliquidExecGrouping::NormalTpsl;
1068 }
1069 }
1070
1071 let all_oco_linked = orders.len() >= 2
1072 && orders
1073 .iter()
1074 .all(|o| o.contingency_type() == Some(ContingencyType::Oco) && o.is_reduce_only())
1075 && orders.iter().all(|o| {
1076 o.linked_order_ids().is_some_and(|ids| {
1077 ids.iter()
1078 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1079 })
1080 });
1081
1082 if all_oco_linked {
1083 HyperliquidExecGrouping::PositionTpsl
1084 } else {
1085 HyperliquidExecGrouping::Na
1086 }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use std::str::FromStr;
1092
1093 use nautilus_model::{
1094 enums::{OrderSide, TimeInForce, TriggerType},
1095 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1096 orders::{OrderAny, StopMarketOrder},
1097 types::{Price, Quantity},
1098 };
1099 use rstest::rstest;
1100 use rust_decimal::Decimal;
1101 use rust_decimal_macros::dec;
1102 use serde::{Deserialize, Serialize};
1103
1104 use super::*;
1105
1106 #[rstest]
1107 fn test_make_fill_trade_id_is_stable() {
1108 let id = make_fill_trade_id(
1111 "0xabc123",
1112 12345,
1113 dec!(50000.0),
1114 dec!(0.1),
1115 1704470400000,
1116 dec!(0.0),
1117 );
1118 assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1119 }
1120
1121 #[derive(Serialize, Deserialize)]
1122 struct TestStruct {
1123 #[serde(
1124 serialize_with = "serialize_decimal_as_str",
1125 deserialize_with = "deserialize_decimal_from_str"
1126 )]
1127 value: Decimal,
1128 #[serde(
1129 serialize_with = "serialize_optional_decimal_as_str",
1130 deserialize_with = "deserialize_optional_decimal_from_str"
1131 )]
1132 optional_value: Option<Decimal>,
1133 }
1134
1135 #[rstest]
1136 #[case("#10", 100_000_010, 1, 0)]
1137 #[case("+10", 100_000_010, 1, 0)]
1138 #[case("#31", 100_000_031, 3, 1)]
1139 #[case("+31", 100_000_031, 3, 1)]
1140 fn test_parse_outcome_symbol(
1141 #[case] symbol: &str,
1142 #[case] raw_asset_id: u32,
1143 #[case] outcome: u32,
1144 #[case] side: u8,
1145 ) {
1146 let asset_id = parse_outcome_symbol(symbol).unwrap();
1147 assert_eq!(asset_id.to_raw(), raw_asset_id);
1148 assert_eq!(asset_id.outcome_index(), Some(outcome));
1149 assert_eq!(asset_id.outcome_side(), Some(side));
1150 }
1151
1152 #[rstest]
1153 #[case("25-YES-OUTCOME", 25, 0)]
1154 #[case("25-NO-OUTCOME", 25, 1)]
1155 #[case("0-YES-OUTCOME", 0, 0)]
1156 #[case("999-NO-OUTCOME", 999, 1)]
1157 fn test_parse_outcome_nautilus_symbol(
1158 #[case] symbol: &str,
1159 #[case] outcome_index: u32,
1160 #[case] side: u8,
1161 ) {
1162 let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1163 assert_eq!(parsed, (outcome_index, side));
1164 }
1165
1166 #[rstest]
1167 #[case("25-OUTCOME")]
1168 #[case("25-MAYBE-OUTCOME")]
1169 #[case("25-yes-OUTCOME")]
1170 #[case("-YES-OUTCOME")]
1171 #[case("YES-25-OUTCOME")]
1172 #[case("25-YES-outcome")]
1173 #[case("25-YES")]
1174 fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1175 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1176 }
1177
1178 #[rstest]
1179 #[case("999999999-YES-OUTCOME")]
1181 #[case("429496729-YES-OUTCOME")]
1183 #[case("4294967295-NO-OUTCOME")]
1185 fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1186 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1187 }
1188
1189 #[rstest]
1190 #[case(25, 0, "25-YES-OUTCOME")]
1191 #[case(25, 1, "25-NO-OUTCOME")]
1192 #[case(0, 0, "0-YES-OUTCOME")]
1193 fn test_format_outcome_nautilus_symbol(
1194 #[case] outcome_index: u32,
1195 #[case] side: u8,
1196 #[case] expected: &str,
1197 ) {
1198 assert_eq!(
1199 format_outcome_nautilus_symbol(outcome_index, side),
1200 expected,
1201 );
1202 }
1203
1204 #[rstest]
1205 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1206 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1207 #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1208 #[case("BTC-USD-PERP", None)]
1209 #[case("+250", None)]
1210 fn test_outcome_token_from_nautilus_symbol(
1211 #[case] symbol: &str,
1212 #[case] expected: Option<String>,
1213 ) {
1214 assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1215 }
1216
1217 #[rstest]
1218 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1219 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1220 #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1221 #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1222 #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1223 #[case("+250", Some("+250".to_string()))]
1224 #[case("#250", Some("#250".to_string()))]
1225 #[case("", None)]
1226 fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1227 assert_eq!(cache_alias_for_symbol(symbol), expected);
1228 }
1229
1230 #[rstest]
1231 #[case("10", "expected #<encoding> or +<encoding>")]
1232 #[case("#", "encoding must not be empty")]
1233 #[case("#1a", "encoding must be numeric")]
1234 #[case("#12", "side digit 0 or 1")]
1235 #[case("#4294967295", "fit u32")]
1236 fn test_parse_outcome_symbol_rejects_invalid_values(
1237 #[case] symbol: &str,
1238 #[case] expected_error: &str,
1239 ) {
1240 let err = parse_outcome_symbol(symbol).unwrap_err();
1241 assert!(
1242 err.to_string().contains(expected_error),
1243 "expected error to contain '{expected_error}', received '{err}'",
1244 );
1245 }
1246
1247 #[rstest]
1248 fn test_decimal_serialization_roundtrip() {
1249 let original = TestStruct {
1250 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1251 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1252 };
1253
1254 let json = serde_json::to_string(&original).unwrap();
1255 println!("Serialized: {json}");
1256
1257 assert!(json.contains("\"123.45678901234567890123456789\""));
1259 assert!(json.contains("\"0.000000001\""));
1260
1261 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1262 assert_eq!(original.value, deserialized.value);
1263 assert_eq!(original.optional_value, deserialized.optional_value);
1264 }
1265
1266 #[rstest]
1267 fn test_decimal_precision_preservation() {
1268 let test_cases = [
1269 "0",
1270 "1",
1271 "0.1",
1272 "0.01",
1273 "0.001",
1274 "123.456789012345678901234567890",
1275 "999999999999999999.999999999999999999",
1276 ];
1277
1278 for case in test_cases {
1279 let decimal = Decimal::from_str(case).unwrap();
1280 let test_struct = TestStruct {
1281 value: decimal,
1282 optional_value: Some(decimal),
1283 };
1284
1285 let json = serde_json::to_string(&test_struct).unwrap();
1286 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1287
1288 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1289 assert_eq!(
1290 Some(decimal),
1291 parsed.optional_value,
1292 "Failed for case: {case}"
1293 );
1294 }
1295 }
1296
1297 #[rstest]
1298 fn test_optional_none_handling() {
1299 let test_struct = TestStruct {
1300 value: Decimal::from_str("42.0").unwrap(),
1301 optional_value: None,
1302 };
1303
1304 let json = serde_json::to_string(&test_struct).unwrap();
1305 assert!(json.contains("null"));
1306
1307 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1308 assert_eq!(test_struct.value, parsed.value);
1309 assert_eq!(None, parsed.optional_value);
1310 }
1311
1312 #[rstest]
1313 fn test_round_down_to_tick() {
1314 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1315 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1316 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1317
1318 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1320 }
1321
1322 #[rstest]
1323 fn test_round_down_to_step() {
1324 assert_eq!(
1325 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1326 dec!(0.1234)
1327 );
1328 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1329 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1330
1331 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1333 }
1334
1335 #[rstest]
1336 fn test_min_notional_validation() {
1337 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1339 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1340
1341 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1343 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1344
1345 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1347 }
1348
1349 #[rstest]
1350 fn test_round_to_sig_figs() {
1351 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1353 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1354 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1355
1356 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1358 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1359 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1360
1361 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1363 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1367
1368 assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1369 assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1370 assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1371 assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1372 }
1373
1374 #[rstest]
1375 fn test_normalize_price() {
1376 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1378 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));
1384 }
1385
1386 #[rstest]
1387 fn test_normalize_quantity() {
1388 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1389 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1390 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1391 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1392 }
1393
1394 #[rstest]
1395 fn test_normalize_order_complete() {
1396 let result = normalize_order(
1397 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1405
1406 assert!(result.is_ok());
1407 let (price, qty) = result.unwrap();
1408 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1411
1412 #[rstest]
1413 fn test_normalize_order_min_notional_fail() {
1414 let result = normalize_order(
1415 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1423
1424 assert!(result.is_err());
1425 assert!(result.unwrap_err().contains("Notional value"));
1426 }
1427
1428 #[rstest]
1429 fn test_edge_cases() {
1430 assert_eq!(
1432 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1433 dec!(0.000001)
1434 );
1435
1436 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1438
1439 assert_eq!(
1441 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1442 dec!(100.00)
1443 );
1444 }
1445
1446 #[rstest]
1447 fn test_is_conditional_order_data() {
1448 assert!(is_conditional_order_data(
1450 Some(dec!(50000.0)),
1451 Some(&HyperliquidTpSl::Sl)
1452 ));
1453
1454 assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1456
1457 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1459
1460 assert!(!is_conditional_order_data(None, None));
1462 }
1463
1464 #[rstest]
1465 fn test_parse_trigger_order_type() {
1466 assert_eq!(
1468 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1469 OrderType::StopMarket
1470 );
1471
1472 assert_eq!(
1474 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1475 OrderType::StopLimit
1476 );
1477
1478 assert_eq!(
1480 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1481 OrderType::MarketIfTouched
1482 );
1483
1484 assert_eq!(
1486 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1487 OrderType::LimitIfTouched
1488 );
1489 }
1490
1491 #[rstest]
1492 fn test_parse_order_status_with_trigger() {
1493 let (status, trigger_status) =
1495 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1496 assert_eq!(status, OrderStatus::Accepted);
1497 assert_eq!(trigger_status, Some("activated".to_string()));
1498
1499 let (status, trigger_status) =
1501 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1502 assert_eq!(status, OrderStatus::Accepted);
1503 assert_eq!(trigger_status, Some("pending".to_string()));
1504
1505 let (status, trigger_status) =
1507 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1508 assert_eq!(status, OrderStatus::Accepted);
1509 assert_eq!(trigger_status, None);
1510 }
1511
1512 #[rstest]
1513 fn test_format_trailing_stop_info() {
1514 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1516 assert!(info.contains("100.0"));
1517 assert!(info.contains("callback at 50000.0"));
1518
1519 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1521 assert!(info.contains("5.0%"));
1522 assert!(info.contains("Trailing stop"));
1523
1524 let info =
1526 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1527 assert!(info.contains("250 bps"));
1528 assert!(info.contains("49000.0"));
1529 }
1530
1531 #[rstest]
1532 fn test_parse_trigger_price() {
1533 let result = parse_trigger_price("50000.0");
1535 assert!(result.is_ok());
1536 assert_eq!(result.unwrap(), dec!(50000.0));
1537
1538 let result = parse_trigger_price("49000");
1540 assert!(result.is_ok());
1541 assert_eq!(result.unwrap(), dec!(49000));
1542
1543 let result = parse_trigger_price("invalid");
1545 assert!(result.is_err());
1546
1547 let result = parse_trigger_price("");
1549 assert!(result.is_err());
1550 }
1551
1552 #[rstest]
1553 #[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(
1564 #[case] trigger_price: Decimal,
1565 #[case] is_buy: bool,
1566 #[case] expected: Decimal,
1567 ) {
1568 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1569 assert_eq!(result, expected);
1570
1571 if is_buy {
1573 assert!(result >= trigger_price);
1574 } else {
1575 assert!(result <= trigger_price);
1576 }
1577 }
1578
1579 #[rstest]
1580 #[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))]
1590 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1591 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1593 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1594 fn test_clamp_price_to_precision(
1595 #[case] price: Decimal,
1596 #[case] decimals: u8,
1597 #[case] is_buy: bool,
1598 #[case] expected: Decimal,
1599 ) {
1600 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1601 }
1602
1603 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1604 OrderAny::StopMarket(StopMarketOrder::new(
1605 TraderId::from("TESTER-001"),
1606 StrategyId::from("S-001"),
1607 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1608 ClientOrderId::from("O-001"),
1609 side,
1610 Quantity::from(1),
1611 Price::from(trigger_price),
1612 TriggerType::LastPrice,
1613 TimeInForce::Gtc,
1614 None,
1615 false,
1616 false,
1617 None,
1618 None,
1619 None,
1620 None,
1621 None,
1622 None,
1623 None,
1624 None,
1625 None,
1626 None,
1627 None,
1628 Default::default(),
1629 Default::default(),
1630 ))
1631 }
1632
1633 #[rstest]
1634 #[case(OrderSide::Sell, "2470.00", 2)]
1636 #[case(OrderSide::Buy, "2470.00", 2)]
1637 #[case(OrderSide::Sell, "104567.3", 1)]
1639 #[case(OrderSide::Buy, "104567.3", 1)]
1640 #[case(OrderSide::Sell, "0.50", 4)]
1642 #[case(OrderSide::Buy, "0.50", 4)]
1643 #[case(OrderSide::Sell, "2470.00", 1)]
1647 #[case(OrderSide::Buy, "2470.00", 1)]
1648 #[case(OrderSide::Sell, "2470.00", 0)]
1652 #[case(OrderSide::Buy, "2470.00", 0)]
1653 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1654 #[case] side: OrderSide,
1655 #[case] trigger_str: &str,
1656 #[case] price_decimals: u8,
1657 ) {
1658 let order = stop_market_order(side, trigger_str);
1659 let request = order_to_hyperliquid_request_with_asset(
1660 &order,
1661 0,
1662 price_decimals,
1663 true,
1664 DEFAULT_MARKET_SLIPPAGE_BPS,
1665 )
1666 .unwrap();
1667 let trigger = Decimal::from_str(trigger_str).unwrap();
1668 let is_buy = matches!(side, OrderSide::Buy);
1669
1670 if is_buy {
1672 assert!(
1673 request.price >= trigger,
1674 "BUY limit {} must be >= trigger {trigger}",
1675 request.price,
1676 );
1677 assert!(request.is_buy);
1678 } else {
1679 assert!(
1680 request.price <= trigger,
1681 "SELL limit {} must be <= trigger {trigger}",
1682 request.price,
1683 );
1684 assert!(!request.is_buy);
1685 }
1686
1687 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1689 let sig_rounded = round_to_sig_figs(derived, 5);
1690 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1691 assert_eq!(request.price, expected);
1692
1693 let price_str = request.price.to_string();
1695 let actual_decimals = price_str
1696 .find('.')
1697 .map_or(0, |dot| price_str.len() - dot - 1);
1698 assert!(
1699 actual_decimals <= price_decimals as usize,
1700 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1701 );
1702
1703 if price_str.contains('.') {
1705 assert!(
1706 !price_str.ends_with('0'),
1707 "Price {price_str} has decimal trailing zeros",
1708 );
1709 }
1710
1711 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1712 assert_eq!(
1713 request.kind,
1714 HyperliquidExecOrderKind::Trigger {
1715 trigger: HyperliquidExecTriggerParams {
1716 is_market: true,
1717 trigger_px: expected_trigger,
1718 tpsl: HyperliquidExecTpSl::Sl,
1719 },
1720 },
1721 );
1722 }
1723
1724 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1725 HyperliquidExchangeResponse::Status {
1726 status: "ok".to_string(),
1727 response: inner,
1728 }
1729 }
1730
1731 #[rstest]
1732 fn test_extract_inner_error_order_with_error() {
1733 let response = ok_response(serde_json::json!({
1734 "type": "order",
1735 "data": {"statuses": [{"error": "Order has invalid price."}]}
1736 }));
1737 assert_eq!(
1738 extract_inner_error(&response),
1739 Some("Order has invalid price.".to_string()),
1740 );
1741 }
1742
1743 #[rstest]
1744 fn test_extract_inner_error_order_resting() {
1745 let response = ok_response(serde_json::json!({
1746 "type": "order",
1747 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1748 }));
1749 assert_eq!(extract_inner_error(&response), None);
1750 }
1751
1752 #[rstest]
1753 fn test_extract_inner_error_order_filled() {
1754 let response = ok_response(serde_json::json!({
1755 "type": "order",
1756 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1757 }));
1758 assert_eq!(extract_inner_error(&response), None);
1759 }
1760
1761 #[rstest]
1762 fn test_extract_inner_error_cancel_error() {
1763 let response = ok_response(serde_json::json!({
1764 "type": "cancel",
1765 "data": {"statuses": [{"error": "Order not found"}]}
1766 }));
1767 assert_eq!(
1768 extract_inner_error(&response),
1769 Some("Order not found".to_string()),
1770 );
1771 }
1772
1773 #[rstest]
1774 fn test_extract_inner_error_cancel_success() {
1775 let response = ok_response(serde_json::json!({
1776 "type": "cancel",
1777 "data": {"statuses": ["success"]}
1778 }));
1779 assert_eq!(extract_inner_error(&response), None);
1780 }
1781
1782 #[rstest]
1783 fn test_extract_inner_error_modify_error() {
1784 let response = ok_response(serde_json::json!({
1785 "type": "modify",
1786 "data": {"statuses": [{"error": "Invalid modify"}]}
1787 }));
1788 assert_eq!(
1789 extract_inner_error(&response),
1790 Some("Invalid modify".to_string()),
1791 );
1792 }
1793
1794 #[rstest]
1795 fn test_extract_inner_error_modify_success() {
1796 let response = ok_response(serde_json::json!({
1797 "type": "modify",
1798 "data": {"statuses": ["success"]}
1799 }));
1800 assert_eq!(extract_inner_error(&response), None);
1801 }
1802
1803 #[rstest]
1804 fn test_extract_inner_error_non_status_response() {
1805 let response = HyperliquidExchangeResponse::Error {
1806 error: "top-level error".to_string(),
1807 };
1808 assert_eq!(extract_inner_error(&response), None);
1809 }
1810
1811 #[rstest]
1812 fn test_extract_inner_error_unparsable_response() {
1813 let response = ok_response(serde_json::json!({"unknown": "data"}));
1814 assert_eq!(extract_inner_error(&response), None);
1815 }
1816
1817 #[rstest]
1818 fn test_extract_inner_error_returns_first_error_in_batch() {
1819 let response = ok_response(serde_json::json!({
1820 "type": "order",
1821 "data": {"statuses": [
1822 {"resting": {"oid": 1}},
1823 {"error": "Second failed"},
1824 {"error": "Third failed"},
1825 ]}
1826 }));
1827 assert_eq!(
1828 extract_inner_error(&response),
1829 Some("Second failed".to_string()),
1830 );
1831 }
1832
1833 #[rstest]
1834 fn test_extract_inner_errors_mixed_batch() {
1835 let response = ok_response(serde_json::json!({
1836 "type": "order",
1837 "data": {"statuses": [
1838 {"resting": {"oid": 1}},
1839 {"error": "Failed order"},
1840 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1841 ]}
1842 }));
1843 let errors = extract_inner_errors(&response);
1844 assert_eq!(errors.len(), 3);
1845 assert_eq!(errors[0], None);
1846 assert_eq!(errors[1], Some("Failed order".to_string()));
1847 assert_eq!(errors[2], None);
1848 }
1849
1850 #[rstest]
1851 fn test_extract_inner_errors_all_success() {
1852 let response = ok_response(serde_json::json!({
1853 "type": "order",
1854 "data": {"statuses": [
1855 {"resting": {"oid": 1}},
1856 {"resting": {"oid": 2}},
1857 ]}
1858 }));
1859 let errors = extract_inner_errors(&response);
1860 assert_eq!(errors.len(), 2);
1861 assert!(errors.iter().all(|e| e.is_none()));
1862 }
1863
1864 #[rstest]
1865 fn test_extract_inner_errors_cancel_success() {
1866 let response = ok_response(serde_json::json!({
1867 "type": "cancel",
1868 "data": {"statuses": ["success"]}
1869 }));
1870 let errors = extract_inner_errors(&response);
1871 assert_eq!(errors.len(), 1);
1872 assert!(errors[0].is_none());
1873 }
1874
1875 #[rstest]
1876 fn test_extract_inner_errors_cancel_mixed() {
1877 let response = ok_response(serde_json::json!({
1878 "type": "cancel",
1879 "data": {"statuses": [
1880 "success",
1881 {"error": "Order was never placed, already canceled, or filled."},
1882 "success",
1883 ]}
1884 }));
1885 let errors = extract_inner_errors(&response);
1886 assert_eq!(errors.len(), 3);
1887 assert_eq!(errors[0], None);
1888 assert_eq!(
1889 errors[1],
1890 Some("Order was never placed, already canceled, or filled.".to_string())
1891 );
1892 assert_eq!(errors[2], None);
1893 }
1894
1895 #[rstest]
1896 fn test_extract_inner_errors_modify_mixed() {
1897 let response = ok_response(serde_json::json!({
1898 "type": "modify",
1899 "data": {"statuses": [
1900 "success",
1901 {"error": "Order does not exist"},
1902 ]}
1903 }));
1904 let errors = extract_inner_errors(&response);
1905 assert_eq!(errors.len(), 2);
1906 assert_eq!(errors[0], None);
1907 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1908 }
1909
1910 #[rstest]
1911 fn test_extract_inner_errors_unparsable() {
1912 let response = ok_response(serde_json::json!({"foo": "bar"}));
1913 let errors = extract_inner_errors(&response);
1914 assert!(errors.is_empty());
1915 }
1916
1917 fn count_sig_figs(s: &str) -> usize {
1918 let s = s.trim_start_matches('-');
1919 if s.contains('.') {
1920 let digits: String = s.replace('.', "");
1922 digits.trim_start_matches('0').len()
1923 } else {
1924 let s = s.trim_start_matches('0');
1926 s.trim_end_matches('0').len()
1927 }
1928 }
1929
1930 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1931 QuoteTick::new(
1932 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1933 Price::from(bid),
1934 Price::from(ask),
1935 Quantity::from("1"),
1936 Quantity::from("1"),
1937 Default::default(),
1938 Default::default(),
1939 )
1940 }
1941
1942 #[rstest]
1943 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1949 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1951 #[case("104500.0", "104567.3", true, 1, "105090")]
1955 #[case("104500.0", "104567.3", false, 1, "103980")]
1957 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1961 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1963 #[case("49900", "50000", true, 0, "50250")]
1967 #[case("49900", "50000", false, 0, "49650")]
1969 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1973 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1975 fn test_derive_market_order_price(
1976 #[case] bid: &str,
1977 #[case] ask: &str,
1978 #[case] is_buy: bool,
1979 #[case] price_decimals: u8,
1980 #[case] expected: &str,
1981 ) {
1982 let quote = make_quote(bid, ask);
1983 let result =
1984 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1985 let expected_dec = Decimal::from_str(expected).unwrap();
1986 assert_eq!(result, expected_dec);
1987
1988 let base = if is_buy {
1990 quote.ask_price.as_decimal()
1991 } else {
1992 quote.bid_price.as_decimal()
1993 };
1994 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1995 let sig_rounded = round_to_sig_figs(derived, 5);
1996 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1997 assert_eq!(result, pipeline);
1998
1999 let s = result.to_string();
2001 if s.contains('.') {
2002 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2003 }
2004
2005 let sig_count = count_sig_figs(&s);
2007 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2008
2009 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2011 assert!(
2012 actual_decimals <= price_decimals as usize,
2013 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2014 );
2015 }
2016
2017 #[rstest]
2018 #[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(
2026 #[case] slippage_bps: u32,
2027 #[case] trigger: Decimal,
2028 #[case] is_buy: bool,
2029 #[case] expected: Decimal,
2030 ) {
2031 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2032 assert_eq!(result, expected);
2033 }
2034
2035 #[rstest]
2036 fn test_derive_market_order_price_respects_slippage_override() {
2037 let quote = make_quote("100.00", "100.10");
2038 let tight = derive_market_order_price("e, true, 2, 50);
2039 let wide = derive_market_order_price("e, true, 2, 800);
2040 assert_eq!(tight, dec!(100.6));
2041 assert_eq!(wide, dec!(108.11));
2042 assert!(wide > tight);
2043 }
2044
2045 #[rstest]
2049 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2050 let json = r#"{
2051 "assetPositions": [],
2052 "crossMarginSummary": {
2053 "accountValue": "150",
2054 "totalNtlPos": "0",
2055 "totalRawUsd": "100",
2056 "totalMarginUsed": "20",
2057 "withdrawable": "120"
2058 },
2059 "withdrawable": "80",
2060 "time": 1700000000000
2061 }"#;
2062
2063 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2064 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2065
2066 assert_eq!(balances.len(), 1);
2067 let balance = &balances[0];
2068 assert_eq!(balance.total.as_decimal(), dec!(100));
2071 assert_eq!(balance.free.as_decimal(), dec!(80));
2072 assert_eq!(balance.locked.as_decimal(), dec!(20));
2073
2074 assert_eq!(margins.len(), 1);
2075 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2076 }
2077
2078 #[rstest]
2079 fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2080 let json =
2081 include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2082
2083 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2084 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2085
2086 assert_eq!(balances.len(), 1);
2087 let balance = &balances[0];
2088 assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2089 assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2090 assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2091
2092 assert_eq!(margins.len(), 1);
2093 assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2094 }
2095
2096 #[rstest]
2097 fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2098 let json = r#"{
2099 "assetPositions": [],
2100 "crossMarginSummary": {
2101 "accountValue": "100",
2102 "totalNtlPos": "0",
2103 "totalRawUsd": "100",
2104 "totalMarginUsed": "0",
2105 "withdrawable": "100"
2106 },
2107 "withdrawable": "150",
2108 "time": 1700000000000
2109 }"#;
2110
2111 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2112 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2113
2114 assert_eq!(balances.len(), 1);
2115 let balance = &balances[0];
2116 assert_eq!(balance.total.as_decimal(), dec!(150));
2117 assert_eq!(balance.free.as_decimal(), dec!(150));
2118 assert_eq!(balance.locked.as_decimal(), dec!(0));
2119 }
2120
2121 #[rstest]
2122 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2123 let json = r#"{
2124 "assetPositions": [],
2125 "withdrawable": "100",
2126 "time": 1700000000000
2127 }"#;
2128
2129 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2130 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2131 assert!(balances.is_empty());
2132 assert!(margins.is_empty());
2133 }
2134
2135 #[rstest]
2136 fn test_parse_spot_account_balances_emits_one_per_token() {
2137 let json = r#"{
2138 "balances": [
2139 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2140 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2141 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2142 ]
2143 }"#;
2144
2145 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2146 let balances = parse_spot_account_balances(&state).unwrap();
2147
2148 assert_eq!(balances.len(), 2);
2149
2150 let usdc = &balances[0];
2151 assert_eq!(usdc.currency.code.as_str(), "USDC");
2152 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2153 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2154 assert_eq!(usdc.locked.as_decimal(), dec!(10));
2155
2156 let purr = &balances[1];
2157 assert_eq!(purr.currency.code.as_str(), "PURR");
2158 assert_eq!(purr.total.as_decimal(), dec!(50));
2159 assert_eq!(purr.free.as_decimal(), dec!(50));
2160 }
2161
2162 #[rstest]
2163 fn test_parse_spot_account_balances_clamps_hold_to_total() {
2164 let json = r#"{
2165 "balances": [
2166 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2167 ]
2168 }"#;
2169
2170 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2171 let balances = parse_spot_account_balances(&state).unwrap();
2172
2173 assert_eq!(balances.len(), 1);
2174 let hype = &balances[0];
2175 assert_eq!(hype.total.as_decimal(), dec!(5));
2176 assert_eq!(hype.free.as_decimal(), dec!(0));
2177 assert_eq!(hype.locked.as_decimal(), dec!(5));
2178 }
2179
2180 #[rstest]
2181 fn test_parse_spot_account_balances_empty() {
2182 let state = SpotClearinghouseState::default();
2183 let balances = parse_spot_account_balances(&state).unwrap();
2184 assert!(balances.is_empty());
2185 }
2186
2187 #[rstest]
2188 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2189 let perp_json = r#"{
2190 "assetPositions": [],
2191 "crossMarginSummary": {
2192 "accountValue": "500",
2193 "totalNtlPos": "0",
2194 "totalRawUsd": "500",
2195 "totalMarginUsed": "0",
2196 "withdrawable": "500"
2197 },
2198 "withdrawable": "500"
2199 }"#;
2200 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2201
2202 let spot_json = r#"{
2203 "balances": [
2204 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2205 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2206 ]
2207 }"#;
2208 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2209
2210 let (balances, margins) =
2211 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2212
2213 assert!(margins.is_empty());
2214 assert_eq!(balances.len(), 2);
2215 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2216 assert_eq!(balances[0].total.as_decimal(), dec!(500));
2217 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2218 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2219 }
2220
2221 #[rstest]
2222 fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2223 let perp_json = r#"{
2224 "assetPositions": [],
2225 "crossMarginSummary": {
2226 "accountValue": "0",
2227 "totalNtlPos": "0",
2228 "totalRawUsd": "0",
2229 "totalMarginUsed": "0",
2230 "withdrawable": "0"
2231 },
2232 "withdrawable": "0"
2233 }"#;
2234 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2235
2236 let spot_json = r#"{
2237 "balances": [
2238 {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2239 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2240 ]
2241 }"#;
2242 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2243
2244 let (balances, margins) =
2245 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2246
2247 assert!(margins.is_empty());
2248 assert_eq!(balances.len(), 2);
2249 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2250 assert_eq!(balances[0].total.as_decimal(), dec!(75));
2251 assert_eq!(balances[0].free.as_decimal(), dec!(70));
2252 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2253 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2254 }
2255
2256 #[rstest]
2257 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2258 let perp_json = r#"{
2259 "assetPositions": [],
2260 "crossMarginSummary": {
2261 "accountValue": "50",
2262 "totalNtlPos": "0",
2263 "totalRawUsd": "50",
2264 "totalMarginUsed": "0",
2265 "withdrawable": "0"
2266 },
2267 "withdrawable": "0"
2268 }"#;
2269 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2270
2271 let spot_json = r#"{
2272 "balances": [
2273 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2274 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2275 ]
2276 }"#;
2277 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2278
2279 let (balances, margins) =
2280 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2281
2282 assert!(margins.is_empty());
2283 assert_eq!(balances.len(), 2);
2284 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2285 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2286 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2287 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2288 }
2289
2290 #[rstest]
2291 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2292 let perp_json = r#"{
2293 "assetPositions": [],
2294 "crossMarginSummary": {
2295 "accountValue": "-50",
2296 "totalNtlPos": "0",
2297 "totalRawUsd": "-50",
2298 "totalMarginUsed": "0",
2299 "withdrawable": "0"
2300 },
2301 "withdrawable": "0"
2302 }"#;
2303 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2304
2305 let spot_json = r#"{
2306 "balances": [
2307 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2308 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2309 ]
2310 }"#;
2311 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2312
2313 let (balances, margins) =
2314 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2315
2316 assert!(margins.is_empty());
2317 assert_eq!(balances.len(), 2);
2318 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2319 assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2320 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2321 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2322 }
2323
2324 #[rstest]
2325 fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2326 let perp_json = r#"{
2327 "assetPositions": [],
2328 "crossMarginSummary": {
2329 "accountValue": "0",
2330 "totalNtlPos": "0",
2331 "totalRawUsd": "0",
2332 "totalMarginUsed": "25",
2333 "withdrawable": "0"
2334 },
2335 "withdrawable": "0"
2336 }"#;
2337 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2338
2339 let spot_json = r#"{
2340 "balances": [
2341 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2342 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2343 ]
2344 }"#;
2345 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2346
2347 let (balances, margins) =
2348 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2349
2350 assert_eq!(margins.len(), 1);
2351 assert_eq!(balances.len(), 2);
2352 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2353 assert_eq!(balances[0].total.as_decimal(), dec!(0));
2354 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2355 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2356 }
2357
2358 #[rstest]
2359 fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2360 let perp_json = r#"{
2361 "assetPositions": [],
2362 "crossMarginSummary": {
2363 "accountValue": "0",
2364 "totalNtlPos": "0",
2365 "totalRawUsd": "0",
2366 "totalMarginUsed": "0",
2367 "withdrawable": "50"
2368 },
2369 "withdrawable": "50"
2370 }"#;
2371 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2372
2373 let spot_json = r#"{
2374 "balances": [
2375 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2376 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2377 ]
2378 }"#;
2379 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2380
2381 let (balances, margins) =
2382 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2383
2384 assert!(margins.is_empty());
2385 assert_eq!(balances.len(), 2);
2386 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2387 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2388 assert_eq!(balances[0].free.as_decimal(), dec!(50));
2389 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2390 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2391 }
2392
2393 #[rstest]
2394 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2395 let perp_json = r#"{"assetPositions": []}"#;
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": "50", "hold": "0", "entryNtl": "0"}
2401 ]
2402 }"#;
2403 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2404
2405 let (balances, _) =
2406 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2407
2408 assert_eq!(balances.len(), 1);
2409 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2410 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2411 }
2412}