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::enums::{
82 HyperliquidBarInterval::{self, *},
83 HyperliquidOrderStatus, HyperliquidTpSl,
84 },
85 http::models::{
86 ClearinghouseState, Cloid, HyperliquidExchangeResponse,
87 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelStatus, HyperliquidExecGrouping,
88 HyperliquidExecLimitParams, HyperliquidExecModifyStatus, HyperliquidExecOrderKind,
89 HyperliquidExecOrderStatus, HyperliquidExecPlaceOrderRequest, HyperliquidExecResponseData,
90 HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
91 SpotClearinghouseState,
92 },
93 websocket::messages::TrailingOffsetType,
94};
95
96pub fn make_fill_trade_id(
104 hash: &str,
105 oid: u64,
106 px: &str,
107 sz: &str,
108 time: u64,
109 start_position: &str,
110) -> TradeId {
111 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
113 for &b in hash.as_bytes() {
114 h ^= b as u64;
115 h = h.wrapping_mul(0x0100_0000_01b3);
116 }
117
118 for b in oid.to_le_bytes() {
119 h ^= b as u64;
120 h = h.wrapping_mul(0x0100_0000_01b3);
121 }
122
123 for &b in px.as_bytes() {
124 h ^= b as u64;
125 h = h.wrapping_mul(0x0100_0000_01b3);
126 }
127
128 for &b in sz.as_bytes() {
129 h ^= b as u64;
130 h = h.wrapping_mul(0x0100_0000_01b3);
131 }
132
133 for b in time.to_le_bytes() {
134 h ^= b as u64;
135 h = h.wrapping_mul(0x0100_0000_01b3);
136 }
137
138 for &b in start_position.as_bytes() {
139 h ^= b as u64;
140 h = h.wrapping_mul(0x0100_0000_01b3);
141 }
142 TradeId::new(format!("{h:016x}-{oid:016x}"))
143}
144
145#[inline]
147pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
148 if tick_size.is_zero() {
149 return price;
150 }
151 (price / tick_size).floor() * tick_size
152}
153
154#[inline]
156pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
157 if step_size.is_zero() {
158 return qty;
159 }
160 (qty / step_size).floor() * step_size
161}
162
163#[inline]
165pub fn ensure_min_notional(
166 price: Decimal,
167 qty: Decimal,
168 min_notional: Decimal,
169) -> Result<(), String> {
170 let notional = price * qty;
171 if notional < min_notional {
172 Err(format!(
173 "Notional value {notional} is less than minimum required {min_notional}"
174 ))
175 } else {
176 Ok(())
177 }
178}
179
180pub fn round_to_sig_figs(value: Decimal, sig_figs: u32) -> Decimal {
183 if value.is_zero() {
184 return Decimal::ZERO;
185 }
186
187 let abs_val = value.abs();
189 let float_val: f64 = abs_val.to_string().parse().unwrap_or(0.0);
190 let magnitude = float_val.log10().floor() as i32;
191
192 let shift = sig_figs as i32 - 1 - magnitude;
194 let factor = Decimal::from(10_i64.pow(shift.unsigned_abs()));
195
196 if shift >= 0 {
197 (value * factor).round() / factor
198 } else {
199 (value / factor).round() * factor
200 }
201}
202
203pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
205 let sig_fig_price = round_to_sig_figs(price, 5);
207 let scale = Decimal::from(10_u64.pow(decimals as u32));
209 (sig_fig_price * scale).floor() / scale
210}
211
212pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
214 let scale = Decimal::from(10_u64.pow(decimals as u32));
215 (qty * scale).floor() / scale
216}
217
218pub fn normalize_order(
220 price: Decimal,
221 qty: Decimal,
222 tick_size: Decimal,
223 step_size: Decimal,
224 min_notional: Decimal,
225 price_decimals: u8,
226 size_decimals: u8,
227) -> Result<(Decimal, Decimal), String> {
228 let normalized_price = normalize_price(price, price_decimals);
230 let normalized_qty = normalize_quantity(qty, size_decimals);
231
232 let final_price = round_down_to_tick(normalized_price, tick_size);
234 let final_qty = round_down_to_step(normalized_qty, step_size);
235
236 ensure_min_notional(final_price, final_qty, min_notional)?;
238
239 Ok((final_price, final_qty))
240}
241
242#[inline]
244pub fn millis_to_nanos(millis: u64) -> anyhow::Result<UnixNanos> {
245 let value = nautilus_core::datetime::millis_to_nanos(millis as f64)?;
246 Ok(UnixNanos::from(value))
247}
248
249pub fn time_in_force_to_hyperliquid_tif(
255 tif: TimeInForce,
256 is_post_only: bool,
257) -> anyhow::Result<HyperliquidExecTif> {
258 match (tif, is_post_only) {
259 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
261 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
262 (TimeInForce::Fok, false) => {
263 anyhow::bail!("FOK time in force is not supported by Hyperliquid")
264 }
265 _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
266 }
267}
268
269fn determine_tpsl_type(
270 order_type: OrderType,
271 order_side: OrderSide,
272 trigger_price: Decimal,
273 current_price: Option<Decimal>,
274) -> HyperliquidExecTpSl {
275 match order_type {
276 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
278
279 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
281
282 _ => {
284 if let Some(current) = current_price {
285 match order_side {
286 OrderSide::Buy => {
287 if trigger_price > current {
289 HyperliquidExecTpSl::Sl
290 } else {
291 HyperliquidExecTpSl::Tp
292 }
293 }
294 OrderSide::Sell => {
295 if trigger_price < current {
297 HyperliquidExecTpSl::Sl
298 } else {
299 HyperliquidExecTpSl::Tp
300 }
301 }
302 _ => HyperliquidExecTpSl::Sl, }
304 } else {
305 HyperliquidExecTpSl::Sl
307 }
308 }
309 }
310}
311
312pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
318 let spec = bar_type.spec();
319 let step = spec.step.get();
320
321 anyhow::ensure!(
322 bar_type.aggregation_source() == AggregationSource::External,
323 "Only EXTERNAL aggregation is supported"
324 );
325
326 let interval = match spec.aggregation {
327 BarAggregation::Minute => match step {
328 1 => OneMinute,
329 3 => ThreeMinutes,
330 5 => FiveMinutes,
331 15 => FifteenMinutes,
332 30 => ThirtyMinutes,
333 _ => anyhow::bail!("Unsupported minute step: {step}"),
334 },
335 BarAggregation::Hour => match step {
336 1 => OneHour,
337 2 => TwoHours,
338 4 => FourHours,
339 8 => EightHours,
340 12 => TwelveHours,
341 _ => anyhow::bail!("Unsupported hour step: {step}"),
342 },
343 BarAggregation::Day => match step {
344 1 => OneDay,
345 3 => ThreeDays,
346 _ => anyhow::bail!("Unsupported day step: {step}"),
347 },
348 BarAggregation::Week if step == 1 => OneWeek,
349 BarAggregation::Month if step == 1 => OneMonth,
350 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
351 };
352
353 Ok(interval)
354}
355
356pub fn order_to_hyperliquid_request_with_asset(
363 order: &OrderAny,
364 asset: u32,
365 price_decimals: u8,
366 should_normalize_prices: bool,
367 slippage_bps: u32,
368) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
369 let is_buy = matches!(order.order_side(), OrderSide::Buy);
370 let reduce_only = order.is_reduce_only();
371 let order_side = order.order_side();
372 let order_type = order.order_type();
373
374 let price_decimal = if let Some(price) = order.price() {
377 let raw = price.as_decimal();
378
379 if should_normalize_prices {
380 normalize_price(raw, price_decimals).normalize()
381 } else {
382 raw.normalize()
383 }
384 } else if matches!(order_type, OrderType::Market) {
385 Decimal::ZERO
386 } else if matches!(
387 order_type,
388 OrderType::StopMarket | OrderType::MarketIfTouched
389 ) {
390 match order.trigger_price() {
391 Some(tp) => {
392 let base = tp.as_decimal().normalize();
393 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
394 let sig_rounded = round_to_sig_figs(derived, 5);
395 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
396 }
397 None => Decimal::ZERO,
398 }
399 } else {
400 anyhow::bail!("Limit orders require a price")
401 };
402
403 let size_decimal = order.quantity().as_decimal().normalize();
404
405 let kind = match order_type {
407 OrderType::Market => HyperliquidExecOrderKind::Limit {
408 limit: HyperliquidExecLimitParams {
409 tif: HyperliquidExecTif::Ioc,
410 },
411 },
412 OrderType::Limit => {
413 let tif =
414 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
415 HyperliquidExecOrderKind::Limit {
416 limit: HyperliquidExecLimitParams { tif },
417 }
418 }
419 OrderType::StopMarket => {
420 if let Some(trigger_price) = order.trigger_price() {
421 let raw = trigger_price.as_decimal();
422 let trigger_price_decimal = if should_normalize_prices {
423 normalize_price(raw, price_decimals).normalize()
424 } else {
425 raw.normalize()
426 };
427 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
428 HyperliquidExecOrderKind::Trigger {
429 trigger: HyperliquidExecTriggerParams {
430 is_market: true,
431 trigger_px: trigger_price_decimal,
432 tpsl,
433 },
434 }
435 } else {
436 anyhow::bail!("Stop market orders require a trigger price")
437 }
438 }
439 OrderType::StopLimit => {
440 if let Some(trigger_price) = order.trigger_price() {
441 let raw = trigger_price.as_decimal();
442 let trigger_price_decimal = if should_normalize_prices {
443 normalize_price(raw, price_decimals).normalize()
444 } else {
445 raw.normalize()
446 };
447 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
448 HyperliquidExecOrderKind::Trigger {
449 trigger: HyperliquidExecTriggerParams {
450 is_market: false,
451 trigger_px: trigger_price_decimal,
452 tpsl,
453 },
454 }
455 } else {
456 anyhow::bail!("Stop limit orders require a trigger price")
457 }
458 }
459 OrderType::MarketIfTouched => {
460 if let Some(trigger_price) = order.trigger_price() {
461 let raw = trigger_price.as_decimal();
462 let trigger_price_decimal = if should_normalize_prices {
463 normalize_price(raw, price_decimals).normalize()
464 } else {
465 raw.normalize()
466 };
467 HyperliquidExecOrderKind::Trigger {
468 trigger: HyperliquidExecTriggerParams {
469 is_market: true,
470 trigger_px: trigger_price_decimal,
471 tpsl: HyperliquidExecTpSl::Tp,
472 },
473 }
474 } else {
475 anyhow::bail!("Market-if-touched orders require a trigger price")
476 }
477 }
478 OrderType::LimitIfTouched => {
479 if let Some(trigger_price) = order.trigger_price() {
480 let raw = trigger_price.as_decimal();
481 let trigger_price_decimal = if should_normalize_prices {
482 normalize_price(raw, price_decimals).normalize()
483 } else {
484 raw.normalize()
485 };
486 HyperliquidExecOrderKind::Trigger {
487 trigger: HyperliquidExecTriggerParams {
488 is_market: false,
489 trigger_px: trigger_price_decimal,
490 tpsl: HyperliquidExecTpSl::Tp,
491 },
492 }
493 } else {
494 anyhow::bail!("Limit-if-touched orders require a trigger price")
495 }
496 }
497 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
498 };
499
500 let cloid = Some(Cloid::from_client_order_id(order.client_order_id()));
501
502 Ok(HyperliquidExecPlaceOrderRequest {
503 asset,
504 is_buy,
505 price: price_decimal,
506 size: size_decimal,
507 reduce_only,
508 kind,
509 cloid,
510 })
511}
512
513pub const DEFAULT_MARKET_SLIPPAGE_BPS: u32 = 50;
515
516pub fn derive_market_order_price(
520 quote: &QuoteTick,
521 is_buy: bool,
522 price_decimals: u8,
523 slippage_bps: u32,
524) -> Decimal {
525 let base = if is_buy {
526 quote.ask_price.as_decimal()
527 } else {
528 quote.bid_price.as_decimal()
529 };
530 let derived = derive_limit_from_trigger(base, is_buy, slippage_bps);
531 let sig_rounded = round_to_sig_figs(derived, 5);
532 clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize()
533}
534
535pub fn derive_limit_from_trigger(
539 trigger_price: Decimal,
540 is_buy: bool,
541 slippage_bps: u32,
542) -> Decimal {
543 let slippage = Decimal::new(slippage_bps as i64, 4);
545 let price = if is_buy {
546 trigger_price * (Decimal::ONE + slippage)
547 } else {
548 trigger_price * (Decimal::ONE - slippage)
549 };
550
551 price.normalize()
553}
554
555pub fn clamp_price_to_precision(price: Decimal, decimals: u8, is_buy: bool) -> Decimal {
558 let scale = Decimal::from(10_u64.pow(decimals as u32));
559
560 if is_buy {
561 (price * scale).ceil() / scale
562 } else {
563 (price * scale).floor() / scale
564 }
565}
566
567pub fn client_order_id_to_cancel_request_with_asset(
569 client_order_id: &str,
570 asset: u32,
571) -> HyperliquidExecCancelByCloidRequest {
572 let cloid = Cloid::from_client_order_id(ClientOrderId::from(client_order_id));
573 HyperliquidExecCancelByCloidRequest { asset, cloid }
574}
575
576pub fn extract_inner_error(response: &HyperliquidExchangeResponse) -> Option<String> {
582 let HyperliquidExchangeResponse::Status { response, .. } = response else {
583 return None;
584 };
585 let data: HyperliquidExecResponseData = serde_json::from_value(response.clone()).ok()?;
586 match data {
587 HyperliquidExecResponseData::Order { data } => {
588 for status in &data.statuses {
589 if let HyperliquidExecOrderStatus::Error { error } = status {
590 return Some(error.clone());
591 }
592 }
593 None
594 }
595 HyperliquidExecResponseData::Cancel { data } => {
596 for status in &data.statuses {
597 if let HyperliquidExecCancelStatus::Error { error } = status {
598 return Some(error.clone());
599 }
600 }
601 None
602 }
603 HyperliquidExecResponseData::Modify { data } => {
604 for status in &data.statuses {
605 if let HyperliquidExecModifyStatus::Error { error } = status {
606 return Some(error.clone());
607 }
608 }
609 None
610 }
611 _ => None,
612 }
613}
614
615pub fn extract_inner_errors(response: &HyperliquidExchangeResponse) -> Vec<Option<String>> {
621 let HyperliquidExchangeResponse::Status { response, .. } = response else {
622 return Vec::new();
623 };
624 let Ok(data) = serde_json::from_value::<HyperliquidExecResponseData>(response.clone()) else {
625 return Vec::new();
626 };
627
628 match data {
629 HyperliquidExecResponseData::Order { data } => data
630 .statuses
631 .into_iter()
632 .map(|s| match s {
633 HyperliquidExecOrderStatus::Error { error } => Some(error),
634 _ => None,
635 })
636 .collect(),
637 HyperliquidExecResponseData::Cancel { data } => data
638 .statuses
639 .into_iter()
640 .map(|s| match s {
641 HyperliquidExecCancelStatus::Error { error } => Some(error),
642 HyperliquidExecCancelStatus::Success(_) => None,
643 })
644 .collect(),
645 HyperliquidExecResponseData::Modify { data } => data
646 .statuses
647 .into_iter()
648 .map(|s| match s {
649 HyperliquidExecModifyStatus::Error { error } => Some(error),
650 HyperliquidExecModifyStatus::Success(_) => None,
651 })
652 .collect(),
653 _ => Vec::new(),
654 }
655}
656
657pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
659 match response {
660 HyperliquidExchangeResponse::Status { status, response } => {
661 if status == RESPONSE_STATUS_OK {
662 "Operation successful".to_string()
663 } else {
664 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
666 error_msg.to_string()
667 } else {
668 format!("Request failed with status: {status}")
669 }
670 }
671 }
672 HyperliquidExchangeResponse::Error { error } => error.clone(),
673 }
674}
675
676pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
682 trigger_px.is_some() && tpsl.is_some()
683}
684
685pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
691 match (is_market, tpsl) {
692 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
693 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
694 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
695 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
696 }
697}
698
699pub fn parse_order_status_with_trigger(
705 status: HyperliquidOrderStatus,
706 trigger_activated: Option<bool>,
707) -> (OrderStatus, Option<String>) {
708 let base_status = OrderStatus::from(status);
709
710 if let Some(activated) = trigger_activated {
712 let trigger_status = if activated {
713 Some("activated".to_string())
714 } else {
715 Some("pending".to_string())
716 };
717 (base_status, trigger_status)
718 } else {
719 (base_status, None)
720 }
721}
722
723pub fn format_trailing_stop_info(
725 offset: &str,
726 offset_type: TrailingOffsetType,
727 callback_price: Option<&str>,
728) -> String {
729 let offset_desc = offset_type.format_offset(offset);
730
731 if let Some(callback) = callback_price {
732 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
733 } else {
734 format!("Trailing stop: {offset_desc} offset")
735 }
736}
737
738pub fn validate_conditional_order_params(
744 trigger_px: Option<&str>,
745 tpsl: Option<&HyperliquidTpSl>,
746 is_market: Option<bool>,
747) -> anyhow::Result<()> {
748 if trigger_px.is_none() {
749 anyhow::bail!("Conditional order missing trigger price");
750 }
751
752 if tpsl.is_none() {
753 anyhow::bail!("Conditional order missing tpsl indicator");
754 }
755
756 if is_market.is_none() {
759 anyhow::bail!("Conditional order missing is_market flag");
760 }
761
762 Ok(())
763}
764
765pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
771 Decimal::from_str_exact(trigger_px)
772 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
773}
774
775pub fn parse_account_balances_and_margins(
786 state: &ClearinghouseState,
787) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
788 let mut balances = Vec::new();
789 let mut margins = Vec::new();
790
791 let currency = Currency::USDC();
792
793 let cross_margin_summary = match &state.cross_margin_summary {
794 Some(summary) => summary,
795 None => return Ok((balances, margins)),
796 };
797
798 let mut total_value = cross_margin_summary.total_raw_usd.max(Decimal::ZERO);
799 let free_value = state.withdrawable.unwrap_or(total_value).max(Decimal::ZERO);
800
801 if free_value > total_value {
804 total_value = free_value;
805 }
806
807 balances.push(AccountBalance::from_total_and_free(
808 total_value,
809 free_value,
810 currency,
811 )?);
812
813 let margin_used = cross_margin_summary.total_margin_used;
814
815 if margin_used > Decimal::ZERO {
816 let initial_margin = Money::from_decimal(margin_used, currency)?;
819 let maintenance_margin = Money::from_decimal(margin_used, currency)?;
820 margins.push(MarginBalance::new(initial_margin, maintenance_margin, None));
821 }
822
823 Ok((balances, margins))
824}
825
826pub fn parse_combined_account_balances_and_margins(
837 perp_state: &ClearinghouseState,
838 spot_state: &SpotClearinghouseState,
839) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
840 let (mut balances, margins) = parse_account_balances_and_margins(perp_state)?;
841
842 let has_perp_summary = perp_state.cross_margin_summary.is_some();
843 let spot_balances = parse_spot_account_balances(spot_state)?;
844
845 for balance in spot_balances {
846 let is_usdc = balance.currency.code.as_str() == "USDC";
847 if has_perp_summary && is_usdc {
848 continue;
849 }
850 balances.push(balance);
851 }
852
853 Ok((balances, margins))
854}
855
856pub fn parse_spot_account_balances(
866 state: &SpotClearinghouseState,
867) -> anyhow::Result<Vec<AccountBalance>> {
868 let mut balances = Vec::with_capacity(state.balances.len());
869
870 for balance in &state.balances {
871 if balance.total.is_zero() {
872 continue;
873 }
874
875 let currency = crate::http::parse::get_currency(balance.coin.as_str());
876
877 balances.push(AccountBalance::from_total_and_locked(
881 balance.total,
882 balance.hold,
883 currency,
884 )?);
885 }
886
887 Ok(balances)
888}
889
890pub(crate) fn determine_order_list_grouping(orders: &[OrderAny]) -> HyperliquidExecGrouping {
902 if orders.len() >= 2 {
903 let entry = &orders[0];
904 let children = &orders[1..];
905 let entry_id = entry.client_order_id();
906 let entry_is_oto =
907 entry.contingency_type() == Some(ContingencyType::Oto) && !entry.is_reduce_only();
908 let children_are_linked = children.iter().all(|o| {
909 o.contingency_type() == Some(ContingencyType::Oco)
910 && o.is_reduce_only()
911 && o.parent_order_id() == Some(entry_id)
912 });
913
914 if entry_is_oto && children_are_linked {
915 return HyperliquidExecGrouping::NormalTpsl;
916 }
917 }
918
919 let all_oco_linked = orders.len() >= 2
920 && orders
921 .iter()
922 .all(|o| o.contingency_type() == Some(ContingencyType::Oco) && o.is_reduce_only())
923 && orders.iter().all(|o| {
924 o.linked_order_ids().is_some_and(|ids| {
925 ids.iter()
926 .all(|id| orders.iter().any(|other| other.client_order_id() == *id))
927 })
928 });
929
930 if all_oco_linked {
931 HyperliquidExecGrouping::PositionTpsl
932 } else {
933 HyperliquidExecGrouping::Na
934 }
935}
936
937#[cfg(test)]
938mod tests {
939 use std::str::FromStr;
940
941 use nautilus_model::{
942 enums::{OrderSide, TimeInForce, TriggerType},
943 identifiers::{ClientOrderId, InstrumentId, StrategyId, TraderId},
944 orders::{OrderAny, StopMarketOrder},
945 types::{Price, Quantity},
946 };
947 use rstest::rstest;
948 use rust_decimal::Decimal;
949 use rust_decimal_macros::dec;
950 use serde::{Deserialize, Serialize};
951
952 use super::*;
953
954 #[derive(Serialize, Deserialize)]
955 struct TestStruct {
956 #[serde(
957 serialize_with = "serialize_decimal_as_str",
958 deserialize_with = "deserialize_decimal_from_str"
959 )]
960 value: Decimal,
961 #[serde(
962 serialize_with = "serialize_optional_decimal_as_str",
963 deserialize_with = "deserialize_optional_decimal_from_str"
964 )]
965 optional_value: Option<Decimal>,
966 }
967
968 #[rstest]
969 fn test_decimal_serialization_roundtrip() {
970 let original = TestStruct {
971 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
972 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
973 };
974
975 let json = serde_json::to_string(&original).unwrap();
976 println!("Serialized: {json}");
977
978 assert!(json.contains("\"123.45678901234567890123456789\""));
980 assert!(json.contains("\"0.000000001\""));
981
982 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
983 assert_eq!(original.value, deserialized.value);
984 assert_eq!(original.optional_value, deserialized.optional_value);
985 }
986
987 #[rstest]
988 fn test_decimal_precision_preservation() {
989 let test_cases = [
990 "0",
991 "1",
992 "0.1",
993 "0.01",
994 "0.001",
995 "123.456789012345678901234567890",
996 "999999999999999999.999999999999999999",
997 ];
998
999 for case in test_cases {
1000 let decimal = Decimal::from_str(case).unwrap();
1001 let test_struct = TestStruct {
1002 value: decimal,
1003 optional_value: Some(decimal),
1004 };
1005
1006 let json = serde_json::to_string(&test_struct).unwrap();
1007 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1008
1009 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
1010 assert_eq!(
1011 Some(decimal),
1012 parsed.optional_value,
1013 "Failed for case: {case}"
1014 );
1015 }
1016 }
1017
1018 #[rstest]
1019 fn test_optional_none_handling() {
1020 let test_struct = TestStruct {
1021 value: Decimal::from_str("42.0").unwrap(),
1022 optional_value: None,
1023 };
1024
1025 let json = serde_json::to_string(&test_struct).unwrap();
1026 assert!(json.contains("null"));
1027
1028 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
1029 assert_eq!(test_struct.value, parsed.value);
1030 assert_eq!(None, parsed.optional_value);
1031 }
1032
1033 #[rstest]
1034 fn test_round_down_to_tick() {
1035 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
1036 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
1037 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
1038
1039 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
1041 }
1042
1043 #[rstest]
1044 fn test_round_down_to_step() {
1045 assert_eq!(
1046 round_down_to_step(dec!(0.12349), dec!(0.0001)),
1047 dec!(0.1234)
1048 );
1049 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
1050 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
1051
1052 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
1054 }
1055
1056 #[rstest]
1057 fn test_min_notional_validation() {
1058 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1060 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1061
1062 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1064 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1065
1066 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1068 }
1069
1070 #[rstest]
1071 fn test_round_to_sig_figs() {
1072 assert_eq!(round_to_sig_figs(dec!(104567.3), 5), dec!(104570));
1074 assert_eq!(round_to_sig_figs(dec!(104522.5), 5), dec!(104520));
1075 assert_eq!(round_to_sig_figs(dec!(99999.9), 5), dec!(100000));
1076
1077 assert_eq!(round_to_sig_figs(dec!(1234.5), 5), dec!(1234.5));
1079 assert_eq!(round_to_sig_figs(dec!(0.12345), 5), dec!(0.12345));
1080 assert_eq!(round_to_sig_figs(dec!(0.123456), 5), dec!(0.12346));
1081
1082 assert_eq!(round_to_sig_figs(dec!(0.000123456), 5), dec!(0.00012346));
1084 assert_eq!(round_to_sig_figs(dec!(0.000999999), 5), dec!(0.0010000)); assert_eq!(round_to_sig_figs(dec!(0), 5), dec!(0));
1088 }
1089
1090 #[rstest]
1091 fn test_normalize_price() {
1092 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1094 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));
1100 }
1101
1102 #[rstest]
1103 fn test_normalize_quantity() {
1104 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1105 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1106 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1107 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1108 }
1109
1110 #[rstest]
1111 fn test_normalize_order_complete() {
1112 let result = normalize_order(
1113 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1121
1122 assert!(result.is_ok());
1123 let (price, qty) = result.unwrap();
1124 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1127
1128 #[rstest]
1129 fn test_normalize_order_min_notional_fail() {
1130 let result = normalize_order(
1131 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1139
1140 assert!(result.is_err());
1141 assert!(result.unwrap_err().contains("Notional value"));
1142 }
1143
1144 #[rstest]
1145 fn test_edge_cases() {
1146 assert_eq!(
1148 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1149 dec!(0.000001)
1150 );
1151
1152 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1154
1155 assert_eq!(
1157 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1158 dec!(100.00)
1159 );
1160 }
1161
1162 #[rstest]
1163 fn test_is_conditional_order_data() {
1164 assert!(is_conditional_order_data(
1166 Some("50000.0"),
1167 Some(&HyperliquidTpSl::Sl)
1168 ));
1169
1170 assert!(!is_conditional_order_data(Some("50000.0"), None));
1172
1173 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1175
1176 assert!(!is_conditional_order_data(None, None));
1178 }
1179
1180 #[rstest]
1181 fn test_parse_trigger_order_type() {
1182 assert_eq!(
1184 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1185 OrderType::StopMarket
1186 );
1187
1188 assert_eq!(
1190 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1191 OrderType::StopLimit
1192 );
1193
1194 assert_eq!(
1196 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1197 OrderType::MarketIfTouched
1198 );
1199
1200 assert_eq!(
1202 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1203 OrderType::LimitIfTouched
1204 );
1205 }
1206
1207 #[rstest]
1208 fn test_parse_order_status_with_trigger() {
1209 let (status, trigger_status) =
1211 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(true));
1212 assert_eq!(status, OrderStatus::Accepted);
1213 assert_eq!(trigger_status, Some("activated".to_string()));
1214
1215 let (status, trigger_status) =
1217 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, Some(false));
1218 assert_eq!(status, OrderStatus::Accepted);
1219 assert_eq!(trigger_status, Some("pending".to_string()));
1220
1221 let (status, trigger_status) =
1223 parse_order_status_with_trigger(HyperliquidOrderStatus::Open, None);
1224 assert_eq!(status, OrderStatus::Accepted);
1225 assert_eq!(trigger_status, None);
1226 }
1227
1228 #[rstest]
1229 fn test_format_trailing_stop_info() {
1230 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1232 assert!(info.contains("100.0"));
1233 assert!(info.contains("callback at 50000.0"));
1234
1235 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1237 assert!(info.contains("5.0%"));
1238 assert!(info.contains("Trailing stop"));
1239
1240 let info =
1242 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1243 assert!(info.contains("250 bps"));
1244 assert!(info.contains("49000.0"));
1245 }
1246
1247 #[rstest]
1248 fn test_parse_trigger_price() {
1249 let result = parse_trigger_price("50000.0");
1251 assert!(result.is_ok());
1252 assert_eq!(result.unwrap(), dec!(50000.0));
1253
1254 let result = parse_trigger_price("49000");
1256 assert!(result.is_ok());
1257 assert_eq!(result.unwrap(), dec!(49000));
1258
1259 let result = parse_trigger_price("invalid");
1261 assert!(result.is_err());
1262
1263 let result = parse_trigger_price("");
1265 assert!(result.is_err());
1266 }
1267
1268 #[rstest]
1269 #[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(
1280 #[case] trigger_price: Decimal,
1281 #[case] is_buy: bool,
1282 #[case] expected: Decimal,
1283 ) {
1284 let result = derive_limit_from_trigger(trigger_price, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1285 assert_eq!(result, expected);
1286
1287 if is_buy {
1289 assert!(result >= trigger_price);
1290 } else {
1291 assert!(result <= trigger_price);
1292 }
1293 }
1294
1295 #[rstest]
1296 #[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))]
1306 #[case(dec!(0.4975), 4, false, dec!(0.4975))]
1307 #[case(dec!(0.4975), 2, true, dec!(0.50))]
1309 #[case(dec!(0.4975), 2, false, dec!(0.49))]
1310 fn test_clamp_price_to_precision(
1311 #[case] price: Decimal,
1312 #[case] decimals: u8,
1313 #[case] is_buy: bool,
1314 #[case] expected: Decimal,
1315 ) {
1316 assert_eq!(clamp_price_to_precision(price, decimals, is_buy), expected);
1317 }
1318
1319 fn stop_market_order(side: OrderSide, trigger_price: &str) -> OrderAny {
1320 OrderAny::StopMarket(StopMarketOrder::new(
1321 TraderId::from("TESTER-001"),
1322 StrategyId::from("S-001"),
1323 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1324 ClientOrderId::from("O-001"),
1325 side,
1326 Quantity::from(1),
1327 Price::from(trigger_price),
1328 TriggerType::LastPrice,
1329 TimeInForce::Gtc,
1330 None,
1331 false,
1332 false,
1333 None,
1334 None,
1335 None,
1336 None,
1337 None,
1338 None,
1339 None,
1340 None,
1341 None,
1342 None,
1343 None,
1344 Default::default(),
1345 Default::default(),
1346 ))
1347 }
1348
1349 #[rstest]
1350 #[case(OrderSide::Sell, "2470.00", 2)]
1352 #[case(OrderSide::Buy, "2470.00", 2)]
1353 #[case(OrderSide::Sell, "104567.3", 1)]
1355 #[case(OrderSide::Buy, "104567.3", 1)]
1356 #[case(OrderSide::Sell, "0.50", 4)]
1358 #[case(OrderSide::Buy, "0.50", 4)]
1359 #[case(OrderSide::Sell, "2470.00", 1)]
1363 #[case(OrderSide::Buy, "2470.00", 1)]
1364 #[case(OrderSide::Sell, "2470.00", 0)]
1368 #[case(OrderSide::Buy, "2470.00", 0)]
1369 fn test_order_to_request_stop_market_derives_limit_from_trigger(
1370 #[case] side: OrderSide,
1371 #[case] trigger_str: &str,
1372 #[case] price_decimals: u8,
1373 ) {
1374 let order = stop_market_order(side, trigger_str);
1375 let request = order_to_hyperliquid_request_with_asset(
1376 &order,
1377 0,
1378 price_decimals,
1379 true,
1380 DEFAULT_MARKET_SLIPPAGE_BPS,
1381 )
1382 .unwrap();
1383 let trigger = Decimal::from_str(trigger_str).unwrap();
1384 let is_buy = matches!(side, OrderSide::Buy);
1385
1386 if is_buy {
1388 assert!(
1389 request.price >= trigger,
1390 "BUY limit {} must be >= trigger {trigger}",
1391 request.price,
1392 );
1393 assert!(request.is_buy);
1394 } else {
1395 assert!(
1396 request.price <= trigger,
1397 "SELL limit {} must be <= trigger {trigger}",
1398 request.price,
1399 );
1400 assert!(!request.is_buy);
1401 }
1402
1403 let derived = derive_limit_from_trigger(trigger, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1405 let sig_rounded = round_to_sig_figs(derived, 5);
1406 let expected = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1407 assert_eq!(request.price, expected);
1408
1409 let price_str = request.price.to_string();
1411 let actual_decimals = price_str
1412 .find('.')
1413 .map_or(0, |dot| price_str.len() - dot - 1);
1414 assert!(
1415 actual_decimals <= price_decimals as usize,
1416 "Price {price_str} has {actual_decimals} decimals, max allowed {price_decimals}",
1417 );
1418
1419 if price_str.contains('.') {
1421 assert!(
1422 !price_str.ends_with('0'),
1423 "Price {price_str} has decimal trailing zeros",
1424 );
1425 }
1426
1427 let expected_trigger = normalize_price(trigger, price_decimals).normalize();
1428 assert_eq!(
1429 request.kind,
1430 HyperliquidExecOrderKind::Trigger {
1431 trigger: HyperliquidExecTriggerParams {
1432 is_market: true,
1433 trigger_px: expected_trigger,
1434 tpsl: HyperliquidExecTpSl::Sl,
1435 },
1436 },
1437 );
1438 }
1439
1440 fn ok_response(inner: serde_json::Value) -> HyperliquidExchangeResponse {
1441 HyperliquidExchangeResponse::Status {
1442 status: "ok".to_string(),
1443 response: inner,
1444 }
1445 }
1446
1447 #[rstest]
1448 fn test_extract_inner_error_order_with_error() {
1449 let response = ok_response(serde_json::json!({
1450 "type": "order",
1451 "data": {"statuses": [{"error": "Order has invalid price."}]}
1452 }));
1453 assert_eq!(
1454 extract_inner_error(&response),
1455 Some("Order has invalid price.".to_string()),
1456 );
1457 }
1458
1459 #[rstest]
1460 fn test_extract_inner_error_order_resting() {
1461 let response = ok_response(serde_json::json!({
1462 "type": "order",
1463 "data": {"statuses": [{"resting": {"oid": 12345}}]}
1464 }));
1465 assert_eq!(extract_inner_error(&response), None);
1466 }
1467
1468 #[rstest]
1469 fn test_extract_inner_error_order_filled() {
1470 let response = ok_response(serde_json::json!({
1471 "type": "order",
1472 "data": {"statuses": [{"filled": {"totalSz": "0.01", "avgPx": "2470.0", "oid": 99}}]}
1473 }));
1474 assert_eq!(extract_inner_error(&response), None);
1475 }
1476
1477 #[rstest]
1478 fn test_extract_inner_error_cancel_error() {
1479 let response = ok_response(serde_json::json!({
1480 "type": "cancel",
1481 "data": {"statuses": [{"error": "Order not found"}]}
1482 }));
1483 assert_eq!(
1484 extract_inner_error(&response),
1485 Some("Order not found".to_string()),
1486 );
1487 }
1488
1489 #[rstest]
1490 fn test_extract_inner_error_cancel_success() {
1491 let response = ok_response(serde_json::json!({
1492 "type": "cancel",
1493 "data": {"statuses": ["success"]}
1494 }));
1495 assert_eq!(extract_inner_error(&response), None);
1496 }
1497
1498 #[rstest]
1499 fn test_extract_inner_error_modify_error() {
1500 let response = ok_response(serde_json::json!({
1501 "type": "modify",
1502 "data": {"statuses": [{"error": "Invalid modify"}]}
1503 }));
1504 assert_eq!(
1505 extract_inner_error(&response),
1506 Some("Invalid modify".to_string()),
1507 );
1508 }
1509
1510 #[rstest]
1511 fn test_extract_inner_error_modify_success() {
1512 let response = ok_response(serde_json::json!({
1513 "type": "modify",
1514 "data": {"statuses": ["success"]}
1515 }));
1516 assert_eq!(extract_inner_error(&response), None);
1517 }
1518
1519 #[rstest]
1520 fn test_extract_inner_error_non_status_response() {
1521 let response = HyperliquidExchangeResponse::Error {
1522 error: "top-level error".to_string(),
1523 };
1524 assert_eq!(extract_inner_error(&response), None);
1525 }
1526
1527 #[rstest]
1528 fn test_extract_inner_error_unparsable_response() {
1529 let response = ok_response(serde_json::json!({"unknown": "data"}));
1530 assert_eq!(extract_inner_error(&response), None);
1531 }
1532
1533 #[rstest]
1534 fn test_extract_inner_error_returns_first_error_in_batch() {
1535 let response = ok_response(serde_json::json!({
1536 "type": "order",
1537 "data": {"statuses": [
1538 {"resting": {"oid": 1}},
1539 {"error": "Second failed"},
1540 {"error": "Third failed"},
1541 ]}
1542 }));
1543 assert_eq!(
1544 extract_inner_error(&response),
1545 Some("Second failed".to_string()),
1546 );
1547 }
1548
1549 #[rstest]
1550 fn test_extract_inner_errors_mixed_batch() {
1551 let response = ok_response(serde_json::json!({
1552 "type": "order",
1553 "data": {"statuses": [
1554 {"resting": {"oid": 1}},
1555 {"error": "Failed order"},
1556 {"filled": {"totalSz": "0.01", "avgPx": "100.0", "oid": 2}},
1557 ]}
1558 }));
1559 let errors = extract_inner_errors(&response);
1560 assert_eq!(errors.len(), 3);
1561 assert_eq!(errors[0], None);
1562 assert_eq!(errors[1], Some("Failed order".to_string()));
1563 assert_eq!(errors[2], None);
1564 }
1565
1566 #[rstest]
1567 fn test_extract_inner_errors_all_success() {
1568 let response = ok_response(serde_json::json!({
1569 "type": "order",
1570 "data": {"statuses": [
1571 {"resting": {"oid": 1}},
1572 {"resting": {"oid": 2}},
1573 ]}
1574 }));
1575 let errors = extract_inner_errors(&response);
1576 assert_eq!(errors.len(), 2);
1577 assert!(errors.iter().all(|e| e.is_none()));
1578 }
1579
1580 #[rstest]
1581 fn test_extract_inner_errors_cancel_success() {
1582 let response = ok_response(serde_json::json!({
1583 "type": "cancel",
1584 "data": {"statuses": ["success"]}
1585 }));
1586 let errors = extract_inner_errors(&response);
1587 assert_eq!(errors.len(), 1);
1588 assert!(errors[0].is_none());
1589 }
1590
1591 #[rstest]
1592 fn test_extract_inner_errors_cancel_mixed() {
1593 let response = ok_response(serde_json::json!({
1594 "type": "cancel",
1595 "data": {"statuses": [
1596 "success",
1597 {"error": "Order was never placed, already canceled, or filled."},
1598 "success",
1599 ]}
1600 }));
1601 let errors = extract_inner_errors(&response);
1602 assert_eq!(errors.len(), 3);
1603 assert_eq!(errors[0], None);
1604 assert_eq!(
1605 errors[1],
1606 Some("Order was never placed, already canceled, or filled.".to_string())
1607 );
1608 assert_eq!(errors[2], None);
1609 }
1610
1611 #[rstest]
1612 fn test_extract_inner_errors_modify_mixed() {
1613 let response = ok_response(serde_json::json!({
1614 "type": "modify",
1615 "data": {"statuses": [
1616 "success",
1617 {"error": "Order does not exist"},
1618 ]}
1619 }));
1620 let errors = extract_inner_errors(&response);
1621 assert_eq!(errors.len(), 2);
1622 assert_eq!(errors[0], None);
1623 assert_eq!(errors[1], Some("Order does not exist".to_string()));
1624 }
1625
1626 #[rstest]
1627 fn test_extract_inner_errors_unparsable() {
1628 let response = ok_response(serde_json::json!({"foo": "bar"}));
1629 let errors = extract_inner_errors(&response);
1630 assert!(errors.is_empty());
1631 }
1632
1633 fn count_sig_figs(s: &str) -> usize {
1634 let s = s.trim_start_matches('-');
1635 if s.contains('.') {
1636 let digits: String = s.replace('.', "");
1638 digits.trim_start_matches('0').len()
1639 } else {
1640 let s = s.trim_start_matches('0');
1642 s.trim_end_matches('0').len()
1643 }
1644 }
1645
1646 fn make_quote(bid: &str, ask: &str) -> QuoteTick {
1647 QuoteTick::new(
1648 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"),
1649 Price::from(bid),
1650 Price::from(ask),
1651 Quantity::from("1"),
1652 Quantity::from("1"),
1653 Default::default(),
1654 Default::default(),
1655 )
1656 }
1657
1658 #[rstest]
1659 #[case("2460.00", "2470.00", true, 2, "2482.4")]
1665 #[case("2460.00", "2470.00", false, 2, "2447.7")]
1667 #[case("104500.0", "104567.3", true, 1, "105090")]
1671 #[case("104500.0", "104567.3", false, 1, "103980")]
1673 #[case("0.4900", "0.5000", true, 4, "0.5025")]
1677 #[case("0.4900", "0.5000", false, 4, "0.4875")]
1679 #[case("49900", "50000", true, 0, "50250")]
1683 #[case("49900", "50000", false, 0, "49650")]
1685 #[case("0.001200", "0.001234", true, 6, "0.001241")]
1689 #[case("0.001200", "0.001234", false, 6, "0.001194")]
1691 fn test_derive_market_order_price(
1692 #[case] bid: &str,
1693 #[case] ask: &str,
1694 #[case] is_buy: bool,
1695 #[case] price_decimals: u8,
1696 #[case] expected: &str,
1697 ) {
1698 let quote = make_quote(bid, ask);
1699 let result =
1700 derive_market_order_price("e, is_buy, price_decimals, DEFAULT_MARKET_SLIPPAGE_BPS);
1701 let expected_dec = Decimal::from_str(expected).unwrap();
1702 assert_eq!(result, expected_dec);
1703
1704 let base = if is_buy {
1706 quote.ask_price.as_decimal()
1707 } else {
1708 quote.bid_price.as_decimal()
1709 };
1710 let derived = derive_limit_from_trigger(base, is_buy, DEFAULT_MARKET_SLIPPAGE_BPS);
1711 let sig_rounded = round_to_sig_figs(derived, 5);
1712 let pipeline = clamp_price_to_precision(sig_rounded, price_decimals, is_buy).normalize();
1713 assert_eq!(result, pipeline);
1714
1715 let s = result.to_string();
1717 if s.contains('.') {
1718 assert!(!s.ends_with('0'), "Price {s} has trailing zeros");
1719 }
1720
1721 let sig_count = count_sig_figs(&s);
1723 assert!(sig_count <= 5, "Price {s} has {sig_count} sig figs, max 5",);
1724
1725 let actual_decimals = s.find('.').map_or(0, |dot| s.len() - dot - 1);
1727 assert!(
1728 actual_decimals <= price_decimals as usize,
1729 "Price {s} has {actual_decimals} decimals, max {price_decimals}",
1730 );
1731 }
1732
1733 #[rstest]
1734 #[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(
1742 #[case] slippage_bps: u32,
1743 #[case] trigger: Decimal,
1744 #[case] is_buy: bool,
1745 #[case] expected: Decimal,
1746 ) {
1747 let result = derive_limit_from_trigger(trigger, is_buy, slippage_bps);
1748 assert_eq!(result, expected);
1749 }
1750
1751 #[rstest]
1752 fn test_derive_market_order_price_respects_slippage_override() {
1753 let quote = make_quote("100.00", "100.10");
1754 let tight = derive_market_order_price("e, true, 2, 50);
1755 let wide = derive_market_order_price("e, true, 2, 800);
1756 assert_eq!(tight, dec!(100.6));
1757 assert_eq!(wide, dec!(108.11));
1758 assert!(wide > tight);
1759 }
1760
1761 #[rstest]
1765 fn test_parse_account_balances_uses_total_raw_usd_and_top_level_withdrawable() {
1766 let json = r#"{
1767 "assetPositions": [],
1768 "crossMarginSummary": {
1769 "accountValue": "150",
1770 "totalNtlPos": "0",
1771 "totalRawUsd": "100",
1772 "totalMarginUsed": "20",
1773 "withdrawable": "120"
1774 },
1775 "withdrawable": "80",
1776 "time": 1700000000000
1777 }"#;
1778
1779 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1780 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1781
1782 assert_eq!(balances.len(), 1);
1783 let balance = &balances[0];
1784 assert_eq!(balance.total.as_decimal(), dec!(100));
1787 assert_eq!(balance.free.as_decimal(), dec!(80));
1788 assert_eq!(balance.locked.as_decimal(), dec!(20));
1789
1790 assert_eq!(margins.len(), 1);
1791 assert_eq!(margins[0].initial.as_decimal(), dec!(20));
1792 }
1793
1794 #[rstest]
1795 fn test_parse_account_balances_bumps_total_when_withdrawable_exceeds() {
1796 let json = r#"{
1797 "assetPositions": [],
1798 "crossMarginSummary": {
1799 "accountValue": "100",
1800 "totalNtlPos": "0",
1801 "totalRawUsd": "100",
1802 "totalMarginUsed": "0",
1803 "withdrawable": "100"
1804 },
1805 "withdrawable": "150",
1806 "time": 1700000000000
1807 }"#;
1808
1809 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1810 let (balances, _) = parse_account_balances_and_margins(&state).unwrap();
1811
1812 assert_eq!(balances.len(), 1);
1813 let balance = &balances[0];
1814 assert_eq!(balance.total.as_decimal(), dec!(150));
1815 assert_eq!(balance.free.as_decimal(), dec!(150));
1816 assert_eq!(balance.locked.as_decimal(), dec!(0));
1817 }
1818
1819 #[rstest]
1820 fn test_parse_account_balances_returns_empty_when_no_cross_margin_summary() {
1821 let json = r#"{
1822 "assetPositions": [],
1823 "withdrawable": "100",
1824 "time": 1700000000000
1825 }"#;
1826
1827 let state: ClearinghouseState = serde_json::from_str(json).unwrap();
1828 let (balances, margins) = parse_account_balances_and_margins(&state).unwrap();
1829 assert!(balances.is_empty());
1830 assert!(margins.is_empty());
1831 }
1832
1833 #[rstest]
1834 fn test_parse_spot_account_balances_emits_one_per_token() {
1835 let json = r#"{
1836 "balances": [
1837 {"coin": "USDC", "token": 0, "total": "100.25", "hold": "10", "entryNtl": "0"},
1838 {"coin": "PURR", "token": 1, "total": "50", "hold": "0", "entryNtl": "25"},
1839 {"coin": "DUST", "token": 2, "total": "0", "hold": "0", "entryNtl": "0"}
1840 ]
1841 }"#;
1842
1843 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1844 let balances = parse_spot_account_balances(&state).unwrap();
1845
1846 assert_eq!(balances.len(), 2);
1847
1848 let usdc = &balances[0];
1849 assert_eq!(usdc.currency.code.as_str(), "USDC");
1850 assert_eq!(usdc.total.as_decimal(), dec!(100.25));
1851 assert_eq!(usdc.free.as_decimal(), dec!(90.25));
1852 assert_eq!(usdc.locked.as_decimal(), dec!(10));
1853
1854 let purr = &balances[1];
1855 assert_eq!(purr.currency.code.as_str(), "PURR");
1856 assert_eq!(purr.total.as_decimal(), dec!(50));
1857 assert_eq!(purr.free.as_decimal(), dec!(50));
1858 }
1859
1860 #[rstest]
1861 fn test_parse_spot_account_balances_clamps_hold_to_total() {
1862 let json = r#"{
1863 "balances": [
1864 {"coin": "HYPE", "token": 5, "total": "5", "hold": "10", "entryNtl": "0"}
1865 ]
1866 }"#;
1867
1868 let state: SpotClearinghouseState = serde_json::from_str(json).unwrap();
1869 let balances = parse_spot_account_balances(&state).unwrap();
1870
1871 assert_eq!(balances.len(), 1);
1872 let hype = &balances[0];
1873 assert_eq!(hype.total.as_decimal(), dec!(5));
1874 assert_eq!(hype.free.as_decimal(), dec!(0));
1875 assert_eq!(hype.locked.as_decimal(), dec!(5));
1876 }
1877
1878 #[rstest]
1879 fn test_parse_spot_account_balances_empty() {
1880 let state = SpotClearinghouseState::default();
1881 let balances = parse_spot_account_balances(&state).unwrap();
1882 assert!(balances.is_empty());
1883 }
1884
1885 #[rstest]
1886 fn test_parse_combined_deduplicates_usdc_when_perp_summary_present() {
1887 let perp_json = r#"{
1888 "assetPositions": [],
1889 "crossMarginSummary": {
1890 "accountValue": "500",
1891 "totalNtlPos": "0",
1892 "totalRawUsd": "500",
1893 "totalMarginUsed": "0",
1894 "withdrawable": "500"
1895 },
1896 "withdrawable": "500"
1897 }"#;
1898 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
1899
1900 let spot_json = r#"{
1901 "balances": [
1902 {"coin": "USDC", "token": 0, "total": "123", "hold": "0", "entryNtl": "0"},
1903 {"coin": "PURR", "token": 1, "total": "10", "hold": "0", "entryNtl": "5"}
1904 ]
1905 }"#;
1906 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
1907
1908 let (balances, margins) =
1909 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
1910
1911 assert!(margins.is_empty());
1912 assert_eq!(balances.len(), 2);
1913 assert_eq!(balances[0].currency.code.as_str(), "USDC");
1914 assert_eq!(balances[0].total.as_decimal(), dec!(500));
1915 assert_eq!(balances[1].currency.code.as_str(), "PURR");
1916 assert_eq!(balances[1].total.as_decimal(), dec!(10));
1917 }
1918
1919 #[rstest]
1920 fn test_parse_combined_uses_spot_usdc_when_perp_summary_missing() {
1921 let perp_json = r#"{"assetPositions": []}"#;
1922 let perp_state: ClearinghouseState = serde_json::from_str(perp_json).unwrap();
1923
1924 let spot_json = r#"{
1925 "balances": [
1926 {"coin": "USDC", "token": 0, "total": "50", "hold": "0", "entryNtl": "0"}
1927 ]
1928 }"#;
1929 let spot_state: SpotClearinghouseState = serde_json::from_str(spot_json).unwrap();
1930
1931 let (balances, _) =
1932 parse_combined_account_balances_and_margins(&perp_state, &spot_state).unwrap();
1933
1934 assert_eq!(balances.len(), 1);
1935 assert_eq!(balances[0].currency.code.as_str(), "USDC");
1936 assert_eq!(balances[0].total.as_decimal(), dec!(50));
1937 }
1938}