1use anyhow::Context;
62use nautilus_core::UnixNanos;
63pub use nautilus_core::serialization::{
64 deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
65 deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
66 serialize_vec_decimal_as_str,
67};
68use nautilus_model::{
69 data::{bar::BarType, quote::QuoteTick},
70 enums::{
71 AggregationSource, BarAggregation, ContingencyType, OrderSide, OrderStatus, OrderType,
72 TimeInForce,
73 },
74 identifiers::{ClientOrderId, TradeId},
75 orders::{Order, any::OrderAny},
76 types::{AccountBalance, Currency, MarginBalance, Money},
77};
78use rust_decimal::Decimal;
79
80use crate::{
81 common::{
82 enums::{
83 HyperliquidBarInterval::{self, *},
84 HyperliquidOrderStatus, HyperliquidTpSl,
85 },
86 types::HyperliquidAssetId,
87 },
88 http::models::{
89 ClearinghouseState, Cloid, HyperliquidExchangeCancelByCloidRequest,
90 HyperliquidExchangeCancelStatus, HyperliquidExchangeGrouping,
91 HyperliquidExchangeLimitParams, HyperliquidExchangeModifyStatus,
92 HyperliquidExchangeOrderKind, HyperliquidExchangeOrderStatus,
93 HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
94 HyperliquidExchangeResponseData, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
95 HyperliquidExchangeTriggerParams, RESPONSE_STATUS_OK, SpotClearinghouseState,
96 },
97 websocket::messages::TrailingOffsetType,
98};
99
100pub fn make_fill_trade_id(
108 hash: &str,
109 oid: u64,
110 px: Decimal,
111 sz: Decimal,
112 time: u64,
113 start_position: Decimal,
114) -> TradeId {
115 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
117 for &b in hash.as_bytes() {
118 h ^= b as u64;
119 h = h.wrapping_mul(0x0100_0000_01b3);
120 }
121
122 for b in oid.to_le_bytes() {
123 h ^= b as u64;
124 h = h.wrapping_mul(0x0100_0000_01b3);
125 }
126
127 for &b in px.to_string().as_bytes() {
128 h ^= b as u64;
129 h = h.wrapping_mul(0x0100_0000_01b3);
130 }
131
132 for &b in sz.to_string().as_bytes() {
133 h ^= b as u64;
134 h = h.wrapping_mul(0x0100_0000_01b3);
135 }
136
137 for b in time.to_le_bytes() {
138 h ^= b as u64;
139 h = h.wrapping_mul(0x0100_0000_01b3);
140 }
141
142 for &b in start_position.to_string().as_bytes() {
143 h ^= b as u64;
144 h = h.wrapping_mul(0x0100_0000_01b3);
145 }
146 TradeId::new(format!("{h:016x}-{oid:016x}"))
147}
148
149#[inline]
151pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
152 if tick_size.is_zero() {
153 return price;
154 }
155 (price / tick_size).floor() * tick_size
156}
157
158#[inline]
160pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
161 if step_size.is_zero() {
162 return qty;
163 }
164 (qty / step_size).floor() * step_size
165}
166
167#[inline]
169pub fn ensure_min_notional(
170 price: Decimal,
171 qty: Decimal,
172 min_notional: Decimal,
173) -> Result<(), String> {
174 let notional = price * qty;
175 if notional < min_notional {
176 Err(format!(
177 "Notional value {notional} is less than minimum required {min_notional}"
178 ))
179 } else {
180 Ok(())
181 }
182}
183
184pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
187 if value.is_zero() {
188 return Decimal::ZERO;
189 }
190
191 let mantissa = value.mantissa().unsigned_abs();
193 let magnitude = mantissa.ilog10() as i32 - value.scale() as i32;
194
195 let shift = sig_figs as i32 - 1 - magnitude;
196 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
197
198 if shift >= 0 {
199 (value * factor).round() / factor
200 } else {
201 (value / factor).round() * factor
202 }
203}
204
205pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
207 let sig_fig_price = round_to_sig_figs(price, 5);
209 let scale = Decimal::from(10_u64.pow(decimals as u32));
211 (sig_fig_price * scale).floor() / scale
212}
213
214pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
216 let scale = Decimal::from(10_u64.pow(decimals as u32));
217 (qty * scale).floor() / scale
218}
219
220pub fn normalize_order(
222 price: Decimal,
223 qty: Decimal,
224 tick_size: Decimal,
225 step_size: Decimal,
226 min_notional: Decimal,
227 price_decimals: u8,
228 size_decimals: u8,
229) -> Result<(Decimal, Decimal), String> {
230 let normalized_price = normalize_price(price, price_decimals);
232 let normalized_qty = normalize_quantity(qty, size_decimals);
233
234 let final_price = round_down_to_tick(normalized_price, tick_size);
236 let final_qty = round_down_to_step(normalized_qty, step_size);
237
238 ensure_min_notional(final_price, final_qty, min_notional)?;
240
241 Ok((final_price, final_qty))
242}
243
244#[inline]
246pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
247 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
248 Ok(UnixNanos::from(value))
249}
250
251pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
261 let encoding = parse_outcome_symbol_encoding(symbol)?;
262 HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
263 format!(
264 "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
265 )
266 })
267}
268
269fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
270 let encoding = symbol
271 .strip_prefix('#')
272 .or_else(|| symbol.strip_prefix('+'))
273 .with_context(|| {
274 format!(
275 "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
276 )
277 })?;
278
279 if encoding.is_empty() {
280 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
281 }
282
283 if !encoding.bytes().all(|b| b.is_ascii_digit()) {
284 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
285 }
286
287 encoding
288 .parse::<u32>()
289 .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
290}
291
292pub const OUTCOME_SYMBOL_SUFFIX: &str = "-OUTCOME";
294pub const OUTCOME_SIDE_YES: &str = "YES";
296pub const OUTCOME_SIDE_NO: &str = "NO";
298
299#[must_use]
310pub fn parse_outcome_nautilus_symbol(symbol: &str) -> Option<(u32, u8)> {
311 let rest = symbol.strip_suffix(OUTCOME_SYMBOL_SUFFIX)?;
312 let (index_str, side_str) = rest.rsplit_once('-')?;
313 let outcome_index = index_str.parse::<u32>().ok()?;
314 let side = match side_str {
315 OUTCOME_SIDE_YES => 0,
316 OUTCOME_SIDE_NO => 1,
317 _ => return None,
318 };
319 let encoding = outcome_index
320 .checked_mul(10)?
321 .checked_add(u32::from(side))?;
322 HyperliquidAssetId::from_outcome_encoding(encoding)?;
323 Some((outcome_index, side))
324}
325
326#[must_use]
329pub fn format_outcome_nautilus_symbol(outcome_index: u32, side: u8) -> String {
330 let side_label = match side {
331 0 => OUTCOME_SIDE_YES,
332 _ => OUTCOME_SIDE_NO,
333 };
334 format!("{outcome_index}-{side_label}{OUTCOME_SYMBOL_SUFFIX}")
335}
336
337#[must_use]
340pub fn outcome_token_from_nautilus_symbol(symbol: &str) -> Option<String> {
341 let (outcome_index, side) = parse_outcome_nautilus_symbol(symbol)?;
342 let encoding = 10 * outcome_index + u32::from(side);
343 Some(format!("+{encoding}"))
344}
345
346#[must_use]
357pub fn cache_alias_for_symbol(symbol: &str) -> Option<String> {
358 if let Some(token) = outcome_token_from_nautilus_symbol(symbol) {
359 return Some(token);
360 }
361
362 let leading = symbol.split('-').next()?;
363 if leading.is_empty() {
364 None
365 } else {
366 Some(leading.to_string())
367 }
368}
369
370pub fn time_in_force_to_hyperliquid_tif(
376 tif: TimeInForce,
377 is_post_only: bool,
378) -> anyhow::Result<HyperliquidExchangeTif> {
379 match (tif, is_post_only) {
380 (_, true) => Ok(HyperliquidExchangeTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExchangeTif::Gtc),
382 (TimeInForce::Ioc, false) => Ok(HyperliquidExchangeTif::Ioc),
383 (TimeInForce::Fok, false) => {
384 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
385 }
386 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
387 }
388}
389
390fn determine_tpsl_type(
391 order_type: OrderType,
392 order_side: OrderSide,
393 trigger_price: Decimal,
394 current_price: Option<Decimal>,
395) -> HyperliquidExchangeTpSl {
396 match order_type {
397 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
399
400 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExchangeTpSl::Tp,
402
403 _ => {
405 if let Some(current) = current_price {
406 match order_side {
407 OrderSide::Buy => {
408 if trigger_price > current {
410 HyperliquidExchangeTpSl::Sl
411 } else {
412 HyperliquidExchangeTpSl::Tp
413 }
414 }
415 OrderSide::Sell => {
416 if trigger_price < current {
418 HyperliquidExchangeTpSl::Sl
419 } else {
420 HyperliquidExchangeTpSl::Tp
421 }
422 }
423 }
424 } else {
425 HyperliquidExchangeTpSl::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<HyperliquidExchangePlaceOrderRequest> {
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<HyperliquidExchangePlaceOrderRequest> {
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 => HyperliquidExchangeOrderKind::Limit {
547 limit: HyperliquidExchangeLimitParams {
548 tif: HyperliquidExchangeTif::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 HyperliquidExchangeOrderKind::Limit {
555 limit: HyperliquidExchangeLimitParams { 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 HyperliquidExchangeOrderKind::Trigger {
568 trigger: HyperliquidExchangeTriggerParams {
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 HyperliquidExchangeOrderKind::Trigger {
588 trigger: HyperliquidExchangeTriggerParams {
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 HyperliquidExchangeOrderKind::Trigger {
607 trigger: HyperliquidExchangeTriggerParams {
608 is_market: true,
609 trigger_px: trigger_price_decimal,
610 tpsl: HyperliquidExchangeTpSl::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 HyperliquidExchangeOrderKind::Trigger {
626 trigger: HyperliquidExchangeTriggerParams {
627 is_market: false,
628 trigger_px: trigger_price_decimal,
629 tpsl: HyperliquidExchangeTpSl::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(HyperliquidExchangePlaceOrderRequest {
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) -> HyperliquidExchangeCancelByCloidRequest {
709 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
710 HyperliquidExchangeCancelByCloidRequest { 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: HyperliquidExchangeResponseData = serde_json::from_value(response.clone()).ok()?;
723 match data {
724 HyperliquidExchangeResponseData::Order { data } => {
725 for status in &data.statuses {
726 if let HyperliquidExchangeOrderStatus::Error { error } = status {
727 return Some(error.clone());
728 }
729 }
730 None
731 }
732 HyperliquidExchangeResponseData::Cancel { data } => {
733 for status in &data.statuses {
734 if let HyperliquidExchangeCancelStatus::Error { error } = status {
735 return Some(error.clone());
736 }
737 }
738 None
739 }
740 HyperliquidExchangeResponseData::Modify { data } => {
741 for status in &data.statuses {
742 if let HyperliquidExchangeModifyStatus::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::<HyperliquidExchangeResponseData>(response.clone())
762 else {
763 return Vec::new();
764 };
765
766 match data {
767 HyperliquidExchangeResponseData::Order { data } => data
768 .statuses
769 .into_iter()
770 .map(|s| match s {
771 HyperliquidExchangeOrderStatus::Error { error } => Some(error),
772 _ => None,
773 })
774 .collect(),
775 HyperliquidExchangeResponseData::Cancel { data } => data
776 .statuses
777 .into_iter()
778 .map(|s| match s {
779 HyperliquidExchangeCancelStatus::Error { error } => Some(error),
780 HyperliquidExchangeCancelStatus::Success(_) => None,
781 })
782 .collect(),
783 HyperliquidExchangeResponseData::Modify { data } => data
784 .statuses
785 .into_iter()
786 .map(|s| match s {
787 HyperliquidExchangeModifyStatus::Error { error } => Some(error),
788 HyperliquidExchangeModifyStatus::Success(_) => None,
789 })
790 .collect(),
791 _ => Vec::new(),
792 }
793}
794
795pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
797 match response {
798 HyperliquidExchangeResponse::Status { status, response } => {
799 if status == RESPONSE_STATUS_OK {
800 "Operation successful".to_string()
801 } else {
802 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
804 error_msg.to_string()
805 } else {
806 format!("Request failed with status: {status}")
807 }
808 }
809 }
810 HyperliquidExchangeResponse::Error { error } => error.clone(),
811 }
812}
813
814pub fn is_conditional_order_data(
820 trigger_px: Option<Decimal>,
821 tpsl: Option<&HyperliquidTpSl>,
822) -> bool {
823 trigger_px.is_some() && tpsl.is_some()
824}
825
826pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
832 match (is_market, tpsl) {
833 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
834 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
835 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
836 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
837 }
838}
839
840pub fn parse_order_status_with_trigger(
846 status: HyperliquidOrderStatus,
847 trigger_activated: Option<bool>,
848) -> (OrderStatus, Option<String>) {
849 let base_status = OrderStatus::from(status);
850
851 if let Some(activated) = trigger_activated {
853 let trigger_status = if activated {
854 Some("activated".to_string())
855 } else {
856 Some("pending".to_string())
857 };
858 (base_status, trigger_status)
859 } else {
860 (base_status, None)
861 }
862}
863
864pub fn format_trailing_stop_info(
866 offset: &str,
867 offset_type: TrailingOffsetType,
868 callback_price: Option<&str>,
869) -> String {
870 let offset_desc = offset_type.format_offset(offset);
871
872 if let Some(callback) = callback_price {
873 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
874 } else {
875 format!("Trailing stop: {offset_desc} offset")
876 }
877}
878
879pub fn validate_conditional_order_params(
885 trigger_px: Option<&str>,
886 tpsl: Option<&HyperliquidTpSl>,
887 is_market: Option<bool>,
888) -> anyhow::Result<()> {
889 if trigger_px.is_none() {
890 anyhow::bail!("Conditional order missing trigger price");
891 }
892
893 if tpsl.is_none() {
894 anyhow::bail!("Conditional order missing tpsl indicator");
895 }
896
897 if is_market.is_none() {
900 anyhow::bail!("Conditional order missing is_market flag");
901 }
902
903 Ok(())
904}
905
906pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
912 Decimal::from_str_exact(trigger_px)
913 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
914}
915
916pub fn parse_account_balances_and_margins(
927 state: &ClearinghouseState,
928) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
929 let mut balances = Vec::new();
930 let mut margins = Vec::new();
931
932 let currency = Currency::USDC();
933
934 let cross_margin_summary = match &state.cross_margin_summary {
935 Some(summary) => summary,
936 None => return Ok((balances, margins)),
937 };
938
939 let mut total_value = cross_margin_summary.total_raw_usd;
940 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
941
942 if total_value >= Decimal::ZERO && free_value > total_value {
945 total_value = free_value;
946 }
947
948 balances.push(AccountBalance::from_total_and_free(
949 total_value,
950 free_value,
951 currency,
952 )?);
953
954 let margin_used = cross_margin_summary.total_margin_used;
955
956 if margin_used > Decimal::ZERO {
957 let initial_margin = Money::from_decimal(margin_used, currency)?;
960 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
961 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
962 }
963
964 Ok((balances, margins))
965}
966
967pub fn parse_combined_account_balances_and_margins(
978 perp_state: &ClearinghouseState,
979 spot_state: &SpotClearinghouseState,
980) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
981 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
982
983 let perp_reflects_usdc = perp_state
984 .cross_margin_summary
985 .as_ref()
986 .is_some_and(|summary| {
987 summary.total_raw_usd != Decimal::ZERO
988 || summary.total_margin_used > Decimal::ZERO
989 || perp_state.withdrawable.unwrap_or(Decimal::ZERO) > Decimal::ZERO
990 });
991
992 if perp_state.cross_margin_summary.is_some() && !perp_reflects_usdc {
993 balances.retain(|balance| balance.currency.code.as_str() != "USDC");
994 }
995
996 let spot_balances = parse_spot_account_balances(spot_state)?;
997
998 for balance in spot_balances {
999 let is_usdc = balance.currency.code.as_str() == "USDC";
1000 if perp_reflects_usdc && is_usdc {
1001 continue;
1002 }
1003 balances.push(balance);
1004 }
1005
1006 Ok((balances, margins))
1007}
1008
1009pub fn parse_spot_account_balances(
1019 state: &SpotClearinghouseState,
1020) -> anyhow::Result<Vec<AccountBalance>> {
1021 let mut balances = Vec::with_capacity(state.balances.len());
1022
1023 for balance in &state.balances {
1024 if balance.total.is_zero() {
1025 continue;
1026 }
1027
1028 let currency = crate::http::parse::get_currency(balance.coin.as_str());
1029
1030 balances.push(AccountBalance::from_total_and_locked(
1034 balance.total,
1035 balance.hold,
1036 currency,
1037 )?);
1038 }
1039
1040 Ok(balances)
1041}
1042
1043pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExchangeGrouping {
1055 if orders.len() >= 2 {
1056 let entry = &orders[0];
1057 let children = &orders[1..];
1058 let entry_id = entry.client_order_id();
1059 let entry_is_oto =
1060 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
1061 let children_are_linked = children.iter().all(|o| {
1062 matches!(
1063 o.contingency_type(),
1064 Some(ContingencyType::Oco | ContingencyType::Ouo)
1065 ) && o.is_reduce_only()
1066 && o.parent_order_id() == Some(entry_id)
1067 });
1068
1069 if entry_is_oto && children_are_linked {
1070 return HyperliquidExchangeGrouping::NormalTpsl;
1071 }
1072 }
1073
1074 let all_oco_linked = orders.len() >= 2
1075 && orders.iter().all(|o| {
1076 matches!(
1077 o.contingency_type(),
1078 Some(ContingencyType::Oco | ContingencyType::Ouo)
1079 ) && o.is_reduce_only()
1080 })
1081 && orders.iter().all(|o| {
1082 o.linked_order_ids().is_some_and(|ids| {
1083 ids.iter()
1084 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
1085 })
1086 });
1087
1088 if all_oco_linked {
1089 HyperliquidExchangeGrouping::PositionTpsl
1090 } else {
1091 HyperliquidExchangeGrouping::Na
1092 }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use std::str::FromStr;
1098
1099 use nautilus_model::{
1100 enums::{OrderSide, TimeInForce, TriggerType},
1101 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
1102 orders::{OrderAny, StopMarketOrder},
1103 types::{Price, Quantity},
1104 };
1105 use rstest::rstest;
1106 use rust_decimal::Decimal;
1107 use rust_decimal_macros::dec;
1108 use serde::{Deserialize, Serialize};
1109
1110 use super::*;
1111
1112 #[rstest]
1113 fn test_make_fill_trade_id_is_stable() {
1114 let id = make_fill_trade_id(
1117 "0xabc123",
1118 12345,
1119 dec!(50000.0),
1120 dec!(0.1),
1121 1704470400000,
1122 dec!(0.0),
1123 );
1124 assert_eq!(id.to_string(), "a846ae6f557868e9-0000000000003039");
1125 }
1126
1127 #[derive(Serialize, Deserialize)]
1128 struct TestStruct {
1129 #[serde(
1130 serialize_with = "serialize_decimal_as_str",
1131 deserialize_with = "deserialize_decimal_from_str"
1132 )]
1133 value: Decimal,
1134 #[serde(
1135 serialize_with = "serialize_optional_decimal_as_str",
1136 deserialize_with = "deserialize_optional_decimal_from_str"
1137 )]
1138 optional_value: Option<Decimal>,
1139 }
1140
1141 #[rstest]
1142 #[case("#10", 100_000_010, 1, 0)]
1143 #[case("+10", 100_000_010, 1, 0)]
1144 #[case("#31", 100_000_031, 3, 1)]
1145 #[case("+31", 100_000_031, 3, 1)]
1146 fn test_parse_outcome_symbol(
1147 #[case] symbol: &str,
1148 #[case] raw_asset_id: u32,
1149 #[case] outcome: u32,
1150 #[case] side: u8,
1151 ) {
1152 let asset_id = parse_outcome_symbol(symbol).unwrap();
1153 assert_eq!(asset_id.to_raw(), raw_asset_id);
1154 assert_eq!(asset_id.outcome_index(), Some(outcome));
1155 assert_eq!(asset_id.outcome_side(), Some(side));
1156 }
1157
1158 #[rstest]
1159 #[case("25-YES-OUTCOME", 25, 0)]
1160 #[case("25-NO-OUTCOME", 25, 1)]
1161 #[case("0-YES-OUTCOME", 0, 0)]
1162 #[case("999-NO-OUTCOME", 999, 1)]
1163 fn test_parse_outcome_nautilus_symbol(
1164 #[case] symbol: &str,
1165 #[case] outcome_index: u32,
1166 #[case] side: u8,
1167 ) {
1168 let parsed = parse_outcome_nautilus_symbol(symbol).unwrap();
1169 assert_eq!(parsed, (outcome_index, side));
1170 }
1171
1172 #[rstest]
1173 #[case("25-OUTCOME")]
1174 #[case("25-MAYBE-OUTCOME")]
1175 #[case("25-yes-OUTCOME")]
1176 #[case("-YES-OUTCOME")]
1177 #[case("YES-25-OUTCOME")]
1178 #[case("25-YES-outcome")]
1179 #[case("25-YES")]
1180 fn test_parse_outcome_nautilus_symbol_rejects_invalid(#[case] symbol: &str) {
1181 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1182 }
1183
1184 #[rstest]
1185 #[case("999999999-YES-OUTCOME")]
1187 #[case("429496729-YES-OUTCOME")]
1189 #[case("4294967295-NO-OUTCOME")]
1191 fn test_parse_outcome_nautilus_symbol_rejects_overflow(#[case] symbol: &str) {
1192 assert!(parse_outcome_nautilus_symbol(symbol).is_none());
1193 }
1194
1195 #[rstest]
1196 #[case(25, 0, "25-YES-OUTCOME")]
1197 #[case(25, 1, "25-NO-OUTCOME")]
1198 #[case(0, 0, "0-YES-OUTCOME")]
1199 fn test_format_outcome_nautilus_symbol(
1200 #[case] outcome_index: u32,
1201 #[case] side: u8,
1202 #[case] expected: &str,
1203 ) {
1204 assert_eq!(
1205 format_outcome_nautilus_symbol(outcome_index, side),
1206 expected,
1207 );
1208 }
1209
1210 #[rstest]
1211 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1212 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1213 #[case("0-YES-OUTCOME", Some("+0".to_string()))]
1214 #[case("BTC-USD-PERP", None)]
1215 #[case("+250", None)]
1216 fn test_outcome_token_from_nautilus_symbol(
1217 #[case] symbol: &str,
1218 #[case] expected: Option<String>,
1219 ) {
1220 assert_eq!(outcome_token_from_nautilus_symbol(symbol), expected);
1221 }
1222
1223 #[rstest]
1224 #[case("25-YES-OUTCOME", Some("+250".to_string()))]
1225 #[case("25-NO-OUTCOME", Some("+251".to_string()))]
1226 #[case("BTC-USD-PERP", Some("BTC".to_string()))]
1227 #[case("PURR-USDC-SPOT", Some("PURR".to_string()))]
1228 #[case("dex:STREAMABCDxxxx-USD-PERP", Some("dex:STREAMABCDxxxx".to_string()))]
1229 #[case("+250", Some("+250".to_string()))]
1230 #[case("#250", Some("#250".to_string()))]
1231 #[case("", None)]
1232 fn test_cache_alias_for_symbol(#[case] symbol: &str, #[case] expected: Option<String>) {
1233 assert_eq!(cache_alias_for_symbol(symbol), expected);
1234 }
1235
1236 #[rstest]
1237 #[case("10", "expected #<encoding> or +<encoding>")]
1238 #[case("#", "encoding must not be empty")]
1239 #[case("#1a", "encoding must be numeric")]
1240 #[case("#12", "side digit 0 or 1")]
1241 #[case("#4294967295", "fit u32")]
1242 fn test_parse_outcome_symbol_rejects_invalid_values(
1243 #[case] symbol: &str,
1244 #[case] expected_error: &str,
1245 ) {
1246 let err = parse_outcome_symbol(symbol).unwrap_err();
1247 assert!(
1248 err.to_string().contains(expected_error),
1249 "expected error to contain '{expected_error}', received '{err}'",
1250 );
1251 }
1252
1253 #[rstest]
1254 fn test_decimal_serialization_roundtrip() {
1255 let original = TestStruct {
1256 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1257 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1258 };
1259
1260 let json = serde_json::to_string(&original).unwrap();
1261 println!("Serialized: {json}");
1262
1263 assert!(json.contains("\"123.45678901234567890123456789\""));
1265 assert!(json.contains("\"0.000000001\""));
1266
1267 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1268 assert_eq!(original.value, deserialized.value);
1269 assert_eq!(original.optional_value, deserialized.optional_value);
1270 }
1271
1272 #[rstest]
1273 fn test_decimal_precision_preservation() {
1274 let test_cases = [
1275 "0",
1276 "1",
1277 "0.1",
1278 "0.01",
1279 "0.001",
1280 "123.456789012345678901234567890",
1281 "999999999999999999.999999999999999999",
1282 ];
1283
1284 for case in test_cases {
1285 let decimal = Decimal::from_str(case).unwrap();
1286 let test_struct = TestStruct {
1287 value: decimal,
1288 optional_value: Some(decimal),
1289 };
1290
1291 let json = serde_json::to_string(&test_struct).unwrap();
1292 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1293
1294 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1295 assert_eq!(
1296 Some(decimal),
1297 parsed.optional_value,
1298 "Failed for case: {case}"
1299 );
1300 }
1301 }
1302
1303 #[rstest]
1304 fn test_optional_none_handling() {
1305 let test_struct = TestStruct {
1306 value: Decimal::from_str("42.0").unwrap(),
1307 optional_value: None,
1308 };
1309
1310 let json = serde_json::to_string(&test_struct).unwrap();
1311 assert!(json.contains("null"));
1312
1313 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1314 assert_eq!(test_struct.value, parsed.value);
1315 assert_eq!(None, parsed.optional_value);
1316 }
1317
1318 #[rstest]
1319 fn test_round_down_to_tick() {
1320 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1321 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1322 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1323
1324 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1326 }
1327
1328 #[rstest]
1329 fn test_round_down_to_step() {
1330 assert_eq!(
1331 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1332 dec!(0.1234)
1333 );
1334 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1335 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1336
1337 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1339 }
1340
1341 #[rstest]
1342 fn test_min_notional_validation() {
1343 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1345 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1346
1347 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1349 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1350
1351 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1353 }
1354
1355 #[rstest]
1356 fn test_round_to_sig_figs() {
1357 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1359 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1360 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1361
1362 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1364 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1365 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1366
1367 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1369 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1373
1374 assert_eq!(round_to_sig_figs(dec!(-104567.3), 5), dec!(-104570));
1375 assert_eq!(round_to_sig_figs(dec!(-1234.5), 5), dec!(-1234.5));
1376 assert_eq!(round_to_sig_figs(dec!(-0.000123456), 5), dec!(-0.00012346));
1377 assert_eq!(round_to_sig_figs(dec!(-0.123456), 5), dec!(-0.12346));
1378 }
1379
1380 #[rstest]
1381 fn test_normalize_price() {
1382 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1384 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));
1390 }
1391
1392 #[rstest]
1393 fn test_normalize_quantity() {
1394 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1395 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1396 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1397 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1398 }
1399
1400 #[rstest]
1401 fn test_normalize_order_complete() {
1402 let result = normalize_order(
1403 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1411
1412 assert!(result.is_ok());
1413 let (price, qty) = result.unwrap();
1414 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1417
1418 #[rstest]
1419 fn test_normalize_order_min_notional_fail() {
1420 let result = normalize_order(
1421 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1429
1430 assert!(result.is_err());
1431 assert!(result.unwrap_err().contains("Notional value"));
1432 }
1433
1434 #[rstest]
1435 fn test_edge_cases() {
1436 assert_eq!(
1438 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1439 dec!(0.000001)
1440 );
1441
1442 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1444
1445 assert_eq!(
1447 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1448 dec!(100.00)
1449 );
1450 }
1451
1452 #[rstest]
1453 fn test_is_conditional_order_data() {
1454 assert!(is_conditional_order_data(
1456 Some(dec!(50000.0)),
1457 Some(&HyperliquidTpSl::Sl)
1458 ));
1459
1460 assert!(!is_conditional_order_data(Some(dec!(50000.0)), None));
1462
1463 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1465
1466 assert!(!is_conditional_order_data(None, None));
1468 }
1469
1470 #[rstest]
1471 fn test_parse_trigger_order_type() {
1472 assert_eq!(
1474 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1475 OrderType::StopMarket
1476 );
1477
1478 assert_eq!(
1480 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1481 OrderType::StopLimit
1482 );
1483
1484 assert_eq!(
1486 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1487 OrderType::MarketIfTouched
1488 );
1489
1490 assert_eq!(
1492 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1493 OrderType::LimitIfTouched
1494 );
1495 }
1496
1497 #[rstest]
1498 fn test_parse_order_status_with_trigger() {
1499 let (status, trigger_status) =
1501 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1502 assert_eq!(status, OrderStatus::Accepted);
1503 assert_eq!(trigger_status, Some("activated".to_string()));
1504
1505 let (status, trigger_status) =
1507 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1508 assert_eq!(status, OrderStatus::Accepted);
1509 assert_eq!(trigger_status, Some("pending".to_string()));
1510
1511 let (status, trigger_status) =
1513 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1514 assert_eq!(status, OrderStatus::Accepted);
1515 assert_eq!(trigger_status, None);
1516 }
1517
1518 #[rstest]
1519 fn test_format_trailing_stop_info() {
1520 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1522 assert!(info.contains("100.0"));
1523 assert!(info.contains("callback at 50000.0"));
1524
1525 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1527 assert!(info.contains("5.0%"));
1528 assert!(info.contains("Trailing stop"));
1529
1530 let info =
1532 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1533 assert!(info.contains("250 bps"));
1534 assert!(info.contains("49000.0"));
1535 }
1536
1537 #[rstest]
1538 fn test_parse_trigger_price() {
1539 let result = parse_trigger_price("50000.0");
1541 assert!(result.is_ok());
1542 assert_eq!(result.unwrap(), dec!(50000.0));
1543
1544 let result = parse_trigger_price("49000");
1546 assert!(result.is_ok());
1547 assert_eq!(result.unwrap(), dec!(49000));
1548
1549 let result = parse_trigger_price("invalid");
1551 assert!(result.is_err());
1552
1553 let result = parse_trigger_price("");
1555 assert!(result.is_err());
1556 }
1557
1558 #[rstest]
1559 #[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(
1570 #[case] trigger_price: Decimal,
1571 #[case] is_buy: bool,
1572 #[case] expected: Decimal,
1573 ) {
1574 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1575 assert_eq!(result, expected);
1576
1577 if is_buy {
1579 assert!(result >= trigger_price);
1580 } else {
1581 assert!(result <= trigger_price);
1582 }
1583 }
1584
1585 #[rstest]
1586 #[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))]
1596 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1597 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1599 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1600 fn test_clamp_price_to_precision(
1601 #[case] price: Decimal,
1602 #[case] decimals: u8,
1603 #[case] is_buy: bool,
1604 #[case] expected: Decimal,
1605 ) {
1606 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1607 }
1608
1609 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1610 OrderAny::StopMarket(StopMarketOrder::new(
1611 TraderId::from("TESTER-001"),
1612 StrategyId::from("S-001"),
1613 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1614 ClientOrderId::from("O-001"),
1615 side,
1616 Quantity::from(1),
1617 Price::from(trigger_price),
1618 TriggerType::LastPrice,
1619 TimeInForce::Gtc,
1620 None,
1621 false,
1622 false,
1623 None,
1624 None,
1625 None,
1626 None,
1627 None,
1628 None,
1629 None,
1630 None,
1631 None,
1632 None,
1633 None,
1634 Default::default(),
1635 Default::default(),
1636 ))
1637 }
1638
1639 #[rstest]
1640 #[case(OrderSide::Sell, "2470.00", 2)]
1642 #[case(OrderSide::Buy, "2470.00", 2)]
1643 #[case(OrderSide::Sell, "104567.3", 1)]
1645 #[case(OrderSide::Buy, "104567.3", 1)]
1646 #[case(OrderSide::Sell, "0.50", 4)]
1648 #[case(OrderSide::Buy, "0.50", 4)]
1649 #[case(OrderSide::Sell, "2470.00", 1)]
1653 #[case(OrderSide::Buy, "2470.00", 1)]
1654 #[case(OrderSide::Sell, "2470.00", 0)]
1658 #[case(OrderSide::Buy, "2470.00", 0)]
1659 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1660 #[case] side: OrderSide,
1661 #[case] trigger_str: &str,
1662 #[case] price_decimals: u8,
1663 ) {
1664 let order = stop_market_order(side, trigger_str);
1665 let request = order_to_hyperliquid_request_with_asset(
1666 &order,
1667 0,
1668 price_decimals,
1669 true,
1670 DEFAULT_MARKET_SLIPPAGE_BPS,
1671 )
1672 .unwrap();
1673 let trigger = Decimal::from_str(trigger_str).unwrap();
1674 let is_buy = matches!(side, OrderSide::Buy);
1675
1676 if is_buy {
1678 assert!(
1679 request.price >= trigger,
1680 "BUY limit {} must be >= trigger {trigger}",
1681 request.price,
1682 );
1683 assert!(request.is_buy);
1684 } else {
1685 assert!(
1686 request.price <= trigger,
1687 "SELL limit {} must be <= trigger {trigger}",
1688 request.price,
1689 );
1690 assert!(!request.is_buy);
1691 }
1692
1693 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1695 let sig_rounded = round_to_sig_figs(derived, 5);
1696 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1697 assert_eq!(request.price, expected);
1698
1699 let price_str = request.price.to_string();
1701 let actual_decimals = price_str
1702 .find('.')
1703 .map_or(0, |dot| price_str.len() - dot - 1);
1704 assert!(
1705 actual_decimals <= price_decimals as usize,
1706 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1707 );
1708
1709 if price_str.contains('.') {
1711 assert!(
1712 !price_str.ends_with('0'),
1713 "Price {price_str} has decimal trailing zeros",
1714 );
1715 }
1716
1717 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1718 assert_eq!(
1719 request.kind,
1720 HyperliquidExchangeOrderKind::Trigger {
1721 trigger: HyperliquidExchangeTriggerParams {
1722 is_market: true,
1723 trigger_px: expected_trigger,
1724 tpsl: HyperliquidExchangeTpSl::Sl,
1725 },
1726 },
1727 );
1728 }
1729
1730 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1731 HyperliquidExchangeResponse::Status {
1732 status: "ok".to_string(),
1733 response: inner,
1734 }
1735 }
1736
1737 #[rstest]
1738 fn test_extract_inner_error_order_with_error() {
1739 let response = ok_response(serde_json::json!({
1740 "type": "order",
1741 "data": {"statuses": [{"error": "Order has invalid price."}]}
1742 }));
1743 assert_eq!(
1744 extract_inner_error(&response),
1745 Some("Order has invalid price.".to_string()),
1746 );
1747 }
1748
1749 #[rstest]
1750 fn test_extract_inner_error_order_resting() {
1751 let response = ok_response(serde_json::json!({
1752 "type": "order",
1753 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1754 }));
1755 assert_eq!(extract_inner_error(&response), None);
1756 }
1757
1758 #[rstest]
1759 fn test_extract_inner_error_order_filled() {
1760 let response = ok_response(serde_json::json!({
1761 "type": "order",
1762 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1763 }));
1764 assert_eq!(extract_inner_error(&response), None);
1765 }
1766
1767 #[rstest]
1768 fn test_extract_inner_error_cancel_error() {
1769 let response = ok_response(serde_json::json!({
1770 "type": "cancel",
1771 "data": {"statuses": [{"error": "Order not found"}]}
1772 }));
1773 assert_eq!(
1774 extract_inner_error(&response),
1775 Some("Order not found".to_string()),
1776 );
1777 }
1778
1779 #[rstest]
1780 fn test_extract_inner_error_cancel_success() {
1781 let response = ok_response(serde_json::json!({
1782 "type": "cancel",
1783 "data": {"statuses": ["success"]}
1784 }));
1785 assert_eq!(extract_inner_error(&response), None);
1786 }
1787
1788 #[rstest]
1789 fn test_extract_inner_error_modify_error() {
1790 let response = ok_response(serde_json::json!({
1791 "type": "modify",
1792 "data": {"statuses": [{"error": "Invalid modify"}]}
1793 }));
1794 assert_eq!(
1795 extract_inner_error(&response),
1796 Some("Invalid modify".to_string()),
1797 );
1798 }
1799
1800 #[rstest]
1801 fn test_extract_inner_error_modify_success() {
1802 let response = ok_response(serde_json::json!({
1803 "type": "modify",
1804 "data": {"statuses": ["success"]}
1805 }));
1806 assert_eq!(extract_inner_error(&response), None);
1807 }
1808
1809 #[rstest]
1810 fn test_extract_inner_error_non_status_response() {
1811 let response = HyperliquidExchangeResponse::Error {
1812 error: "top-level error".to_string(),
1813 };
1814 assert_eq!(extract_inner_error(&response), None);
1815 }
1816
1817 #[rstest]
1818 fn test_extract_inner_error_unparsable_response() {
1819 let response = ok_response(serde_json::json!({"unknown": "data"}));
1820 assert_eq!(extract_inner_error(&response), None);
1821 }
1822
1823 #[rstest]
1824 fn test_extract_inner_error_returns_first_error_in_batch() {
1825 let response = ok_response(serde_json::json!({
1826 "type": "order",
1827 "data": {"statuses": [
1828 {"resting": {"oid": 1}},
1829 {"error": "Second failed"},
1830 {"error": "Third failed"},
1831 ]}
1832 }));
1833 assert_eq!(
1834 extract_inner_error(&response),
1835 Some("Second failed".to_string()),
1836 );
1837 }
1838
1839 #[rstest]
1840 fn test_extract_inner_errors_mixed_batch() {
1841 let response = ok_response(serde_json::json!({
1842 "type": "order",
1843 "data": {"statuses": [
1844 {"resting": {"oid": 1}},
1845 {"error": "Failed order"},
1846 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1847 ]}
1848 }));
1849 let errors = extract_inner_errors(&response);
1850 assert_eq!(errors.len(), 3);
1851 assert_eq!(errors[0], None);
1852 assert_eq!(errors[1], Some("Failed order".to_string()));
1853 assert_eq!(errors[2], None);
1854 }
1855
1856 #[rstest]
1857 fn test_extract_inner_errors_all_success() {
1858 let response = ok_response(serde_json::json!({
1859 "type": "order",
1860 "data": {"statuses": [
1861 {"resting": {"oid": 1}},
1862 {"resting": {"oid": 2}},
1863 ]}
1864 }));
1865 let errors = extract_inner_errors(&response);
1866 assert_eq!(errors.len(), 2);
1867 assert!(errors.iter().all(|e| e.is_none()));
1868 }
1869
1870 #[rstest]
1871 fn test_extract_inner_errors_cancel_success() {
1872 let response = ok_response(serde_json::json!({
1873 "type": "cancel",
1874 "data": {"statuses": ["success"]}
1875 }));
1876 let errors = extract_inner_errors(&response);
1877 assert_eq!(errors.len(), 1);
1878 assert!(errors[0].is_none());
1879 }
1880
1881 #[rstest]
1882 fn test_extract_inner_errors_cancel_mixed() {
1883 let response = ok_response(serde_json::json!({
1884 "type": "cancel",
1885 "data": {"statuses": [
1886 "success",
1887 {"error": "Order was never placed, already canceled, or filled."},
1888 "success",
1889 ]}
1890 }));
1891 let errors = extract_inner_errors(&response);
1892 assert_eq!(errors.len(), 3);
1893 assert_eq!(errors[0], None);
1894 assert_eq!(
1895 errors[1],
1896 Some("Order was never placed, already canceled, or filled.".to_string())
1897 );
1898 assert_eq!(errors[2], None);
1899 }
1900
1901 #[rstest]
1902 fn test_extract_inner_errors_modify_mixed() {
1903 let response = ok_response(serde_json::json!({
1904 "type": "modify",
1905 "data": {"statuses": [
1906 "success",
1907 {"error": "Order does not exist"},
1908 ]}
1909 }));
1910 let errors = extract_inner_errors(&response);
1911 assert_eq!(errors.len(), 2);
1912 assert_eq!(errors[0], None);
1913 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1914 }
1915
1916 #[rstest]
1917 fn test_extract_inner_errors_unparsable() {
1918 let response = ok_response(serde_json::json!({"foo": "bar"}));
1919 let errors = extract_inner_errors(&response);
1920 assert!(errors.is_empty());
1921 }
1922
1923 fn count_sig_figs(s: &str) -> usize {
1924 let s = s.trim_start_matches('-');
1925 if s.contains('.') {
1926 let digits: String = s.replace('.', "");
1928 digits.trim_start_matches('0').len()
1929 } else {
1930 let s = s.trim_start_matches('0');
1932 s.trim_end_matches('0').len()
1933 }
1934 }
1935
1936 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1937 QuoteTick::new(
1938 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1939 Price::from(bid),
1940 Price::from(ask),
1941 Quantity::from("1"),
1942 Quantity::from("1"),
1943 Default::default(),
1944 Default::default(),
1945 )
1946 }
1947
1948 #[rstest]
1949 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1955 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1957 #[case("104500.0", "104567.3", true, 1, "105090")]
1961 #[case("104500.0", "104567.3", false, 1, "103980")]
1963 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1967 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1969 #[case("49900", "50000", true, 0, "50250")]
1973 #[case("49900", "50000", false, 0, "49650")]
1975 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1979 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1981 fn test_derive_market_order_price(
1982 #[case] bid: &str,
1983 #[case] ask: &str,
1984 #[case] is_buy: bool,
1985 #[case] price_decimals: u8,
1986 #[case] expected: &str,
1987 ) {
1988 let quote = make_quote(bid, ask);
1989 let result =
1990 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1991 let expected_dec = Decimal::from_str(expected).unwrap();
1992 assert_eq!(result, expected_dec);
1993
1994 let base = if is_buy {
1996 quote.ask_price.as_decimal()
1997 } else {
1998 quote.bid_price.as_decimal()
1999 };
2000 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
2001 let sig_rounded = round_to_sig_figs(derived, 5);
2002 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
2003 assert_eq!(result, pipeline);
2004
2005 let s = result.to_string();
2007 if s.contains('.') {
2008 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
2009 }
2010
2011 let sig_count = count_sig_figs(&s);
2013 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
2014
2015 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
2017 assert!(
2018 actual_decimals <= price_decimals as usize,
2019 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
2020 );
2021 }
2022
2023 #[rstest]
2024 #[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(
2032 #[case] slippage_bps: u32,
2033 #[case] trigger: Decimal,
2034 #[case] is_buy: bool,
2035 #[case] expected: Decimal,
2036 ) {
2037 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
2038 assert_eq!(result, expected);
2039 }
2040
2041 #[rstest]
2042 fn test_derive_market_order_price_respects_slippage_override() {
2043 let quote = make_quote("100.00", "100.10");
2044 let tight = derive_market_order_price("e, true, 2, 50);
2045 let wide = derive_market_order_price("e, true, 2, 800);
2046 assert_eq!(tight, dec!(100.6));
2047 assert_eq!(wide, dec!(108.11));
2048 assert!(wide > tight);
2049 }
2050
2051 #[rstest]
2055 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
2056 let json = r#"{
2057 "assetPositions": [],
2058 "crossMarginSummary": {
2059 "accountValue": "150",
2060 "totalNtlPos": "0",
2061 "totalRawUsd": "100",
2062 "totalMarginUsed": "20",
2063 "withdrawable": "120"
2064 },
2065 "withdrawable": "80",
2066 "time": 1700000000000
2067 }"#;
2068
2069 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2070 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2071
2072 assert_eq!(balances.len(), 1);
2073 let balance = &balances[0];
2074 assert_eq!(balance.total.as_decimal(), dec!(100));
2077 assert_eq!(balance.free.as_decimal(), dec!(80));
2078 assert_eq!(balance.locked.as_decimal(), dec!(20));
2079
2080 assert_eq!(margins.len(), 1);
2081 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
2082 }
2083
2084 #[rstest]
2085 fn test_parse_account_balances_preserves_negative_total_raw_usd() {
2086 let json =
2087 include_str!("../../test_data/http_clearinghouse_state_negative_total_raw_usd.json");
2088
2089 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2090 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2091
2092 assert_eq!(balances.len(), 1);
2093 let balance = &balances[0];
2094 assert_eq!(balance.total.as_decimal(), dec!(-22358.938225));
2095 assert_eq!(balance.free.as_decimal(), dec!(772.232111));
2096 assert_eq!(balance.locked.as_decimal(), dec!(-23131.170336));
2097
2098 assert_eq!(margins.len(), 1);
2099 assert_eq!(margins[0].initial.as_decimal(), dec!(963.798764));
2100 }
2101
2102 #[rstest]
2103 fn test_parse_account_balances_bumps_positive_total_when_withdrawable_exceeds() {
2104 let json = r#"{
2105 "assetPositions": [],
2106 "crossMarginSummary": {
2107 "accountValue": "100",
2108 "totalNtlPos": "0",
2109 "totalRawUsd": "100",
2110 "totalMarginUsed": "0",
2111 "withdrawable": "100"
2112 },
2113 "withdrawable": "150",
2114 "time": 1700000000000
2115 }"#;
2116
2117 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2118 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
2119
2120 assert_eq!(balances.len(), 1);
2121 let balance = &balances[0];
2122 assert_eq!(balance.total.as_decimal(), dec!(150));
2123 assert_eq!(balance.free.as_decimal(), dec!(150));
2124 assert_eq!(balance.locked.as_decimal(), dec!(0));
2125 }
2126
2127 #[rstest]
2128 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
2129 let json = r#"{
2130 "assetPositions": [],
2131 "withdrawable": "100",
2132 "time": 1700000000000
2133 }"#;
2134
2135 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
2136 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
2137 assert!(balances.is_empty());
2138 assert!(margins.is_empty());
2139 }
2140
2141 #[rstest]
2142 fn test_parse_spot_account_balances_emits_one_per_token() {
2143 let json = r#"{
2144 "balances": [
2145 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
2146 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
2147 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
2148 ]
2149 }"#;
2150
2151 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2152 let balances = parse_spot_account_balances(&state).unwrap();
2153
2154 assert_eq!(balances.len(), 2);
2155
2156 let usdc = &balances[0];
2157 assert_eq!(usdc.currency.code.as_str(), "USDC");
2158 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
2159 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
2160 assert_eq!(usdc.locked.as_decimal(), dec!(10));
2161
2162 let purr = &balances[1];
2163 assert_eq!(purr.currency.code.as_str(), "PURR");
2164 assert_eq!(purr.total.as_decimal(), dec!(50));
2165 assert_eq!(purr.free.as_decimal(), dec!(50));
2166 }
2167
2168 #[rstest]
2169 fn test_parse_spot_account_balances_clamps_hold_to_total() {
2170 let json = r#"{
2171 "balances": [
2172 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
2173 ]
2174 }"#;
2175
2176 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
2177 let balances = parse_spot_account_balances(&state).unwrap();
2178
2179 assert_eq!(balances.len(), 1);
2180 let hype = &balances[0];
2181 assert_eq!(hype.total.as_decimal(), dec!(5));
2182 assert_eq!(hype.free.as_decimal(), dec!(0));
2183 assert_eq!(hype.locked.as_decimal(), dec!(5));
2184 }
2185
2186 #[rstest]
2187 fn test_parse_spot_account_balances_empty() {
2188 let state = SpotClearinghouseState::default();
2189 let balances = parse_spot_account_balances(&state).unwrap();
2190 assert!(balances.is_empty());
2191 }
2192
2193 #[rstest]
2194 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
2195 let perp_json = r#"{
2196 "assetPositions": [],
2197 "crossMarginSummary": {
2198 "accountValue": "500",
2199 "totalNtlPos": "0",
2200 "totalRawUsd": "500",
2201 "totalMarginUsed": "0",
2202 "withdrawable": "500"
2203 },
2204 "withdrawable": "500"
2205 }"#;
2206 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2207
2208 let spot_json = r#"{
2209 "balances": [
2210 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
2211 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2212 ]
2213 }"#;
2214 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2215
2216 let (balances, margins) =
2217 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2218
2219 assert!(margins.is_empty());
2220 assert_eq!(balances.len(), 2);
2221 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2222 assert_eq!(balances[0].total.as_decimal(), dec!(500));
2223 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2224 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2225 }
2226
2227 #[rstest]
2228 fn test_parse_combined_surfaces_spot_usdc_when_perp_summary_zeroed_unified() {
2229 let perp_json = r#"{
2230 "assetPositions": [],
2231 "crossMarginSummary": {
2232 "accountValue": "0",
2233 "totalNtlPos": "0",
2234 "totalRawUsd": "0",
2235 "totalMarginUsed": "0",
2236 "withdrawable": "0"
2237 },
2238 "withdrawable": "0"
2239 }"#;
2240 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2241
2242 let spot_json = r#"{
2243 "balances": [
2244 {"coin": "USDC", "token": 0, "total": "75", "hold": "5", "entryNtl": "0"},
2245 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2246 ]
2247 }"#;
2248 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2249
2250 let (balances, margins) =
2251 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2252
2253 assert!(margins.is_empty());
2254 assert_eq!(balances.len(), 2);
2255 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2256 assert_eq!(balances[0].total.as_decimal(), dec!(75));
2257 assert_eq!(balances[0].free.as_decimal(), dec!(70));
2258 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2259 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2260 }
2261
2262 #[rstest]
2263 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_non_zero() {
2264 let perp_json = r#"{
2265 "assetPositions": [],
2266 "crossMarginSummary": {
2267 "accountValue": "50",
2268 "totalNtlPos": "0",
2269 "totalRawUsd": "50",
2270 "totalMarginUsed": "0",
2271 "withdrawable": "0"
2272 },
2273 "withdrawable": "0"
2274 }"#;
2275 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2276
2277 let spot_json = r#"{
2278 "balances": [
2279 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2280 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2281 ]
2282 }"#;
2283 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2284
2285 let (balances, margins) =
2286 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2287
2288 assert!(margins.is_empty());
2289 assert_eq!(balances.len(), 2);
2290 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2291 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2292 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2293 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2294 }
2295
2296 #[rstest]
2297 fn test_parse_combined_deduplicates_usdc_when_perp_total_raw_usd_negative() {
2298 let perp_json = r#"{
2299 "assetPositions": [],
2300 "crossMarginSummary": {
2301 "accountValue": "-50",
2302 "totalNtlPos": "0",
2303 "totalRawUsd": "-50",
2304 "totalMarginUsed": "0",
2305 "withdrawable": "0"
2306 },
2307 "withdrawable": "0"
2308 }"#;
2309 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2310
2311 let spot_json = r#"{
2312 "balances": [
2313 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2314 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2315 ]
2316 }"#;
2317 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2318
2319 let (balances, margins) =
2320 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2321
2322 assert!(margins.is_empty());
2323 assert_eq!(balances.len(), 2);
2324 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2325 assert_eq!(balances[0].total.as_decimal(), dec!(-50));
2326 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2327 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2328 }
2329
2330 #[rstest]
2331 fn test_parse_combined_deduplicates_usdc_when_perp_margin_used_non_zero() {
2332 let perp_json = r#"{
2333 "assetPositions": [],
2334 "crossMarginSummary": {
2335 "accountValue": "0",
2336 "totalNtlPos": "0",
2337 "totalRawUsd": "0",
2338 "totalMarginUsed": "25",
2339 "withdrawable": "0"
2340 },
2341 "withdrawable": "0"
2342 }"#;
2343 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2344
2345 let spot_json = r#"{
2346 "balances": [
2347 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2348 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2349 ]
2350 }"#;
2351 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2352
2353 let (balances, margins) =
2354 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2355
2356 assert_eq!(margins.len(), 1);
2357 assert_eq!(balances.len(), 2);
2358 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2359 assert_eq!(balances[0].total.as_decimal(), dec!(0));
2360 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2361 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2362 }
2363
2364 #[rstest]
2365 fn test_parse_combined_deduplicates_usdc_when_perp_withdrawable_non_zero() {
2366 let perp_json = r#"{
2367 "assetPositions": [],
2368 "crossMarginSummary": {
2369 "accountValue": "0",
2370 "totalNtlPos": "0",
2371 "totalRawUsd": "0",
2372 "totalMarginUsed": "0",
2373 "withdrawable": "50"
2374 },
2375 "withdrawable": "50"
2376 }"#;
2377 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2378
2379 let spot_json = r#"{
2380 "balances": [
2381 {"coin": "USDC", "token": 0, "total": "75", "hold": "0", "entryNtl": "0"},
2382 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
2383 ]
2384 }"#;
2385 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2386
2387 let (balances, margins) =
2388 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2389
2390 assert!(margins.is_empty());
2391 assert_eq!(balances.len(), 2);
2392 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2393 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2394 assert_eq!(balances[0].free.as_decimal(), dec!(50));
2395 assert_eq!(balances[1].currency.code.as_str(), "PURR");
2396 assert_eq!(balances[1].total.as_decimal(), dec!(10));
2397 }
2398
2399 #[rstest]
2400 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
2401 let perp_json = r#"{"assetPositions": []}"#;
2402 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2403
2404 let spot_json = r#"{
2405 "balances": [
2406 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2407 ]
2408 }"#;
2409 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2410
2411 let (balances, _) =
2412 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2413
2414 assert_eq!(balances.len(), 1);
2415 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2416 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2417 }
2418}