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: &str,
110 sz: &str,
111 time: u64,
112 start_position: &str,
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.as_bytes() {
127 h ^= b as u64;
128 h = h.wrapping_mul(0x0100_0000_01b3);
129 }
130
131 for &b in sz.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.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 abs_val = value.abs();
192 let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
193 let magnitude = float_val.log10().floor() as i32;
194
195 let shift = sig_figs as i32 - 1 - magnitude;
197 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
198
199 if shift >= 0 {
200 (value * factor).round() / factor
201 } else {
202 (value / factor).round() * factor
203 }
204}
205
206pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
208 let sig_fig_price = round_to_sig_figs(price, 5);
210 let scale = Decimal::from(10_u64.pow(decimals as u32));
212 (sig_fig_price * scale).floor() / scale
213}
214
215pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
217 let scale = Decimal::from(10_u64.pow(decimals as u32));
218 (qty * scale).floor() / scale
219}
220
221pub fn normalize_order(
223 price: Decimal,
224 qty: Decimal,
225 tick_size: Decimal,
226 step_size: Decimal,
227 min_notional: Decimal,
228 price_decimals: u8,
229 size_decimals: u8,
230) -> Result<(Decimal, Decimal), String> {
231 let normalized_price = normalize_price(price, price_decimals);
233 let normalized_qty = normalize_quantity(qty, size_decimals);
234
235 let final_price = round_down_to_tick(normalized_price, tick_size);
237 let final_qty = round_down_to_step(normalized_qty, step_size);
238
239 ensure_min_notional(final_price, final_qty, min_notional)?;
241
242 Ok((final_price, final_qty))
243}
244
245#[inline]
247pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
248 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
249 Ok(UnixNanos::from(value))
250}
251
252pub fn parse_outcome_symbol(symbol: &str) -> anyhow::Result<HyperliquidAssetId> {
262 let encoding = parse_outcome_symbol_encoding(symbol)?;
263 HyperliquidAssetId::from_outcome_encoding(encoding).with_context(|| {
264 format!(
265 "Invalid Hyperliquid outcome symbol '{symbol}': encoding must fit u32 and end with side digit 0 or 1"
266 )
267 })
268}
269
270fn parse_outcome_symbol_encoding(symbol: &str) -> anyhow::Result<u32> {
271 let encoding = symbol
272 .strip_prefix('#')
273 .or_else(|| symbol.strip_prefix('+'))
274 .with_context(|| {
275 format!(
276 "Invalid Hyperliquid outcome symbol '{symbol}': expected #<encoding> or +<encoding>"
277 )
278 })?;
279
280 if encoding.is_empty() {
281 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must not be empty");
282 }
283
284 if !encoding.bytes().all(|b| b.is_ascii_digit()) {
285 anyhow::bail!("Invalid Hyperliquid outcome symbol '{symbol}': encoding must be numeric");
286 }
287
288 encoding
289 .parse::<u32>()
290 .with_context(|| format!("Invalid Hyperliquid outcome symbol '{symbol}'"))
291}
292
293pub fn time_in_force_to_hyperliquid_tif(
299 tif: TimeInForce,
300 is_post_only: bool,
301) -> anyhow::Result<HyperliquidExecTif> {
302 match (tif, is_post_only) {
303 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
305 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
306 (TimeInForce::Fok, false) => {
307 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
308 }
309 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
310 }
311}
312
313fn determine_tpsl_type(
314 order_type: OrderType,
315 order_side: OrderSide,
316 trigger_price: Decimal,
317 current_price: Option<Decimal>,
318) -> HyperliquidExecTpSl {
319 match order_type {
320 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
322
323 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
325
326 _ => {
328 if let Some(current) = current_price {
329 match order_side {
330 OrderSide::Buy => {
331 if trigger_price > current {
333 HyperliquidExecTpSl::Sl
334 } else {
335 HyperliquidExecTpSl::Tp
336 }
337 }
338 OrderSide::Sell => {
339 if trigger_price < current {
341 HyperliquidExecTpSl::Sl
342 } else {
343 HyperliquidExecTpSl::Tp
344 }
345 }
346 _ => HyperliquidExecTpSl::Sl, }
348 } else {
349 HyperliquidExecTpSl::Sl
351 }
352 }
353 }
354}
355
356pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
362 let spec = bar_type.spec();
363 let step = spec.step.get();
364
365 anyhow::ensure!(
366 bar_type.aggregation_source() == AggregationSource::External,
367 "Only EXTERNAL aggregation is supported"
368 );
369
370 let interval = match spec.aggregation {
371 BarAggregation::Minute => match step {
372 1 => OneMinute,
373 3 => ThreeMinutes,
374 5 => FiveMinutes,
375 15 => FifteenMinutes,
376 30 => ThirtyMinutes,
377 _ => anyhow::bail!("Unsupported minute step: {step}"),
378 },
379 BarAggregation::Hour => match step {
380 1 => OneHour,
381 2 => TwoHours,
382 4 => FourHours,
383 8 => EightHours,
384 12 => TwelveHours,
385 _ => anyhow::bail!("Unsupported hour step: {step}"),
386 },
387 BarAggregation::Day => match step {
388 1 => OneDay,
389 3 => ThreeDays,
390 _ => anyhow::bail!("Unsupported day step: {step}"),
391 },
392 BarAggregation::Week if step == 1 => OneWeek,
393 BarAggregation::Month if step == 1 => OneMonth,
394 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
395 };
396
397 Ok(interval)
398}
399
400pub fn order_to_hyperliquid_request_with_asset(
407 order: &OrderAny,
408 asset: u32,
409 price_decimals: u8,
410 should_normalize_prices: bool,
411 slippage_bps: u32,
412) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
413 let is_buy = matches!(order.order_side(), OrderSide::Buy);
414 let reduce_only = order.is_reduce_only();
415 let order_side = order.order_side();
416 let order_type = order.order_type();
417
418 let price_decimal = if let Some(price) = order.price() {
421 let raw = price.as_decimal();
422
423 if should_normalize_prices {
424 normalize_price(raw, price_decimals).normalize()
425 } else {
426 raw.normalize()
427 }
428 } else if matches!(order_type, OrderType::Market) {
429 Decimal::ZERO
430 } else if matches!(
431 order_type,
432 OrderType::StopMarket | OrderType::MarketIfTouched
433 ) {
434 match order.trigger_price() {
435 Some(tp) => {
436 let base = tp.as_decimal().normalize();
437 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
438 let sig_rounded = round_to_sig_figs(derived, 5);
439 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
440 }
441 None => Decimal::ZERO,
442 }
443 } else {
444 anyhow::bail!("Limit orders require a price")
445 };
446
447 let size_decimal = order.quantity().as_decimal().normalize();
448
449 let kind = match order_type {
451 OrderType::Market => HyperliquidExecOrderKind::Limit {
452 limit: HyperliquidExecLimitParams {
453 tif: HyperliquidExecTif::Ioc,
454 },
455 },
456 OrderType::Limit => {
457 let tif =
458 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
459 HyperliquidExecOrderKind::Limit {
460 limit: HyperliquidExecLimitParams { tif },
461 }
462 }
463 OrderType::StopMarket => {
464 if let Some(trigger_price) = order.trigger_price() {
465 let raw = trigger_price.as_decimal();
466 let trigger_price_decimal = if should_normalize_prices {
467 normalize_price(raw, price_decimals).normalize()
468 } else {
469 raw.normalize()
470 };
471 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
472 HyperliquidExecOrderKind::Trigger {
473 trigger: HyperliquidExecTriggerParams {
474 is_market: true,
475 trigger_px: trigger_price_decimal,
476 tpsl,
477 },
478 }
479 } else {
480 anyhow::bail!("Stop market orders require a trigger price")
481 }
482 }
483 OrderType::StopLimit => {
484 if let Some(trigger_price) = order.trigger_price() {
485 let raw = trigger_price.as_decimal();
486 let trigger_price_decimal = if should_normalize_prices {
487 normalize_price(raw, price_decimals).normalize()
488 } else {
489 raw.normalize()
490 };
491 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
492 HyperliquidExecOrderKind::Trigger {
493 trigger: HyperliquidExecTriggerParams {
494 is_market: false,
495 trigger_px: trigger_price_decimal,
496 tpsl,
497 },
498 }
499 } else {
500 anyhow::bail!("Stop limit orders require a trigger price")
501 }
502 }
503 OrderType::MarketIfTouched => {
504 if let Some(trigger_price) = order.trigger_price() {
505 let raw = trigger_price.as_decimal();
506 let trigger_price_decimal = if should_normalize_prices {
507 normalize_price(raw, price_decimals).normalize()
508 } else {
509 raw.normalize()
510 };
511 HyperliquidExecOrderKind::Trigger {
512 trigger: HyperliquidExecTriggerParams {
513 is_market: true,
514 trigger_px: trigger_price_decimal,
515 tpsl: HyperliquidExecTpSl::Tp,
516 },
517 }
518 } else {
519 anyhow::bail!("Market-if-touched orders require a trigger price")
520 }
521 }
522 OrderType::LimitIfTouched => {
523 if let Some(trigger_price) = order.trigger_price() {
524 let raw = trigger_price.as_decimal();
525 let trigger_price_decimal = if should_normalize_prices {
526 normalize_price(raw, price_decimals).normalize()
527 } else {
528 raw.normalize()
529 };
530 HyperliquidExecOrderKind::Trigger {
531 trigger: HyperliquidExecTriggerParams {
532 is_market: false,
533 trigger_px: trigger_price_decimal,
534 tpsl: HyperliquidExecTpSl::Tp,
535 },
536 }
537 } else {
538 anyhow::bail!("Limit-if-touched orders require a trigger price")
539 }
540 }
541 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
542 };
543
544 let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
545
546 Ok(HyperliquidExecPlaceOrderRequest {
547 asset,
548 is_buy,
549 price: price_decimal,
550 size: size_decimal,
551 reduce_only,
552 kind,
553 cloid,
554 })
555}
556
557pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
559
560pub fn derive_market_order_price(
564 quote: &QuoteTick,
565 is_buy: bool,
566 price_decimals: u8,
567 slippage_bps: u32,
568) -> Decimal {
569 let base = if is_buy {
570 quote.ask_price.as_decimal()
571 } else {
572 quote.bid_price.as_decimal()
573 };
574 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
575 let sig_rounded = round_to_sig_figs(derived, 5);
576 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
577}
578
579pub fn derive_limit_from_trigger(
583 trigger_price: Decimal,
584 is_buy: bool,
585 slippage_bps: u32,
586) -> Decimal {
587 let slippage = Decimal::new(slippage_bps as i64, 4);
589 let price = if is_buy {
590 trigger_price * (Decimal::ONE + slippage)
591 } else {
592 trigger_price * (Decimal::ONE - slippage)
593 };
594
595 price.normalize()
597}
598
599pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
602 let scale = Decimal::from(10_u64.pow(decimals as u32));
603
604 if is_buy {
605 (price * scale).ceil() / scale
606 } else {
607 (price * scale).floor() / scale
608 }
609}
610
611pub fn client_order_id_to_cancel_request_with_asset(
613 client_order_id: &str,
614 asset: u32,
615) -> HyperliquidExecCancelByCloidRequest {
616 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
617 HyperliquidExecCancelByCloidRequest { asset, cloid }
618}
619
620pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
626 let HyperliquidExchangeResponse::Status { response, .. } = response else {
627 return None;
628 };
629 let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
630 match data {
631 HyperliquidExecResponseData::Order { data } => {
632 for status in &data.statuses {
633 if let HyperliquidExecOrderStatus::Error { error } = status {
634 return Some(error.clone());
635 }
636 }
637 None
638 }
639 HyperliquidExecResponseData::Cancel { data } => {
640 for status in &data.statuses {
641 if let HyperliquidExecCancelStatus::Error { error } = status {
642 return Some(error.clone());
643 }
644 }
645 None
646 }
647 HyperliquidExecResponseData::Modify { data } => {
648 for status in &data.statuses {
649 if let HyperliquidExecModifyStatus::Error { error } = status {
650 return Some(error.clone());
651 }
652 }
653 None
654 }
655 _ => None,
656 }
657}
658
659pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
665 let HyperliquidExchangeResponse::Status { response, .. } = response else {
666 return Vec::new();
667 };
668 let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
669 return Vec::new();
670 };
671
672 match data {
673 HyperliquidExecResponseData::Order { data } => data
674 .statuses
675 .into_iter()
676 .map(|s| match s {
677 HyperliquidExecOrderStatus::Error { error } => Some(error),
678 _ => None,
679 })
680 .collect(),
681 HyperliquidExecResponseData::Cancel { data } => data
682 .statuses
683 .into_iter()
684 .map(|s| match s {
685 HyperliquidExecCancelStatus::Error { error } => Some(error),
686 HyperliquidExecCancelStatus::Success(_) => None,
687 })
688 .collect(),
689 HyperliquidExecResponseData::Modify { data } => data
690 .statuses
691 .into_iter()
692 .map(|s| match s {
693 HyperliquidExecModifyStatus::Error { error } => Some(error),
694 HyperliquidExecModifyStatus::Success(_) => None,
695 })
696 .collect(),
697 _ => Vec::new(),
698 }
699}
700
701pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
703 match response {
704 HyperliquidExchangeResponse::Status { status, response } => {
705 if status == RESPONSE_STATUS_OK {
706 "Operation successful".to_string()
707 } else {
708 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
710 error_msg.to_string()
711 } else {
712 format!("Request failed with status: {status}")
713 }
714 }
715 }
716 HyperliquidExchangeResponse::Error { error } => error.clone(),
717 }
718}
719
720pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
726 trigger_px.is_some() && tpsl.is_some()
727}
728
729pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
735 match (is_market, tpsl) {
736 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
737 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
738 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
739 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
740 }
741}
742
743pub fn parse_order_status_with_trigger(
749 status: HyperliquidOrderStatus,
750 trigger_activated: Option<bool>,
751) -> (OrderStatus, Option<String>) {
752 let base_status = OrderStatus::from(status);
753
754 if let Some(activated) = trigger_activated {
756 let trigger_status = if activated {
757 Some("activated".to_string())
758 } else {
759 Some("pending".to_string())
760 };
761 (base_status, trigger_status)
762 } else {
763 (base_status, None)
764 }
765}
766
767pub fn format_trailing_stop_info(
769 offset: &str,
770 offset_type: TrailingOffsetType,
771 callback_price: Option<&str>,
772) -> String {
773 let offset_desc = offset_type.format_offset(offset);
774
775 if let Some(callback) = callback_price {
776 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
777 } else {
778 format!("Trailing stop: {offset_desc} offset")
779 }
780}
781
782pub fn validate_conditional_order_params(
788 trigger_px: Option<&str>,
789 tpsl: Option<&HyperliquidTpSl>,
790 is_market: Option<bool>,
791) -> anyhow::Result<()> {
792 if trigger_px.is_none() {
793 anyhow::bail!("Conditional order missing trigger price");
794 }
795
796 if tpsl.is_none() {
797 anyhow::bail!("Conditional order missing tpsl indicator");
798 }
799
800 if is_market.is_none() {
803 anyhow::bail!("Conditional order missing is_market flag");
804 }
805
806 Ok(())
807}
808
809pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
815 Decimal::from_str_exact(trigger_px)
816 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
817}
818
819pub fn parse_account_balances_and_margins(
830 state: &ClearinghouseState,
831) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
832 let mut balances = Vec::new();
833 let mut margins = Vec::new();
834
835 let currency = Currency::USDC();
836
837 let cross_margin_summary = match &state.cross_margin_summary {
838 Some(summary) => summary,
839 None => return Ok((balances, margins)),
840 };
841
842 let mut total_value = cross_margin_summary.total_raw_usd.max(Decimal::ZERO);
843 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
844
845 if free_value > total_value {
848 total_value = free_value;
849 }
850
851 balances.push(AccountBalance::from_total_and_free(
852 total_value,
853 free_value,
854 currency,
855 )?);
856
857 let margin_used = cross_margin_summary.total_margin_used;
858
859 if margin_used > Decimal::ZERO {
860 let initial_margin = Money::from_decimal(margin_used, currency)?;
863 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
864 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
865 }
866
867 Ok((balances, margins))
868}
869
870pub fn parse_combined_account_balances_and_margins(
881 perp_state: &ClearinghouseState,
882 spot_state: &SpotClearinghouseState,
883) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
884 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
885
886 let has_perp_summary = perp_state.cross_margin_summary.is_some();
887 let spot_balances = parse_spot_account_balances(spot_state)?;
888
889 for balance in spot_balances {
890 let is_usdc = balance.currency.code.as_str() == "USDC";
891 if has_perp_summary && is_usdc {
892 continue;
893 }
894 balances.push(balance);
895 }
896
897 Ok((balances, margins))
898}
899
900pub fn parse_spot_account_balances(
910 state: &SpotClearinghouseState,
911) -> anyhow::Result<Vec<AccountBalance>> {
912 let mut balances = Vec::with_capacity(state.balances.len());
913
914 for balance in &state.balances {
915 if balance.total.is_zero() {
916 continue;
917 }
918
919 let currency = crate::http::parse::get_currency(balance.coin.as_str());
920
921 balances.push(AccountBalance::from_total_and_locked(
925 balance.total,
926 balance.hold,
927 currency,
928 )?);
929 }
930
931 Ok(balances)
932}
933
934pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExecGrouping {
946 if orders.len() >= 2 {
947 let entry = &orders[0];
948 let children = &orders[1..];
949 let entry_id = entry.client_order_id();
950 let entry_is_oto =
951 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
952 let children_are_linked = children.iter().all(|o| {
953 o.contingency_type() == Some(ContingencyType::Oco)
954 && o.is_reduce_only()
955 && o.parent_order_id() == Some(entry_id)
956 });
957
958 if entry_is_oto && children_are_linked {
959 return HyperliquidExecGrouping::NormalTpsl;
960 }
961 }
962
963 let all_oco_linked = orders.len() >= 2
964 && orders
965 .iter()
966 .all(|o| o.contingency_type() == Some(ContingencyType::Oco) && o.is_reduce_only())
967 && orders.iter().all(|o| {
968 o.linked_order_ids().is_some_and(|ids| {
969 ids.iter()
970 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
971 })
972 });
973
974 if all_oco_linked {
975 HyperliquidExecGrouping::PositionTpsl
976 } else {
977 HyperliquidExecGrouping::Na
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use std::str::FromStr;
984
985 use nautilus_model::{
986 enums::{OrderSide, TimeInForce, TriggerType},
987 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
988 orders::{OrderAny, StopMarketOrder},
989 types::{Price, Quantity},
990 };
991 use rstest::rstest;
992 use rust_decimal::Decimal;
993 use rust_decimal_macros::dec;
994 use serde::{Deserialize, Serialize};
995
996 use super::*;
997
998 #[derive(Serialize, Deserialize)]
999 struct TestStruct {
1000 #[serde(
1001 serialize_with = "serialize_decimal_as_str",
1002 deserialize_with = "deserialize_decimal_from_str"
1003 )]
1004 value: Decimal,
1005 #[serde(
1006 serialize_with = "serialize_optional_decimal_as_str",
1007 deserialize_with = "deserialize_optional_decimal_from_str"
1008 )]
1009 optional_value: Option<Decimal>,
1010 }
1011
1012 #[rstest]
1013 #[case("#10", 100_000_010, 1, 0)]
1014 #[case("+10", 100_000_010, 1, 0)]
1015 #[case("#31", 100_000_031, 3, 1)]
1016 #[case("+31", 100_000_031, 3, 1)]
1017 fn test_parse_outcome_symbol(
1018 #[case] symbol: &str,
1019 #[case] raw_asset_id: u32,
1020 #[case] outcome: u32,
1021 #[case] side: u8,
1022 ) {
1023 let asset_id = parse_outcome_symbol(symbol).unwrap();
1024 assert_eq!(asset_id.to_raw(), raw_asset_id);
1025 assert_eq!(asset_id.outcome_index(), Some(outcome));
1026 assert_eq!(asset_id.outcome_side(), Some(side));
1027 }
1028
1029 #[rstest]
1030 #[case("10", "expected #<encoding> or +<encoding>")]
1031 #[case("#", "encoding must not be empty")]
1032 #[case("#1a", "encoding must be numeric")]
1033 #[case("#12", "side digit 0 or 1")]
1034 #[case("#4294967295", "fit u32")]
1035 fn test_parse_outcome_symbol_rejects_invalid_values(
1036 #[case] symbol: &str,
1037 #[case] expected_error: &str,
1038 ) {
1039 let err = parse_outcome_symbol(symbol).unwrap_err();
1040 assert!(
1041 err.to_string().contains(expected_error),
1042 "expected error to contain '{expected_error}', received '{err}'",
1043 );
1044 }
1045
1046 #[rstest]
1047 fn test_decimal_serialization_roundtrip() {
1048 let original = TestStruct {
1049 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
1050 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
1051 };
1052
1053 let json = serde_json::to_string(&original).unwrap();
1054 println!("Serialized: {json}");
1055
1056 assert!(json.contains("\"123.45678901234567890123456789\""));
1058 assert!(json.contains("\"0.000000001\""));
1059
1060 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
1061 assert_eq!(original.value, deserialized.value);
1062 assert_eq!(original.optional_value, deserialized.optional_value);
1063 }
1064
1065 #[rstest]
1066 fn test_decimal_precision_preservation() {
1067 let test_cases = [
1068 "0",
1069 "1",
1070 "0.1",
1071 "0.01",
1072 "0.001",
1073 "123.456789012345678901234567890",
1074 "999999999999999999.999999999999999999",
1075 ];
1076
1077 for case in test_cases {
1078 let decimal = Decimal::from_str(case).unwrap();
1079 let test_struct = TestStruct {
1080 value: decimal,
1081 optional_value: Some(decimal),
1082 };
1083
1084 let json = serde_json::to_string(&test_struct).unwrap();
1085 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1086
1087 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1088 assert_eq!(
1089 Some(decimal),
1090 parsed.optional_value,
1091 "Failed for case: {case}"
1092 );
1093 }
1094 }
1095
1096 #[rstest]
1097 fn test_optional_none_handling() {
1098 let test_struct = TestStruct {
1099 value: Decimal::from_str("42.0").unwrap(),
1100 optional_value: None,
1101 };
1102
1103 let json = serde_json::to_string(&test_struct).unwrap();
1104 assert!(json.contains("null"));
1105
1106 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1107 assert_eq!(test_struct.value, parsed.value);
1108 assert_eq!(None, parsed.optional_value);
1109 }
1110
1111 #[rstest]
1112 fn test_round_down_to_tick() {
1113 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1114 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1115 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1116
1117 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1119 }
1120
1121 #[rstest]
1122 fn test_round_down_to_step() {
1123 assert_eq!(
1124 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1125 dec!(0.1234)
1126 );
1127 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1128 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1129
1130 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1132 }
1133
1134 #[rstest]
1135 fn test_min_notional_validation() {
1136 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1138 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1139
1140 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1142 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1143
1144 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1146 }
1147
1148 #[rstest]
1149 fn test_round_to_sig_figs() {
1150 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1152 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1153 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1154
1155 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1157 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1158 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1159
1160 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1162 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1166 }
1167
1168 #[rstest]
1169 fn test_normalize_price() {
1170 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1172 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));
1178 }
1179
1180 #[rstest]
1181 fn test_normalize_quantity() {
1182 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1183 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1184 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1185 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1186 }
1187
1188 #[rstest]
1189 fn test_normalize_order_complete() {
1190 let result = normalize_order(
1191 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1199
1200 assert!(result.is_ok());
1201 let (price, qty) = result.unwrap();
1202 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1205
1206 #[rstest]
1207 fn test_normalize_order_min_notional_fail() {
1208 let result = normalize_order(
1209 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1217
1218 assert!(result.is_err());
1219 assert!(result.unwrap_err().contains("Notional value"));
1220 }
1221
1222 #[rstest]
1223 fn test_edge_cases() {
1224 assert_eq!(
1226 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1227 dec!(0.000001)
1228 );
1229
1230 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1232
1233 assert_eq!(
1235 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1236 dec!(100.00)
1237 );
1238 }
1239
1240 #[rstest]
1241 fn test_is_conditional_order_data() {
1242 assert!(is_conditional_order_data(
1244 Some("50000.0"),
1245 Some(&HyperliquidTpSl::Sl)
1246 ));
1247
1248 assert!(!is_conditional_order_data(Some("50000.0"), None));
1250
1251 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1253
1254 assert!(!is_conditional_order_data(None, None));
1256 }
1257
1258 #[rstest]
1259 fn test_parse_trigger_order_type() {
1260 assert_eq!(
1262 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1263 OrderType::StopMarket
1264 );
1265
1266 assert_eq!(
1268 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1269 OrderType::StopLimit
1270 );
1271
1272 assert_eq!(
1274 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1275 OrderType::MarketIfTouched
1276 );
1277
1278 assert_eq!(
1280 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1281 OrderType::LimitIfTouched
1282 );
1283 }
1284
1285 #[rstest]
1286 fn test_parse_order_status_with_trigger() {
1287 let (status, trigger_status) =
1289 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1290 assert_eq!(status, OrderStatus::Accepted);
1291 assert_eq!(trigger_status, Some("activated".to_string()));
1292
1293 let (status, trigger_status) =
1295 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1296 assert_eq!(status, OrderStatus::Accepted);
1297 assert_eq!(trigger_status, Some("pending".to_string()));
1298
1299 let (status, trigger_status) =
1301 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1302 assert_eq!(status, OrderStatus::Accepted);
1303 assert_eq!(trigger_status, None);
1304 }
1305
1306 #[rstest]
1307 fn test_format_trailing_stop_info() {
1308 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1310 assert!(info.contains("100.0"));
1311 assert!(info.contains("callback at 50000.0"));
1312
1313 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1315 assert!(info.contains("5.0%"));
1316 assert!(info.contains("Trailing stop"));
1317
1318 let info =
1320 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1321 assert!(info.contains("250 bps"));
1322 assert!(info.contains("49000.0"));
1323 }
1324
1325 #[rstest]
1326 fn test_parse_trigger_price() {
1327 let result = parse_trigger_price("50000.0");
1329 assert!(result.is_ok());
1330 assert_eq!(result.unwrap(), dec!(50000.0));
1331
1332 let result = parse_trigger_price("49000");
1334 assert!(result.is_ok());
1335 assert_eq!(result.unwrap(), dec!(49000));
1336
1337 let result = parse_trigger_price("invalid");
1339 assert!(result.is_err());
1340
1341 let result = parse_trigger_price("");
1343 assert!(result.is_err());
1344 }
1345
1346 #[rstest]
1347 #[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(
1358 #[case] trigger_price: Decimal,
1359 #[case] is_buy: bool,
1360 #[case] expected: Decimal,
1361 ) {
1362 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1363 assert_eq!(result, expected);
1364
1365 if is_buy {
1367 assert!(result >= trigger_price);
1368 } else {
1369 assert!(result <= trigger_price);
1370 }
1371 }
1372
1373 #[rstest]
1374 #[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))]
1384 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1385 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1387 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1388 fn test_clamp_price_to_precision(
1389 #[case] price: Decimal,
1390 #[case] decimals: u8,
1391 #[case] is_buy: bool,
1392 #[case] expected: Decimal,
1393 ) {
1394 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1395 }
1396
1397 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1398 OrderAny::StopMarket(StopMarketOrder::new(
1399 TraderId::from("TESTER-001"),
1400 StrategyId::from("S-001"),
1401 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1402 ClientOrderId::from("O-001"),
1403 side,
1404 Quantity::from(1),
1405 Price::from(trigger_price),
1406 TriggerType::LastPrice,
1407 TimeInForce::Gtc,
1408 None,
1409 false,
1410 false,
1411 None,
1412 None,
1413 None,
1414 None,
1415 None,
1416 None,
1417 None,
1418 None,
1419 None,
1420 None,
1421 None,
1422 Default::default(),
1423 Default::default(),
1424 ))
1425 }
1426
1427 #[rstest]
1428 #[case(OrderSide::Sell, "2470.00", 2)]
1430 #[case(OrderSide::Buy, "2470.00", 2)]
1431 #[case(OrderSide::Sell, "104567.3", 1)]
1433 #[case(OrderSide::Buy, "104567.3", 1)]
1434 #[case(OrderSide::Sell, "0.50", 4)]
1436 #[case(OrderSide::Buy, "0.50", 4)]
1437 #[case(OrderSide::Sell, "2470.00", 1)]
1441 #[case(OrderSide::Buy, "2470.00", 1)]
1442 #[case(OrderSide::Sell, "2470.00", 0)]
1446 #[case(OrderSide::Buy, "2470.00", 0)]
1447 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1448 #[case] side: OrderSide,
1449 #[case] trigger_str: &str,
1450 #[case] price_decimals: u8,
1451 ) {
1452 let order = stop_market_order(side, trigger_str);
1453 let request = order_to_hyperliquid_request_with_asset(
1454 &order,
1455 0,
1456 price_decimals,
1457 true,
1458 DEFAULT_MARKET_SLIPPAGE_BPS,
1459 )
1460 .unwrap();
1461 let trigger = Decimal::from_str(trigger_str).unwrap();
1462 let is_buy = matches!(side, OrderSide::Buy);
1463
1464 if is_buy {
1466 assert!(
1467 request.price >= trigger,
1468 "BUY limit {} must be >= trigger {trigger}",
1469 request.price,
1470 );
1471 assert!(request.is_buy);
1472 } else {
1473 assert!(
1474 request.price <= trigger,
1475 "SELL limit {} must be <= trigger {trigger}",
1476 request.price,
1477 );
1478 assert!(!request.is_buy);
1479 }
1480
1481 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1483 let sig_rounded = round_to_sig_figs(derived, 5);
1484 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1485 assert_eq!(request.price, expected);
1486
1487 let price_str = request.price.to_string();
1489 let actual_decimals = price_str
1490 .find('.')
1491 .map_or(0, |dot| price_str.len() - dot - 1);
1492 assert!(
1493 actual_decimals <= price_decimals as usize,
1494 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1495 );
1496
1497 if price_str.contains('.') {
1499 assert!(
1500 !price_str.ends_with('0'),
1501 "Price {price_str} has decimal trailing zeros",
1502 );
1503 }
1504
1505 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1506 assert_eq!(
1507 request.kind,
1508 HyperliquidExecOrderKind::Trigger {
1509 trigger: HyperliquidExecTriggerParams {
1510 is_market: true,
1511 trigger_px: expected_trigger,
1512 tpsl: HyperliquidExecTpSl::Sl,
1513 },
1514 },
1515 );
1516 }
1517
1518 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1519 HyperliquidExchangeResponse::Status {
1520 status: "ok".to_string(),
1521 response: inner,
1522 }
1523 }
1524
1525 #[rstest]
1526 fn test_extract_inner_error_order_with_error() {
1527 let response = ok_response(serde_json::json!({
1528 "type": "order",
1529 "data": {"statuses": [{"error": "Order has invalid price."}]}
1530 }));
1531 assert_eq!(
1532 extract_inner_error(&response),
1533 Some("Order has invalid price.".to_string()),
1534 );
1535 }
1536
1537 #[rstest]
1538 fn test_extract_inner_error_order_resting() {
1539 let response = ok_response(serde_json::json!({
1540 "type": "order",
1541 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1542 }));
1543 assert_eq!(extract_inner_error(&response), None);
1544 }
1545
1546 #[rstest]
1547 fn test_extract_inner_error_order_filled() {
1548 let response = ok_response(serde_json::json!({
1549 "type": "order",
1550 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1551 }));
1552 assert_eq!(extract_inner_error(&response), None);
1553 }
1554
1555 #[rstest]
1556 fn test_extract_inner_error_cancel_error() {
1557 let response = ok_response(serde_json::json!({
1558 "type": "cancel",
1559 "data": {"statuses": [{"error": "Order not found"}]}
1560 }));
1561 assert_eq!(
1562 extract_inner_error(&response),
1563 Some("Order not found".to_string()),
1564 );
1565 }
1566
1567 #[rstest]
1568 fn test_extract_inner_error_cancel_success() {
1569 let response = ok_response(serde_json::json!({
1570 "type": "cancel",
1571 "data": {"statuses": ["success"]}
1572 }));
1573 assert_eq!(extract_inner_error(&response), None);
1574 }
1575
1576 #[rstest]
1577 fn test_extract_inner_error_modify_error() {
1578 let response = ok_response(serde_json::json!({
1579 "type": "modify",
1580 "data": {"statuses": [{"error": "Invalid modify"}]}
1581 }));
1582 assert_eq!(
1583 extract_inner_error(&response),
1584 Some("Invalid modify".to_string()),
1585 );
1586 }
1587
1588 #[rstest]
1589 fn test_extract_inner_error_modify_success() {
1590 let response = ok_response(serde_json::json!({
1591 "type": "modify",
1592 "data": {"statuses": ["success"]}
1593 }));
1594 assert_eq!(extract_inner_error(&response), None);
1595 }
1596
1597 #[rstest]
1598 fn test_extract_inner_error_non_status_response() {
1599 let response = HyperliquidExchangeResponse::Error {
1600 error: "top-level error".to_string(),
1601 };
1602 assert_eq!(extract_inner_error(&response), None);
1603 }
1604
1605 #[rstest]
1606 fn test_extract_inner_error_unparsable_response() {
1607 let response = ok_response(serde_json::json!({"unknown": "data"}));
1608 assert_eq!(extract_inner_error(&response), None);
1609 }
1610
1611 #[rstest]
1612 fn test_extract_inner_error_returns_first_error_in_batch() {
1613 let response = ok_response(serde_json::json!({
1614 "type": "order",
1615 "data": {"statuses": [
1616 {"resting": {"oid": 1}},
1617 {"error": "Second failed"},
1618 {"error": "Third failed"},
1619 ]}
1620 }));
1621 assert_eq!(
1622 extract_inner_error(&response),
1623 Some("Second failed".to_string()),
1624 );
1625 }
1626
1627 #[rstest]
1628 fn test_extract_inner_errors_mixed_batch() {
1629 let response = ok_response(serde_json::json!({
1630 "type": "order",
1631 "data": {"statuses": [
1632 {"resting": {"oid": 1}},
1633 {"error": "Failed order"},
1634 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1635 ]}
1636 }));
1637 let errors = extract_inner_errors(&response);
1638 assert_eq!(errors.len(), 3);
1639 assert_eq!(errors[0], None);
1640 assert_eq!(errors[1], Some("Failed order".to_string()));
1641 assert_eq!(errors[2], None);
1642 }
1643
1644 #[rstest]
1645 fn test_extract_inner_errors_all_success() {
1646 let response = ok_response(serde_json::json!({
1647 "type": "order",
1648 "data": {"statuses": [
1649 {"resting": {"oid": 1}},
1650 {"resting": {"oid": 2}},
1651 ]}
1652 }));
1653 let errors = extract_inner_errors(&response);
1654 assert_eq!(errors.len(), 2);
1655 assert!(errors.iter().all(|e| e.is_none()));
1656 }
1657
1658 #[rstest]
1659 fn test_extract_inner_errors_cancel_success() {
1660 let response = ok_response(serde_json::json!({
1661 "type": "cancel",
1662 "data": {"statuses": ["success"]}
1663 }));
1664 let errors = extract_inner_errors(&response);
1665 assert_eq!(errors.len(), 1);
1666 assert!(errors[0].is_none());
1667 }
1668
1669 #[rstest]
1670 fn test_extract_inner_errors_cancel_mixed() {
1671 let response = ok_response(serde_json::json!({
1672 "type": "cancel",
1673 "data": {"statuses": [
1674 "success",
1675 {"error": "Order was never placed, already canceled, or filled."},
1676 "success",
1677 ]}
1678 }));
1679 let errors = extract_inner_errors(&response);
1680 assert_eq!(errors.len(), 3);
1681 assert_eq!(errors[0], None);
1682 assert_eq!(
1683 errors[1],
1684 Some("Order was never placed, already canceled, or filled.".to_string())
1685 );
1686 assert_eq!(errors[2], None);
1687 }
1688
1689 #[rstest]
1690 fn test_extract_inner_errors_modify_mixed() {
1691 let response = ok_response(serde_json::json!({
1692 "type": "modify",
1693 "data": {"statuses": [
1694 "success",
1695 {"error": "Order does not exist"},
1696 ]}
1697 }));
1698 let errors = extract_inner_errors(&response);
1699 assert_eq!(errors.len(), 2);
1700 assert_eq!(errors[0], None);
1701 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1702 }
1703
1704 #[rstest]
1705 fn test_extract_inner_errors_unparsable() {
1706 let response = ok_response(serde_json::json!({"foo": "bar"}));
1707 let errors = extract_inner_errors(&response);
1708 assert!(errors.is_empty());
1709 }
1710
1711 fn count_sig_figs(s: &str) -> usize {
1712 let s = s.trim_start_matches('-');
1713 if s.contains('.') {
1714 let digits: String = s.replace('.', "");
1716 digits.trim_start_matches('0').len()
1717 } else {
1718 let s = s.trim_start_matches('0');
1720 s.trim_end_matches('0').len()
1721 }
1722 }
1723
1724 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1725 QuoteTick::new(
1726 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1727 Price::from(bid),
1728 Price::from(ask),
1729 Quantity::from("1"),
1730 Quantity::from("1"),
1731 Default::default(),
1732 Default::default(),
1733 )
1734 }
1735
1736 #[rstest]
1737 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1743 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1745 #[case("104500.0", "104567.3", true, 1, "105090")]
1749 #[case("104500.0", "104567.3", false, 1, "103980")]
1751 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1755 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1757 #[case("49900", "50000", true, 0, "50250")]
1761 #[case("49900", "50000", false, 0, "49650")]
1763 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1767 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1769 fn test_derive_market_order_price(
1770 #[case] bid: &str,
1771 #[case] ask: &str,
1772 #[case] is_buy: bool,
1773 #[case] price_decimals: u8,
1774 #[case] expected: &str,
1775 ) {
1776 let quote = make_quote(bid, ask);
1777 let result =
1778 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1779 let expected_dec = Decimal::from_str(expected).unwrap();
1780 assert_eq!(result, expected_dec);
1781
1782 let base = if is_buy {
1784 quote.ask_price.as_decimal()
1785 } else {
1786 quote.bid_price.as_decimal()
1787 };
1788 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1789 let sig_rounded = round_to_sig_figs(derived, 5);
1790 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1791 assert_eq!(result, pipeline);
1792
1793 let s = result.to_string();
1795 if s.contains('.') {
1796 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
1797 }
1798
1799 let sig_count = count_sig_figs(&s);
1801 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
1802
1803 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
1805 assert!(
1806 actual_decimals <= price_decimals as usize,
1807 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
1808 );
1809 }
1810
1811 #[rstest]
1812 #[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(
1820 #[case] slippage_bps: u32,
1821 #[case] trigger: Decimal,
1822 #[case] is_buy: bool,
1823 #[case] expected: Decimal,
1824 ) {
1825 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
1826 assert_eq!(result, expected);
1827 }
1828
1829 #[rstest]
1830 fn test_derive_market_order_price_respects_slippage_override() {
1831 let quote = make_quote("100.00", "100.10");
1832 let tight = derive_market_order_price("e, true, 2, 50);
1833 let wide = derive_market_order_price("e, true, 2, 800);
1834 assert_eq!(tight, dec!(100.6));
1835 assert_eq!(wide, dec!(108.11));
1836 assert!(wide > tight);
1837 }
1838
1839 #[rstest]
1843 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
1844 let json = r#"{
1845 "assetPositions": [],
1846 "crossMarginSummary": {
1847 "accountValue": "150",
1848 "totalNtlPos": "0",
1849 "totalRawUsd": "100",
1850 "totalMarginUsed": "20",
1851 "withdrawable": "120"
1852 },
1853 "withdrawable": "80",
1854 "time": 1700000000000
1855 }"#;
1856
1857 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1858 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1859
1860 assert_eq!(balances.len(), 1);
1861 let balance = &balances[0];
1862 assert_eq!(balance.total.as_decimal(), dec!(100));
1865 assert_eq!(balance.free.as_decimal(), dec!(80));
1866 assert_eq!(balance.locked.as_decimal(), dec!(20));
1867
1868 assert_eq!(margins.len(), 1);
1869 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
1870 }
1871
1872 #[rstest]
1873 fn test_parse_account_balances_bumps_total_when_withdrawable_exceeds() {
1874 let json = r#"{
1875 "assetPositions": [],
1876 "crossMarginSummary": {
1877 "accountValue": "100",
1878 "totalNtlPos": "0",
1879 "totalRawUsd": "100",
1880 "totalMarginUsed": "0",
1881 "withdrawable": "100"
1882 },
1883 "withdrawable": "150",
1884 "time": 1700000000000
1885 }"#;
1886
1887 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1888 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
1889
1890 assert_eq!(balances.len(), 1);
1891 let balance = &balances[0];
1892 assert_eq!(balance.total.as_decimal(), dec!(150));
1893 assert_eq!(balance.free.as_decimal(), dec!(150));
1894 assert_eq!(balance.locked.as_decimal(), dec!(0));
1895 }
1896
1897 #[rstest]
1898 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
1899 let json = r#"{
1900 "assetPositions": [],
1901 "withdrawable": "100",
1902 "time": 1700000000000
1903 }"#;
1904
1905 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1906 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1907 assert!(balances.is_empty());
1908 assert!(margins.is_empty());
1909 }
1910
1911 #[rstest]
1912 fn test_parse_spot_account_balances_emits_one_per_token() {
1913 let json = r#"{
1914 "balances": [
1915 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
1916 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
1917 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
1918 ]
1919 }"#;
1920
1921 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1922 let balances = parse_spot_account_balances(&state).unwrap();
1923
1924 assert_eq!(balances.len(), 2);
1925
1926 let usdc = &balances[0];
1927 assert_eq!(usdc.currency.code.as_str(), "USDC");
1928 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
1929 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
1930 assert_eq!(usdc.locked.as_decimal(), dec!(10));
1931
1932 let purr = &balances[1];
1933 assert_eq!(purr.currency.code.as_str(), "PURR");
1934 assert_eq!(purr.total.as_decimal(), dec!(50));
1935 assert_eq!(purr.free.as_decimal(), dec!(50));
1936 }
1937
1938 #[rstest]
1939 fn test_parse_spot_account_balances_clamps_hold_to_total() {
1940 let json = r#"{
1941 "balances": [
1942 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
1943 ]
1944 }"#;
1945
1946 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1947 let balances = parse_spot_account_balances(&state).unwrap();
1948
1949 assert_eq!(balances.len(), 1);
1950 let hype = &balances[0];
1951 assert_eq!(hype.total.as_decimal(), dec!(5));
1952 assert_eq!(hype.free.as_decimal(), dec!(0));
1953 assert_eq!(hype.locked.as_decimal(), dec!(5));
1954 }
1955
1956 #[rstest]
1957 fn test_parse_spot_account_balances_empty() {
1958 let state = SpotClearinghouseState::default();
1959 let balances = parse_spot_account_balances(&state).unwrap();
1960 assert!(balances.is_empty());
1961 }
1962
1963 #[rstest]
1964 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
1965 let perp_json = r#"{
1966 "assetPositions": [],
1967 "crossMarginSummary": {
1968 "accountValue": "500",
1969 "totalNtlPos": "0",
1970 "totalRawUsd": "500",
1971 "totalMarginUsed": "0",
1972 "withdrawable": "500"
1973 },
1974 "withdrawable": "500"
1975 }"#;
1976 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
1977
1978 let spot_json = r#"{
1979 "balances": [
1980 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
1981 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
1982 ]
1983 }"#;
1984 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
1985
1986 let (balances, margins) =
1987 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
1988
1989 assert!(margins.is_empty());
1990 assert_eq!(balances.len(), 2);
1991 assert_eq!(balances[0].currency.code.as_str(), "USDC");
1992 assert_eq!(balances[0].total.as_decimal(), dec!(500));
1993 assert_eq!(balances[1].currency.code.as_str(), "PURR");
1994 assert_eq!(balances[1].total.as_decimal(), dec!(10));
1995 }
1996
1997 #[rstest]
1998 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
1999 let perp_json = r#"{"assetPositions": []}"#;
2000 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
2001
2002 let spot_json = r#"{
2003 "balances": [
2004 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
2005 ]
2006 }"#;
2007 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
2008
2009 let (balances, _) =
2010 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
2011
2012 assert_eq!(balances.len(), 1);
2013 assert_eq!(balances[0].currency.code.as_str(), "USDC");
2014 assert_eq!(balances[0].total.as_decimal(), dec!(50));
2015 }
2016}