1use std::str::FromStr;
77
78use anyhow::Context;
79use nautilus_core::UnixNanos;
80use nautilus_model::{
81 data::bar::BarType,
82 enums::{AggregationSource, BarAggregation, OrderSide, OrderStatus, OrderType, TimeInForce},
83 identifiers::{InstrumentId, Symbol, Venue},
84 orders::{Order, any::OrderAny},
85 types::{AccountBalance, Currency, MarginBalance, Money},
86};
87use rust_decimal::Decimal;
88use serde::{Deserialize, Deserializer, Serializer};
89use serde_json::Value;
90
91use crate::{
92 common::enums::{HyperliquidBarInterval, HyperliquidTpSl},
93 http::models::{
94 AssetId, Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
95 HyperliquidExecCancelByCloidRequest, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
96 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
97 HyperliquidExecTriggerParams,
98 },
99 websocket::messages::TrailingOffsetType,
100};
101
102pub fn serialize_decimal_as_str<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
104where
105 S: Serializer,
106{
107 serializer.serialize_str(&decimal.normalize().to_string())
108}
109
110pub fn deserialize_decimal_from_str<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
112where
113 D: Deserializer<'de>,
114{
115 let s = String::deserialize(deserializer)?;
116 Decimal::from_str(&s).map_err(serde::de::Error::custom)
117}
118
119pub fn serialize_optional_decimal_as_str<S>(
121 decimal: &Option<Decimal>,
122 serializer: S,
123) -> Result<S::Ok, S::Error>
124where
125 S: Serializer,
126{
127 match decimal {
128 Some(d) => serializer.serialize_str(&d.normalize().to_string()),
129 None => serializer.serialize_none(),
130 }
131}
132
133pub fn deserialize_optional_decimal_from_str<'de, D>(
135 deserializer: D,
136) -> Result<Option<Decimal>, D::Error>
137where
138 D: Deserializer<'de>,
139{
140 let opt = Option::<String>::deserialize(deserializer)?;
141 match opt {
142 Some(s) => {
143 let decimal = Decimal::from_str(&s).map_err(serde::de::Error::custom)?;
144 Ok(Some(decimal))
145 }
146 None => Ok(None),
147 }
148}
149
150pub fn serialize_vec_decimal_as_str<S>(
152 decimals: &Vec<Decimal>,
153 serializer: S,
154) -> Result<S::Ok, S::Error>
155where
156 S: Serializer,
157{
158 use serde::ser::SerializeSeq;
159 let mut seq = serializer.serialize_seq(Some(decimals.len()))?;
160 for decimal in decimals {
161 seq.serialize_element(&decimal.normalize().to_string())?;
162 }
163 seq.end()
164}
165
166pub fn deserialize_vec_decimal_from_str<'de, D>(deserializer: D) -> Result<Vec<Decimal>, D::Error>
168where
169 D: Deserializer<'de>,
170{
171 let strings = Vec::<String>::deserialize(deserializer)?;
172 strings
173 .into_iter()
174 .map(|s| Decimal::from_str(&s).map_err(serde::de::Error::custom))
175 .collect()
176}
177
178#[inline]
180pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
181 if tick_size.is_zero() {
182 return price;
183 }
184 (price / tick_size).floor() * tick_size
185}
186
187#[inline]
189pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
190 if step_size.is_zero() {
191 return qty;
192 }
193 (qty / step_size).floor() * step_size
194}
195
196#[inline]
198pub fn ensure_min_notional(
199 price: Decimal,
200 qty: Decimal,
201 min_notional: Decimal,
202) -> Result<(), String> {
203 let notional = price * qty;
204 if notional < min_notional {
205 Err(format!(
206 "Notional value {} is less than minimum required {}",
207 notional, min_notional
208 ))
209 } else {
210 Ok(())
211 }
212}
213
214pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
216 let scale = Decimal::from(10_u64.pow(decimals as u32));
217 (price * scale).floor() / scale
218}
219
220pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
222 let scale = Decimal::from(10_u64.pow(decimals as u32));
223 (qty * scale).floor() / scale
224}
225
226pub fn normalize_order(
228 price: Decimal,
229 qty: Decimal,
230 tick_size: Decimal,
231 step_size: Decimal,
232 min_notional: Decimal,
233 price_decimals: u8,
234 size_decimals: u8,
235) -> Result<(Decimal, Decimal), String> {
236 let normalized_price = normalize_price(price, price_decimals);
238 let normalized_qty = normalize_quantity(qty, size_decimals);
239
240 let final_price = round_down_to_tick(normalized_price, tick_size);
242 let final_qty = round_down_to_step(normalized_qty, step_size);
243
244 ensure_min_notional(final_price, final_qty, min_notional)?;
246
247 Ok((final_price, final_qty))
248}
249
250pub fn parse_millis_to_nanos(millis: u64) -> UnixNanos {
252 UnixNanos::from(millis * 1_000_000)
253}
254
255pub fn time_in_force_to_hyperliquid_tif(
261 tif: TimeInForce,
262 is_post_only: bool,
263) -> anyhow::Result<HyperliquidExecTif> {
264 match (tif, is_post_only) {
265 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
267 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
268 (TimeInForce::Fok, false) => Ok(HyperliquidExecTif::Ioc), _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
270 }
271}
272
273pub fn extract_asset_id_from_symbol(symbol: &str) -> anyhow::Result<AssetId> {
282 let base = if let Some(base) = symbol.strip_suffix("-PERP") {
284 base.strip_suffix("-USD")
286 .ok_or_else(|| anyhow::anyhow!("Cannot extract asset from symbol: {symbol}"))?
287 } else if let Some(base) = symbol.strip_suffix("-USD") {
288 base
290 } else {
291 anyhow::bail!("Cannot extract asset ID from symbol: {symbol}")
292 };
293
294 Ok(match base {
301 "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, _ => {
318 anyhow::bail!("Asset ID mapping not found for symbol: {symbol}")
320 }
321 })
322}
323
324fn determine_tpsl_type(
337 order_type: OrderType,
338 order_side: OrderSide,
339 trigger_price: Decimal,
340 current_price: Option<Decimal>,
341) -> HyperliquidExecTpSl {
342 match order_type {
343 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
345
346 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
348
349 _ => {
351 if let Some(current) = current_price {
352 match order_side {
353 OrderSide::Buy => {
354 if trigger_price > current {
356 HyperliquidExecTpSl::Sl
357 } else {
358 HyperliquidExecTpSl::Tp
359 }
360 }
361 OrderSide::Sell => {
362 if trigger_price < current {
364 HyperliquidExecTpSl::Sl
365 } else {
366 HyperliquidExecTpSl::Tp
367 }
368 }
369 _ => HyperliquidExecTpSl::Sl, }
371 } else {
372 HyperliquidExecTpSl::Sl
374 }
375 }
376 }
377}
378
379pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
385 use crate::common::enums::HyperliquidBarInterval::{
386 EightHours, FifteenMinutes, FiveMinutes, FourHours, OneDay, OneHour, OneMinute, OneMonth,
387 OneWeek, ThirtyMinutes, ThreeDays, ThreeMinutes, TwelveHours, TwoHours,
388 };
389
390 let spec = bar_type.spec();
391 let step = spec.step.get();
392
393 anyhow::ensure!(
394 bar_type.aggregation_source() == AggregationSource::External,
395 "Only EXTERNAL aggregation is supported"
396 );
397
398 let interval = match spec.aggregation {
399 BarAggregation::Minute => match step {
400 1 => OneMinute,
401 3 => ThreeMinutes,
402 5 => FiveMinutes,
403 15 => FifteenMinutes,
404 30 => ThirtyMinutes,
405 _ => anyhow::bail!("Unsupported minute step: {step}"),
406 },
407 BarAggregation::Hour => match step {
408 1 => OneHour,
409 2 => TwoHours,
410 4 => FourHours,
411 8 => EightHours,
412 12 => TwelveHours,
413 _ => anyhow::bail!("Unsupported hour step: {step}"),
414 },
415 BarAggregation::Day => match step {
416 1 => OneDay,
417 3 => ThreeDays,
418 _ => anyhow::bail!("Unsupported day step: {step}"),
419 },
420 BarAggregation::Week if step == 1 => OneWeek,
421 BarAggregation::Month if step == 1 => OneMonth,
422 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
423 };
424
425 Ok(interval)
426}
427
428pub fn order_to_hyperliquid_request(
452 order: &OrderAny,
453) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
454 let instrument_id = order.instrument_id();
455 let symbol = instrument_id.symbol.as_str();
456 let asset = extract_asset_id_from_symbol(symbol)
457 .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
458
459 let is_buy = matches!(order.order_side(), OrderSide::Buy);
460 let reduce_only = order.is_reduce_only();
461 let order_side = order.order_side();
462 let order_type = order.order_type();
463
464 let price_decimal = match order.price() {
466 Some(price) => Decimal::from_str_exact(&price.to_string())
467 .with_context(|| format!("Failed to convert price to decimal: {}", price))?,
468 None => {
469 if matches!(
472 order_type,
473 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
474 ) {
475 Decimal::ZERO
476 } else {
477 anyhow::bail!("Limit orders require a price")
478 }
479 }
480 };
481
482 let size_decimal =
484 Decimal::from_str_exact(&order.quantity().to_string()).with_context(|| {
485 format!(
486 "Failed to convert quantity to decimal: {}",
487 order.quantity()
488 )
489 })?;
490
491 let kind = match order_type {
493 OrderType::Market => {
494 HyperliquidExecOrderKind::Limit {
496 limit: HyperliquidExecLimitParams {
497 tif: HyperliquidExecTif::Ioc,
498 },
499 }
500 }
501 OrderType::Limit => {
502 let tif =
503 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
504 HyperliquidExecOrderKind::Limit {
505 limit: HyperliquidExecLimitParams { tif },
506 }
507 }
508 OrderType::StopMarket => {
509 if let Some(trigger_price) = order.trigger_price() {
510 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
511 .with_context(|| {
512 format!(
513 "Failed to convert trigger price to decimal: {}",
514 trigger_price
515 )
516 })?;
517
518 let tpsl = determine_tpsl_type(
520 order_type,
521 order_side,
522 trigger_price_decimal,
523 None, );
525
526 HyperliquidExecOrderKind::Trigger {
527 trigger: HyperliquidExecTriggerParams {
528 is_market: true,
529 trigger_px: trigger_price_decimal,
530 tpsl,
531 },
532 }
533 } else {
534 anyhow::bail!("Stop market orders require a trigger price")
535 }
536 }
537 OrderType::StopLimit => {
538 if let Some(trigger_price) = order.trigger_price() {
539 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
540 .with_context(|| {
541 format!(
542 "Failed to convert trigger price to decimal: {}",
543 trigger_price
544 )
545 })?;
546
547 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
549
550 HyperliquidExecOrderKind::Trigger {
551 trigger: HyperliquidExecTriggerParams {
552 is_market: false,
553 trigger_px: trigger_price_decimal,
554 tpsl,
555 },
556 }
557 } else {
558 anyhow::bail!("Stop limit orders require a trigger price")
559 }
560 }
561 OrderType::MarketIfTouched => {
562 if let Some(trigger_price) = order.trigger_price() {
565 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
566 .with_context(|| {
567 format!(
568 "Failed to convert trigger price to decimal: {}",
569 trigger_price
570 )
571 })?;
572
573 HyperliquidExecOrderKind::Trigger {
574 trigger: HyperliquidExecTriggerParams {
575 is_market: true,
576 trigger_px: trigger_price_decimal,
577 tpsl: HyperliquidExecTpSl::Tp, },
579 }
580 } else {
581 anyhow::bail!("Market-if-touched orders require a trigger price")
582 }
583 }
584 OrderType::LimitIfTouched => {
585 if let Some(trigger_price) = order.trigger_price() {
588 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
589 .with_context(|| {
590 format!(
591 "Failed to convert trigger price to decimal: {}",
592 trigger_price
593 )
594 })?;
595
596 HyperliquidExecOrderKind::Trigger {
597 trigger: HyperliquidExecTriggerParams {
598 is_market: false,
599 trigger_px: trigger_price_decimal,
600 tpsl: HyperliquidExecTpSl::Tp, },
602 }
603 } else {
604 anyhow::bail!("Limit-if-touched orders require a trigger price")
605 }
606 }
607 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {:?}", order_type),
608 };
609
610 let cloid = match Cloid::from_hex(order.client_order_id()) {
612 Ok(cloid) => Some(cloid),
613 Err(e) => {
614 anyhow::bail!(
615 "Failed to convert client order ID '{}' to CLOID: {}",
616 order.client_order_id(),
617 e
618 )
619 }
620 };
621
622 Ok(HyperliquidExecPlaceOrderRequest {
623 asset,
624 is_buy,
625 price: price_decimal,
626 size: size_decimal,
627 reduce_only,
628 kind,
629 cloid,
630 })
631}
632
633pub fn orders_to_hyperliquid_requests(
635 orders: &[&OrderAny],
636) -> anyhow::Result<Vec<HyperliquidExecPlaceOrderRequest>> {
637 orders
638 .iter()
639 .map(|order| order_to_hyperliquid_request(order))
640 .collect()
641}
642
643pub fn orders_to_hyperliquid_action_value(orders: &[&OrderAny]) -> anyhow::Result<Value> {
645 let requests = orders_to_hyperliquid_requests(orders)?;
646 serde_json::to_value(requests).context("failed to serialize orders to JSON")
647}
648
649pub fn order_any_to_hyperliquid_request(
651 order: &OrderAny,
652) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
653 order_to_hyperliquid_request(order)
654}
655
656pub fn client_order_id_to_cancel_request(
662 client_order_id: &str,
663 symbol: &str,
664) -> anyhow::Result<HyperliquidExecCancelByCloidRequest> {
665 let asset = extract_asset_id_from_symbol(symbol)
666 .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
667
668 let cloid = Cloid::from_hex(client_order_id).map_err(|e| {
669 anyhow::anyhow!(
670 "Failed to convert client order ID '{}' to CLOID: {}",
671 client_order_id,
672 e
673 )
674 })?;
675
676 Ok(HyperliquidExecCancelByCloidRequest { asset, cloid })
677}
678
679pub fn is_response_successful(response: &HyperliquidExchangeResponse) -> bool {
681 matches!(response, HyperliquidExchangeResponse::Status { status, .. } if status == "ok")
682}
683
684pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
686 match response {
687 HyperliquidExchangeResponse::Status { status, response } => {
688 if status == "ok" {
689 "Operation successful".to_string()
690 } else {
691 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
693 error_msg.to_string()
694 } else {
695 format!("Request failed with status: {}", status)
696 }
697 }
698 }
699 HyperliquidExchangeResponse::Error { error } => error.clone(),
700 }
701}
702
703pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
709 trigger_px.is_some() && tpsl.is_some()
710}
711
712pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
718 match (is_market, tpsl) {
719 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
720 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
721 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
722 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
723 }
724}
725
726pub fn parse_order_status_with_trigger(
732 status: &str,
733 trigger_activated: Option<bool>,
734) -> (OrderStatus, Option<String>) {
735 use crate::common::enums::hyperliquid_status_to_order_status;
736
737 let base_status = hyperliquid_status_to_order_status(status);
738
739 if let Some(activated) = trigger_activated {
741 let trigger_status = if activated {
742 Some("activated".to_string())
743 } else {
744 Some("pending".to_string())
745 };
746 (base_status, trigger_status)
747 } else {
748 (base_status, None)
749 }
750}
751
752pub fn format_trailing_stop_info(
754 offset: &str,
755 offset_type: TrailingOffsetType,
756 callback_price: Option<&str>,
757) -> String {
758 let offset_desc = offset_type.format_offset(offset);
759
760 if let Some(callback) = callback_price {
761 format!(
762 "Trailing stop: {} offset, callback at {}",
763 offset_desc, callback
764 )
765 } else {
766 format!("Trailing stop: {} offset", offset_desc)
767 }
768}
769
770pub fn validate_conditional_order_params(
780 trigger_px: Option<&str>,
781 tpsl: Option<&HyperliquidTpSl>,
782 is_market: Option<bool>,
783) -> anyhow::Result<()> {
784 if trigger_px.is_none() {
785 anyhow::bail!("Conditional order missing trigger price");
786 }
787
788 if tpsl.is_none() {
789 anyhow::bail!("Conditional order missing tpsl indicator");
790 }
791
792 if is_market.is_none() {
795 anyhow::bail!("Conditional order missing is_market flag");
796 }
797
798 Ok(())
799}
800
801pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
807 Decimal::from_str_exact(trigger_px)
808 .with_context(|| format!("Failed to parse trigger price: {}", trigger_px))
809}
810
811pub fn parse_account_balances_and_margins(
817 cross_margin_summary: &CrossMarginSummary,
818) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
819 let mut balances = Vec::new();
820 let mut margins = Vec::new();
821
822 let currency = Currency::USD(); let total_value = cross_margin_summary
827 .account_value
828 .to_string()
829 .parse::<f64>()?;
830
831 let withdrawable = cross_margin_summary
833 .withdrawable
834 .to_string()
835 .parse::<f64>()?;
836
837 let margin_used = cross_margin_summary
839 .total_margin_used
840 .to_string()
841 .parse::<f64>()?;
842
843 let total = Money::new(total_value, currency);
845 let locked = Money::new(margin_used, currency);
846 let free = Money::new(withdrawable, currency);
847
848 let balance = AccountBalance::new(total, locked, free);
849 balances.push(balance);
850
851 if margin_used > 0.0 {
856 let margin_instrument_id =
857 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
858
859 let initial_margin = Money::new(margin_used, currency);
860 let maintenance_margin = Money::new(margin_used, currency);
861
862 let margin_balance =
863 MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
864
865 margins.push(margin_balance);
866 }
867
868 Ok((balances, margins))
869}
870
871#[cfg(test)]
876mod tests {
877 use rstest::rstest;
878 use serde::{Deserialize, Serialize};
879
880 use super::*;
881
882 #[derive(Serialize, Deserialize)]
883 struct TestStruct {
884 #[serde(
885 serialize_with = "serialize_decimal_as_str",
886 deserialize_with = "deserialize_decimal_from_str"
887 )]
888 value: Decimal,
889 #[serde(
890 serialize_with = "serialize_optional_decimal_as_str",
891 deserialize_with = "deserialize_optional_decimal_from_str"
892 )]
893 optional_value: Option<Decimal>,
894 }
895
896 #[rstest]
897 fn test_decimal_serialization_roundtrip() {
898 let original = TestStruct {
899 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
900 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
901 };
902
903 let json = serde_json::to_string(&original).unwrap();
904 println!("Serialized: {}", json);
905
906 assert!(json.contains("\"123.45678901234567890123456789\""));
908 assert!(json.contains("\"0.000000001\""));
909
910 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
911 assert_eq!(original.value, deserialized.value);
912 assert_eq!(original.optional_value, deserialized.optional_value);
913 }
914
915 #[rstest]
916 fn test_decimal_precision_preservation() {
917 let test_cases = [
918 "0",
919 "1",
920 "0.1",
921 "0.01",
922 "0.001",
923 "123.456789012345678901234567890",
924 "999999999999999999.999999999999999999",
925 ];
926
927 for case in test_cases {
928 let decimal = Decimal::from_str(case).unwrap();
929 let test_struct = TestStruct {
930 value: decimal,
931 optional_value: Some(decimal),
932 };
933
934 let json = serde_json::to_string(&test_struct).unwrap();
935 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
936
937 assert_eq!(decimal, parsed.value, "Failed for case: {}", case);
938 assert_eq!(
939 Some(decimal),
940 parsed.optional_value,
941 "Failed for case: {}",
942 case
943 );
944 }
945 }
946
947 #[rstest]
948 fn test_optional_none_handling() {
949 let test_struct = TestStruct {
950 value: Decimal::from_str("42.0").unwrap(),
951 optional_value: None,
952 };
953
954 let json = serde_json::to_string(&test_struct).unwrap();
955 assert!(json.contains("null"));
956
957 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
958 assert_eq!(test_struct.value, parsed.value);
959 assert_eq!(None, parsed.optional_value);
960 }
961
962 #[rstest]
963 fn test_round_down_to_tick() {
964 use rust_decimal_macros::dec;
965
966 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
967 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
968 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
969
970 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
972 }
973
974 #[rstest]
975 fn test_round_down_to_step() {
976 use rust_decimal_macros::dec;
977
978 assert_eq!(
979 round_down_to_step(dec!(0.12349), dec!(0.0001)),
980 dec!(0.1234)
981 );
982 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
983 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
984
985 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
987 }
988
989 #[rstest]
990 fn test_min_notional_validation() {
991 use rust_decimal_macros::dec;
992
993 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
995 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
996
997 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
999 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1000
1001 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1003 }
1004
1005 #[rstest]
1006 fn test_normalize_price() {
1007 use rust_decimal_macros::dec;
1008
1009 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1010 assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.19));
1011 assert_eq!(normalize_price(dec!(100.999), 0), dec!(100));
1012 assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.1234));
1013 }
1014
1015 #[rstest]
1016 fn test_normalize_quantity() {
1017 use rust_decimal_macros::dec;
1018
1019 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1020 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1021 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1022 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1023 }
1024
1025 #[rstest]
1026 fn test_normalize_order_complete() {
1027 use rust_decimal_macros::dec;
1028
1029 let result = normalize_order(
1030 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1038
1039 assert!(result.is_ok());
1040 let (price, qty) = result.unwrap();
1041 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1044
1045 #[rstest]
1046 fn test_normalize_order_min_notional_fail() {
1047 use rust_decimal_macros::dec;
1048
1049 let result = normalize_order(
1050 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1058
1059 assert!(result.is_err());
1060 assert!(result.unwrap_err().contains("Notional value"));
1061 }
1062
1063 #[rstest]
1064 fn test_edge_cases() {
1065 use rust_decimal_macros::dec;
1066
1067 assert_eq!(
1069 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1070 dec!(0.000001)
1071 );
1072
1073 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1075
1076 assert_eq!(
1078 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1079 dec!(100.00)
1080 );
1081 }
1082
1083 #[rstest]
1088 fn test_is_conditional_order_data() {
1089 assert!(is_conditional_order_data(
1091 Some("50000.0"),
1092 Some(&HyperliquidTpSl::Sl)
1093 ));
1094
1095 assert!(!is_conditional_order_data(Some("50000.0"), None));
1097
1098 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1100
1101 assert!(!is_conditional_order_data(None, None));
1103 }
1104
1105 #[rstest]
1106 fn test_parse_trigger_order_type() {
1107 assert_eq!(
1109 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1110 OrderType::StopMarket
1111 );
1112
1113 assert_eq!(
1115 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1116 OrderType::StopLimit
1117 );
1118
1119 assert_eq!(
1121 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1122 OrderType::MarketIfTouched
1123 );
1124
1125 assert_eq!(
1127 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1128 OrderType::LimitIfTouched
1129 );
1130 }
1131
1132 #[rstest]
1133 fn test_parse_order_status_with_trigger() {
1134 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(true));
1136 assert_eq!(status, OrderStatus::Accepted);
1137 assert_eq!(trigger_status, Some("activated".to_string()));
1138
1139 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(false));
1141 assert_eq!(status, OrderStatus::Accepted);
1142 assert_eq!(trigger_status, Some("pending".to_string()));
1143
1144 let (status, trigger_status) = parse_order_status_with_trigger("open", None);
1146 assert_eq!(status, OrderStatus::Accepted);
1147 assert_eq!(trigger_status, None);
1148 }
1149
1150 #[rstest]
1151 fn test_format_trailing_stop_info() {
1152 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1154 assert!(info.contains("100.0"));
1155 assert!(info.contains("callback at 50000.0"));
1156
1157 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1159 assert!(info.contains("5.0%"));
1160 assert!(info.contains("Trailing stop"));
1161
1162 let info =
1164 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1165 assert!(info.contains("250 bps"));
1166 assert!(info.contains("49000.0"));
1167 }
1168
1169 #[rstest]
1170 fn test_parse_trigger_price() {
1171 use rust_decimal_macros::dec;
1172
1173 let result = parse_trigger_price("50000.0");
1175 assert!(result.is_ok());
1176 assert_eq!(result.unwrap(), dec!(50000.0));
1177
1178 let result = parse_trigger_price("49000");
1180 assert!(result.is_ok());
1181 assert_eq!(result.unwrap(), dec!(49000));
1182
1183 let result = parse_trigger_price("invalid");
1185 assert!(result.is_err());
1186
1187 let result = parse_trigger_price("");
1189 assert!(result.is_err());
1190 }
1191}