1use anyhow::Context;
77use nautilus_core::UnixNanos;
78pub use nautilus_core::serialization::{
79 deserialize_decimal_from_str, deserialize_optional_decimal_from_str,
80 deserialize_vec_decimal_from_str, serialize_decimal_as_str, serialize_optional_decimal_as_str,
81 serialize_vec_decimal_as_str,
82};
83use nautilus_model::{
84 data::bar::BarType,
85 enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType, TimeInForce},
86 identifiers::{InstrumentId, Symbol, Venue},
87 orders::{Order, any::OrderAny},
88 types::{AccountBalance, Currency, MarginBalance, Money},
89};
90use rust_decimal::Decimal;
91use serde_json::Value;
92
93use crate::{
94 common::enums::{HyperliquidBarInterval, HyperliquidTpSl},
95 http::models::{
96 AssetId, Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
97 HyperliquidExecCancelByCloidRequest, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
98 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
99 HyperliquidExecTriggerParams,
100 },
101 websocket::messages::TrailingOffsetType,
102};
103
104#[inline]
106pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
107 if tick_size.is_zero() {
108 return price;
109 }
110 (price / tick_size).floor() * tick_size
111}
112
113#[inline]
115pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
116 if step_size.is_zero() {
117 return qty;
118 }
119 (qty / step_size).floor() * step_size
120}
121
122#[inline]
124pub fn ensure_min_notional(
125 price: Decimal,
126 qty: Decimal,
127 min_notional: Decimal,
128) -> Result<(), String> {
129 let notional = price * qty;
130 if notional < min_notional {
131 Err(format!(
132 "Notional value {notional} is less than minimum required {min_notional}"
133 ))
134 } else {
135 Ok(())
136 }
137}
138
139pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
141 let scale = Decimal::from(10_u64.pow(decimals as u32));
142 (price * scale).floor() / scale
143}
144
145pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
147 let scale = Decimal::from(10_u64.pow(decimals as u32));
148 (qty * scale).floor() / scale
149}
150
151pub fn normalize_order(
153 price: Decimal,
154 qty: Decimal,
155 tick_size: Decimal,
156 step_size: Decimal,
157 min_notional: Decimal,
158 price_decimals: u8,
159 size_decimals: u8,
160) -> Result<(Decimal, Decimal), String> {
161 let normalized_price = normalize_price(price, price_decimals);
163 let normalized_qty = normalize_quantity(qty, size_decimals);
164
165 let final_price = round_down_to_tick(normalized_price, tick_size);
167 let final_qty = round_down_to_step(normalized_qty, step_size);
168
169 ensure_min_notional(final_price, final_qty, min_notional)?;
171
172 Ok((final_price, final_qty))
173}
174
175pub fn parse_millis_to_nanos(millis: u64) -> UnixNanos {
177 UnixNanos::from(millis * 1_000_000)
178}
179
180pub fn time_in_force_to_hyperliquid_tif(
186 tif: TimeInForce,
187 is_post_only: bool,
188) -> anyhow::Result<HyperliquidExecTif> {
189 match (tif, is_post_only) {
190 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
192 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
193 (TimeInForce::Fok, false) => Ok(HyperliquidExecTif::Ioc), _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
195 }
196}
197
198pub fn extract_asset_id_from_symbol(symbol: &str) -> anyhow::Result<AssetId> {
207 let base = if let Some(base) = symbol.strip_suffix("-PERP") {
209 base.strip_suffix("-USD")
211 .ok_or_else(|| anyhow::anyhow!("Cannot extract asset from symbol: {symbol}"))?
212 } else if let Some(base) = symbol.strip_suffix("-USD") {
213 base
215 } else {
216 anyhow::bail!("Cannot extract asset ID from symbol: {symbol}")
217 };
218
219 Ok(match base {
226 "SOL" => 0, "APT" => 1, "ATOM" => 2, "BTC" => 3, "ETH" => 4, "MATIC" => 5, "BNB" => 6, "AVAX" => 7, "DYDX" => 9, "APE" => 10, "OP" => 11, "kPEPE" => 12, "ARB" => 13, "kSHIB" => 29, "WIF" => 78, "DOGE" => 173, _ => {
243 anyhow::bail!("Asset ID mapping not found for symbol: {symbol}")
245 }
246 })
247}
248
249fn determine_tpsl_type(
262 order_type: OrderType,
263 order_side: OrderSide,
264 trigger_price: Decimal,
265 current_price: Option<Decimal>,
266) -> HyperliquidExecTpSl {
267 match order_type {
268 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
270
271 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
273
274 _ => {
276 if let Some(current) = current_price {
277 match order_side {
278 OrderSide::Buy => {
279 if trigger_price > current {
281 HyperliquidExecTpSl::Sl
282 } else {
283 HyperliquidExecTpSl::Tp
284 }
285 }
286 OrderSide::Sell => {
287 if trigger_price < current {
289 HyperliquidExecTpSl::Sl
290 } else {
291 HyperliquidExecTpSl::Tp
292 }
293 }
294 _ => HyperliquidExecTpSl::Sl, }
296 } else {
297 HyperliquidExecTpSl::Sl
299 }
300 }
301 }
302}
303
304pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
310 use crate::common::enums::HyperliquidBarInterval::{
311 EightHours, FifteenMinutes, FiveMinutes, FourHours, OneDay, OneHour, OneMinute, OneMonth,
312 OneWeek, ThirtyMinutes, ThreeDays, ThreeMinutes, TwelveHours, TwoHours,
313 };
314
315 let spec = bar_type.spec();
316 let step = spec.step.get();
317
318 anyhow::ensure!(
319 bar_type.aggregation_source() == AggregationSource::External,
320 "Only EXTERNAL aggregation is supported"
321 );
322
323 let interval = match spec.aggregation {
324 BarAggregation::Minute => match step {
325 1 => OneMinute,
326 3 => ThreeMinutes,
327 5 => FiveMinutes,
328 15 => FifteenMinutes,
329 30 => ThirtyMinutes,
330 _ => anyhow::bail!("Unsupported minute step: {step}"),
331 },
332 BarAggregation::Hour => match step {
333 1 => OneHour,
334 2 => TwoHours,
335 4 => FourHours,
336 8 => EightHours,
337 12 => TwelveHours,
338 _ => anyhow::bail!("Unsupported hour step: {step}"),
339 },
340 BarAggregation::Day => match step {
341 1 => OneDay,
342 3 => ThreeDays,
343 _ => anyhow::bail!("Unsupported day step: {step}"),
344 },
345 BarAggregation::Week if step == 1 => OneWeek,
346 BarAggregation::Month if step == 1 => OneMonth,
347 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
348 };
349
350 Ok(interval)
351}
352
353pub fn order_to_hyperliquid_request(
377 order: &OrderAny,
378) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
379 let instrument_id = order.instrument_id();
380 let symbol = instrument_id.symbol.as_str();
381 let asset = extract_asset_id_from_symbol(symbol)
382 .with_context(|| format!("Failed to extract asset ID from symbol: {symbol}"))?;
383
384 let is_buy = matches!(order.order_side(), OrderSide::Buy);
385 let reduce_only = order.is_reduce_only();
386 let order_side = order.order_side();
387 let order_type = order.order_type();
388
389 let price_decimal = match order.price() {
391 Some(price) => Decimal::from_str_exact(&price.to_string())
392 .with_context(|| format!("Failed to convert price to decimal: {price}"))?,
393 None => {
394 if matches!(
397 order_type,
398 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
399 ) {
400 Decimal::ZERO
401 } else {
402 anyhow::bail!("Limit orders require a price")
403 }
404 }
405 };
406
407 let size_decimal =
409 Decimal::from_str_exact(&order.quantity().to_string()).with_context(|| {
410 format!(
411 "Failed to convert quantity to decimal: {}",
412 order.quantity()
413 )
414 })?;
415
416 let kind = match order_type {
418 OrderType::Market => {
419 HyperliquidExecOrderKind::Limit {
421 limit: HyperliquidExecLimitParams {
422 tif: HyperliquidExecTif::Ioc,
423 },
424 }
425 }
426 OrderType::Limit => {
427 let tif =
428 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
429 HyperliquidExecOrderKind::Limit {
430 limit: HyperliquidExecLimitParams { tif },
431 }
432 }
433 OrderType::StopMarket => {
434 if let Some(trigger_price) = order.trigger_price() {
435 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
436 .with_context(|| {
437 format!("Failed to convert trigger price to decimal: {trigger_price}")
438 })?;
439
440 let tpsl = determine_tpsl_type(
442 order_type,
443 order_side,
444 trigger_price_decimal,
445 None, );
447
448 HyperliquidExecOrderKind::Trigger {
449 trigger: HyperliquidExecTriggerParams {
450 is_market: true,
451 trigger_px: trigger_price_decimal,
452 tpsl,
453 },
454 }
455 } else {
456 anyhow::bail!("Stop market orders require a trigger price")
457 }
458 }
459 OrderType::StopLimit => {
460 if let Some(trigger_price) = order.trigger_price() {
461 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
462 .with_context(|| {
463 format!("Failed to convert trigger price to decimal: {trigger_price}")
464 })?;
465
466 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
468
469 HyperliquidExecOrderKind::Trigger {
470 trigger: HyperliquidExecTriggerParams {
471 is_market: false,
472 trigger_px: trigger_price_decimal,
473 tpsl,
474 },
475 }
476 } else {
477 anyhow::bail!("Stop limit orders require a trigger price")
478 }
479 }
480 OrderType::MarketIfTouched => {
481 if let Some(trigger_price) = order.trigger_price() {
484 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
485 .with_context(|| {
486 format!("Failed to convert trigger price to decimal: {trigger_price}")
487 })?;
488
489 HyperliquidExecOrderKind::Trigger {
490 trigger: HyperliquidExecTriggerParams {
491 is_market: true,
492 trigger_px: trigger_price_decimal,
493 tpsl: HyperliquidExecTpSl::Tp, },
495 }
496 } else {
497 anyhow::bail!("Market-if-touched orders require a trigger price")
498 }
499 }
500 OrderType::LimitIfTouched => {
501 if let Some(trigger_price) = order.trigger_price() {
504 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
505 .with_context(|| {
506 format!("Failed to convert trigger price to decimal: {trigger_price}")
507 })?;
508
509 HyperliquidExecOrderKind::Trigger {
510 trigger: HyperliquidExecTriggerParams {
511 is_market: false,
512 trigger_px: trigger_price_decimal,
513 tpsl: HyperliquidExecTpSl::Tp, },
515 }
516 } else {
517 anyhow::bail!("Limit-if-touched orders require a trigger price")
518 }
519 }
520 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
521 };
522
523 let cloid = match Cloid::from_hex(order.client_order_id()) {
525 Ok(cloid) => Some(cloid),
526 Err(e) => {
527 anyhow::bail!(
528 "Failed to convert client order ID '{}' to CLOID: {}",
529 order.client_order_id(),
530 e
531 )
532 }
533 };
534
535 Ok(HyperliquidExecPlaceOrderRequest {
536 asset,
537 is_buy,
538 price: price_decimal,
539 size: size_decimal,
540 reduce_only,
541 kind,
542 cloid,
543 })
544}
545
546pub fn orders_to_hyperliquid_requests(
548 orders: &[&OrderAny],
549) -> anyhow::Result<Vec<HyperliquidExecPlaceOrderRequest>> {
550 orders
551 .iter()
552 .map(|order| order_to_hyperliquid_request(order))
553 .collect()
554}
555
556pub fn orders_to_hyperliquid_action_value(orders: &[&OrderAny]) -> anyhow::Result<Value> {
558 let requests = orders_to_hyperliquid_requests(orders)?;
559 serde_json::to_value(requests).context("failed to serialize orders to JSON")
560}
561
562pub fn order_any_to_hyperliquid_request(
564 order: &OrderAny,
565) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
566 order_to_hyperliquid_request(order)
567}
568
569pub fn client_order_id_to_cancel_request(
575 client_order_id: &str,
576 symbol: &str,
577) -> anyhow::Result<HyperliquidExecCancelByCloidRequest> {
578 let asset = extract_asset_id_from_symbol(symbol)
579 .with_context(|| format!("Failed to extract asset ID from symbol: {symbol}"))?;
580
581 let cloid = Cloid::from_hex(client_order_id).map_err(|e| {
582 anyhow::anyhow!("Failed to convert client order ID '{client_order_id}' to CLOID: {e}")
583 })?;
584
585 Ok(HyperliquidExecCancelByCloidRequest { asset, cloid })
586}
587
588pub fn is_response_successful(response: &HyperliquidExchangeResponse) -> bool {
590 matches!(response, HyperliquidExchangeResponse::Status { status, .. } if status == "ok")
591}
592
593pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
595 match response {
596 HyperliquidExchangeResponse::Status { status, response } => {
597 if status == "ok" {
598 "Operation successful".to_string()
599 } else {
600 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
602 error_msg.to_string()
603 } else {
604 format!("Request failed with status: {status}")
605 }
606 }
607 }
608 HyperliquidExchangeResponse::Error { error } => error.clone(),
609 }
610}
611
612pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
618 trigger_px.is_some() && tpsl.is_some()
619}
620
621pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
627 match (is_market, tpsl) {
628 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
629 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
630 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
631 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
632 }
633}
634
635pub fn parse_order_status_with_trigger(
641 status: &str,
642 trigger_activated: Option<bool>,
643) -> (OrderStatus, Option<String>) {
644 use crate::common::enums::hyperliquid_status_to_order_status;
645
646 let base_status = hyperliquid_status_to_order_status(status);
647
648 if let Some(activated) = trigger_activated {
650 let trigger_status = if activated {
651 Some("activated".to_string())
652 } else {
653 Some("pending".to_string())
654 };
655 (base_status, trigger_status)
656 } else {
657 (base_status, None)
658 }
659}
660
661pub fn format_trailing_stop_info(
663 offset: &str,
664 offset_type: TrailingOffsetType,
665 callback_price: Option<&str>,
666) -> String {
667 let offset_desc = offset_type.format_offset(offset);
668
669 if let Some(callback) = callback_price {
670 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
671 } else {
672 format!("Trailing stop: {offset_desc} offset")
673 }
674}
675
676pub fn validate_conditional_order_params(
686 trigger_px: Option<&str>,
687 tpsl: Option<&HyperliquidTpSl>,
688 is_market: Option<bool>,
689) -> anyhow::Result<()> {
690 if trigger_px.is_none() {
691 anyhow::bail!("Conditional order missing trigger price");
692 }
693
694 if tpsl.is_none() {
695 anyhow::bail!("Conditional order missing tpsl indicator");
696 }
697
698 if is_market.is_none() {
701 anyhow::bail!("Conditional order missing is_market flag");
702 }
703
704 Ok(())
705}
706
707pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
713 Decimal::from_str_exact(trigger_px)
714 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
715}
716
717pub fn parse_account_balances_and_margins(
723 cross_margin_summary: &CrossMarginSummary,
724) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
725 let mut balances = Vec::new();
726 let mut margins = Vec::new();
727
728 let currency = Currency::USD(); let total_value = cross_margin_summary
733 .account_value
734 .to_string()
735 .parse::<f64>()?;
736
737 let withdrawable = cross_margin_summary
739 .withdrawable
740 .to_string()
741 .parse::<f64>()?;
742
743 let margin_used = cross_margin_summary
745 .total_margin_used
746 .to_string()
747 .parse::<f64>()?;
748
749 let total = Money::new(total_value, currency);
751 let locked = Money::new(margin_used, currency);
752 let free = Money::new(withdrawable, currency);
753
754 let balance = AccountBalance::new(total, locked, free);
755 balances.push(balance);
756
757 if margin_used > 0.0 {
762 let margin_instrument_id =
763 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
764
765 let initial_margin = Money::new(margin_used, currency);
766 let maintenance_margin = Money::new(margin_used, currency);
767
768 let margin_balance =
769 MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
770
771 margins.push(margin_balance);
772 }
773
774 Ok((balances, margins))
775}
776
777#[cfg(test)]
778mod tests {
779 use std::str::FromStr;
780
781 use rstest::rstest;
782 use rust_decimal::Decimal;
783 use serde::{Deserialize, Serialize};
784
785 use super::*;
786
787 #[derive(Serialize, Deserialize)]
788 struct TestStruct {
789 #[serde(
790 serialize_with = "serialize_decimal_as_str",
791 deserialize_with = "deserialize_decimal_from_str"
792 )]
793 value: Decimal,
794 #[serde(
795 serialize_with = "serialize_optional_decimal_as_str",
796 deserialize_with = "deserialize_optional_decimal_from_str"
797 )]
798 optional_value: Option<Decimal>,
799 }
800
801 #[rstest]
802 fn test_decimal_serialization_roundtrip() {
803 let original = TestStruct {
804 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
805 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
806 };
807
808 let json = serde_json::to_string(&original).unwrap();
809 println!("Serialized: {json}");
810
811 assert!(json.contains("\"123.45678901234567890123456789\""));
813 assert!(json.contains("\"0.000000001\""));
814
815 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
816 assert_eq!(original.value, deserialized.value);
817 assert_eq!(original.optional_value, deserialized.optional_value);
818 }
819
820 #[rstest]
821 fn test_decimal_precision_preservation() {
822 let test_cases = [
823 "0",
824 "1",
825 "0.1",
826 "0.01",
827 "0.001",
828 "123.456789012345678901234567890",
829 "999999999999999999.999999999999999999",
830 ];
831
832 for case in test_cases {
833 let decimal = Decimal::from_str(case).unwrap();
834 let test_struct = TestStruct {
835 value: decimal,
836 optional_value: Some(decimal),
837 };
838
839 let json = serde_json::to_string(&test_struct).unwrap();
840 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
841
842 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
843 assert_eq!(
844 Some(decimal),
845 parsed.optional_value,
846 "Failed for case: {case}"
847 );
848 }
849 }
850
851 #[rstest]
852 fn test_optional_none_handling() {
853 let test_struct = TestStruct {
854 value: Decimal::from_str("42.0").unwrap(),
855 optional_value: None,
856 };
857
858 let json = serde_json::to_string(&test_struct).unwrap();
859 assert!(json.contains("null"));
860
861 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
862 assert_eq!(test_struct.value, parsed.value);
863 assert_eq!(None, parsed.optional_value);
864 }
865
866 #[rstest]
867 fn test_round_down_to_tick() {
868 use rust_decimal_macros::dec;
869
870 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
871 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
872 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
873
874 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
876 }
877
878 #[rstest]
879 fn test_round_down_to_step() {
880 use rust_decimal_macros::dec;
881
882 assert_eq!(
883 round_down_to_step(dec!(0.12349), dec!(0.0001)),
884 dec!(0.1234)
885 );
886 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
887 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
888
889 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
891 }
892
893 #[rstest]
894 fn test_min_notional_validation() {
895 use rust_decimal_macros::dec;
896
897 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
899 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
900
901 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
903 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
904
905 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
907 }
908
909 #[rstest]
910 fn test_normalize_price() {
911 use rust_decimal_macros::dec;
912
913 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
914 assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.19));
915 assert_eq!(normalize_price(dec!(100.999), 0), dec!(100));
916 assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.1234));
917 }
918
919 #[rstest]
920 fn test_normalize_quantity() {
921 use rust_decimal_macros::dec;
922
923 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
924 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
925 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
926 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
927 }
928
929 #[rstest]
930 fn test_normalize_order_complete() {
931 use rust_decimal_macros::dec;
932
933 let result = normalize_order(
934 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
942
943 assert!(result.is_ok());
944 let (price, qty) = result.unwrap();
945 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
948
949 #[rstest]
950 fn test_normalize_order_min_notional_fail() {
951 use rust_decimal_macros::dec;
952
953 let result = normalize_order(
954 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
962
963 assert!(result.is_err());
964 assert!(result.unwrap_err().contains("Notional value"));
965 }
966
967 #[rstest]
968 fn test_edge_cases() {
969 use rust_decimal_macros::dec;
970
971 assert_eq!(
973 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
974 dec!(0.000001)
975 );
976
977 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
979
980 assert_eq!(
982 round_down_to_tick(dec!(100.009999), dec!(0.01)),
983 dec!(100.00)
984 );
985 }
986
987 #[rstest]
988 fn test_is_conditional_order_data() {
989 assert!(is_conditional_order_data(
991 Some("50000.0"),
992 Some(&HyperliquidTpSl::Sl)
993 ));
994
995 assert!(!is_conditional_order_data(Some("50000.0"), None));
997
998 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1000
1001 assert!(!is_conditional_order_data(None, None));
1003 }
1004
1005 #[rstest]
1006 fn test_parse_trigger_order_type() {
1007 assert_eq!(
1009 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1010 OrderType::StopMarket
1011 );
1012
1013 assert_eq!(
1015 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1016 OrderType::StopLimit
1017 );
1018
1019 assert_eq!(
1021 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1022 OrderType::MarketIfTouched
1023 );
1024
1025 assert_eq!(
1027 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1028 OrderType::LimitIfTouched
1029 );
1030 }
1031
1032 #[rstest]
1033 fn test_parse_order_status_with_trigger() {
1034 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(true));
1036 assert_eq!(status, OrderStatus::Accepted);
1037 assert_eq!(trigger_status, Some("activated".to_string()));
1038
1039 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(false));
1041 assert_eq!(status, OrderStatus::Accepted);
1042 assert_eq!(trigger_status, Some("pending".to_string()));
1043
1044 let (status, trigger_status) = parse_order_status_with_trigger("open", None);
1046 assert_eq!(status, OrderStatus::Accepted);
1047 assert_eq!(trigger_status, None);
1048 }
1049
1050 #[rstest]
1051 fn test_format_trailing_stop_info() {
1052 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1054 assert!(info.contains("100.0"));
1055 assert!(info.contains("callback at 50000.0"));
1056
1057 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1059 assert!(info.contains("5.0%"));
1060 assert!(info.contains("Trailing stop"));
1061
1062 let info =
1064 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1065 assert!(info.contains("250 bps"));
1066 assert!(info.contains("49000.0"));
1067 }
1068
1069 #[rstest]
1070 fn test_parse_trigger_price() {
1071 use rust_decimal_macros::dec;
1072
1073 let result = parse_trigger_price("50000.0");
1075 assert!(result.is_ok());
1076 assert_eq!(result.unwrap(), dec!(50000.0));
1077
1078 let result = parse_trigger_price("49000");
1080 assert!(result.is_ok());
1081 assert_eq!(result.unwrap(), dec!(49000));
1082
1083 let result = parse_trigger_price("invalid");
1085 assert!(result.is_err());
1086
1087 let result = parse_trigger_price("");
1089 assert!(result.is_err());
1090 }
1091}