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 {notional} is less than minimum required {min_notional}"
207 ))
208 } else {
209 Ok(())
210 }
211}
212
213pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
215 let scale = Decimal::from(10_u64.pow(decimals as u32));
216 (price * scale).floor() / scale
217}
218
219pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
221 let scale = Decimal::from(10_u64.pow(decimals as u32));
222 (qty * scale).floor() / scale
223}
224
225pub fn normalize_order(
227 price: Decimal,
228 qty: Decimal,
229 tick_size: Decimal,
230 step_size: Decimal,
231 min_notional: Decimal,
232 price_decimals: u8,
233 size_decimals: u8,
234) -> Result<(Decimal, Decimal), String> {
235 let normalized_price = normalize_price(price, price_decimals);
237 let normalized_qty = normalize_quantity(qty, size_decimals);
238
239 let final_price = round_down_to_tick(normalized_price, tick_size);
241 let final_qty = round_down_to_step(normalized_qty, step_size);
242
243 ensure_min_notional(final_price, final_qty, min_notional)?;
245
246 Ok((final_price, final_qty))
247}
248
249pub fn parse_millis_to_nanos(millis: u64) -> UnixNanos {
251 UnixNanos::from(millis * 1_000_000)
252}
253
254pub fn time_in_force_to_hyperliquid_tif(
260 tif: TimeInForce,
261 is_post_only: bool,
262) -> anyhow::Result<HyperliquidExecTif> {
263 match (tif, is_post_only) {
264 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
266 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
267 (TimeInForce::Fok, false) => Ok(HyperliquidExecTif::Ioc), _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
269 }
270}
271
272pub fn extract_asset_id_from_symbol(symbol: &str) -> anyhow::Result<AssetId> {
281 let base = if let Some(base) = symbol.strip_suffix("-PERP") {
283 base.strip_suffix("-USD")
285 .ok_or_else(|| anyhow::anyhow!("Cannot extract asset from symbol: {symbol}"))?
286 } else if let Some(base) = symbol.strip_suffix("-USD") {
287 base
289 } else {
290 anyhow::bail!("Cannot extract asset ID from symbol: {symbol}")
291 };
292
293 Ok(match base {
300 "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, _ => {
317 anyhow::bail!("Asset ID mapping not found for symbol: {symbol}")
319 }
320 })
321}
322
323fn determine_tpsl_type(
336 order_type: OrderType,
337 order_side: OrderSide,
338 trigger_price: Decimal,
339 current_price: Option<Decimal>,
340) -> HyperliquidExecTpSl {
341 match order_type {
342 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
344
345 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
347
348 _ => {
350 if let Some(current) = current_price {
351 match order_side {
352 OrderSide::Buy => {
353 if trigger_price > current {
355 HyperliquidExecTpSl::Sl
356 } else {
357 HyperliquidExecTpSl::Tp
358 }
359 }
360 OrderSide::Sell => {
361 if trigger_price < current {
363 HyperliquidExecTpSl::Sl
364 } else {
365 HyperliquidExecTpSl::Tp
366 }
367 }
368 _ => HyperliquidExecTpSl::Sl, }
370 } else {
371 HyperliquidExecTpSl::Sl
373 }
374 }
375 }
376}
377
378pub fn bar_type_to_interval(bar_type: &BarType) -> anyhow::Result<HyperliquidBarInterval> {
384 use crate::common::enums::HyperliquidBarInterval::{
385 EightHours, FifteenMinutes, FiveMinutes, FourHours, OneDay, OneHour, OneMinute, OneMonth,
386 OneWeek, ThirtyMinutes, ThreeDays, ThreeMinutes, TwelveHours, TwoHours,
387 };
388
389 let spec = bar_type.spec();
390 let step = spec.step.get();
391
392 anyhow::ensure!(
393 bar_type.aggregation_source() == AggregationSource::External,
394 "Only EXTERNAL aggregation is supported"
395 );
396
397 let interval = match spec.aggregation {
398 BarAggregation::Minute => match step {
399 1 => OneMinute,
400 3 => ThreeMinutes,
401 5 => FiveMinutes,
402 15 => FifteenMinutes,
403 30 => ThirtyMinutes,
404 _ => anyhow::bail!("Unsupported minute step: {step}"),
405 },
406 BarAggregation::Hour => match step {
407 1 => OneHour,
408 2 => TwoHours,
409 4 => FourHours,
410 8 => EightHours,
411 12 => TwelveHours,
412 _ => anyhow::bail!("Unsupported hour step: {step}"),
413 },
414 BarAggregation::Day => match step {
415 1 => OneDay,
416 3 => ThreeDays,
417 _ => anyhow::bail!("Unsupported day step: {step}"),
418 },
419 BarAggregation::Week if step == 1 => OneWeek,
420 BarAggregation::Month if step == 1 => OneMonth,
421 a => anyhow::bail!("Hyperliquid does not support {a:?} aggregation"),
422 };
423
424 Ok(interval)
425}
426
427pub fn order_to_hyperliquid_request(
451 order: &OrderAny,
452) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
453 let instrument_id = order.instrument_id();
454 let symbol = instrument_id.symbol.as_str();
455 let asset = extract_asset_id_from_symbol(symbol)
456 .with_context(|| format!("Failed to extract asset ID from symbol: {symbol}"))?;
457
458 let is_buy = matches!(order.order_side(), OrderSide::Buy);
459 let reduce_only = order.is_reduce_only();
460 let order_side = order.order_side();
461 let order_type = order.order_type();
462
463 let price_decimal = match order.price() {
465 Some(price) => Decimal::from_str_exact(&price.to_string())
466 .with_context(|| format!("Failed to convert price to decimal: {price}"))?,
467 None => {
468 if matches!(
471 order_type,
472 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
473 ) {
474 Decimal::ZERO
475 } else {
476 anyhow::bail!("Limit orders require a price")
477 }
478 }
479 };
480
481 let size_decimal =
483 Decimal::from_str_exact(&order.quantity().to_string()).with_context(|| {
484 format!(
485 "Failed to convert quantity to decimal: {}",
486 order.quantity()
487 )
488 })?;
489
490 let kind = match order_type {
492 OrderType::Market => {
493 HyperliquidExecOrderKind::Limit {
495 limit: HyperliquidExecLimitParams {
496 tif: HyperliquidExecTif::Ioc,
497 },
498 }
499 }
500 OrderType::Limit => {
501 let tif =
502 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
503 HyperliquidExecOrderKind::Limit {
504 limit: HyperliquidExecLimitParams { tif },
505 }
506 }
507 OrderType::StopMarket => {
508 if let Some(trigger_price) = order.trigger_price() {
509 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
510 .with_context(|| {
511 format!("Failed to convert trigger price to decimal: {trigger_price}")
512 })?;
513
514 let tpsl = determine_tpsl_type(
516 order_type,
517 order_side,
518 trigger_price_decimal,
519 None, );
521
522 HyperliquidExecOrderKind::Trigger {
523 trigger: HyperliquidExecTriggerParams {
524 is_market: true,
525 trigger_px: trigger_price_decimal,
526 tpsl,
527 },
528 }
529 } else {
530 anyhow::bail!("Stop market orders require a trigger price")
531 }
532 }
533 OrderType::StopLimit => {
534 if let Some(trigger_price) = order.trigger_price() {
535 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
536 .with_context(|| {
537 format!("Failed to convert trigger price to decimal: {trigger_price}")
538 })?;
539
540 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
542
543 HyperliquidExecOrderKind::Trigger {
544 trigger: HyperliquidExecTriggerParams {
545 is_market: false,
546 trigger_px: trigger_price_decimal,
547 tpsl,
548 },
549 }
550 } else {
551 anyhow::bail!("Stop limit orders require a trigger price")
552 }
553 }
554 OrderType::MarketIfTouched => {
555 if let Some(trigger_price) = order.trigger_price() {
558 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
559 .with_context(|| {
560 format!("Failed to convert trigger price to decimal: {trigger_price}")
561 })?;
562
563 HyperliquidExecOrderKind::Trigger {
564 trigger: HyperliquidExecTriggerParams {
565 is_market: true,
566 trigger_px: trigger_price_decimal,
567 tpsl: HyperliquidExecTpSl::Tp, },
569 }
570 } else {
571 anyhow::bail!("Market-if-touched orders require a trigger price")
572 }
573 }
574 OrderType::LimitIfTouched => {
575 if let Some(trigger_price) = order.trigger_price() {
578 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
579 .with_context(|| {
580 format!("Failed to convert trigger price to decimal: {trigger_price}")
581 })?;
582
583 HyperliquidExecOrderKind::Trigger {
584 trigger: HyperliquidExecTriggerParams {
585 is_market: false,
586 trigger_px: trigger_price_decimal,
587 tpsl: HyperliquidExecTpSl::Tp, },
589 }
590 } else {
591 anyhow::bail!("Limit-if-touched orders require a trigger price")
592 }
593 }
594 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {order_type:?}"),
595 };
596
597 let cloid = match Cloid::from_hex(order.client_order_id()) {
599 Ok(cloid) => Some(cloid),
600 Err(e) => {
601 anyhow::bail!(
602 "Failed to convert client order ID '{}' to CLOID: {}",
603 order.client_order_id(),
604 e
605 )
606 }
607 };
608
609 Ok(HyperliquidExecPlaceOrderRequest {
610 asset,
611 is_buy,
612 price: price_decimal,
613 size: size_decimal,
614 reduce_only,
615 kind,
616 cloid,
617 })
618}
619
620pub fn orders_to_hyperliquid_requests(
622 orders: &[&OrderAny],
623) -> anyhow::Result<Vec<HyperliquidExecPlaceOrderRequest>> {
624 orders
625 .iter()
626 .map(|order| order_to_hyperliquid_request(order))
627 .collect()
628}
629
630pub fn orders_to_hyperliquid_action_value(orders: &[&OrderAny]) -> anyhow::Result<Value> {
632 let requests = orders_to_hyperliquid_requests(orders)?;
633 serde_json::to_value(requests).context("failed to serialize orders to JSON")
634}
635
636pub fn order_any_to_hyperliquid_request(
638 order: &OrderAny,
639) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
640 order_to_hyperliquid_request(order)
641}
642
643pub fn client_order_id_to_cancel_request(
649 client_order_id: &str,
650 symbol: &str,
651) -> anyhow::Result<HyperliquidExecCancelByCloidRequest> {
652 let asset = extract_asset_id_from_symbol(symbol)
653 .with_context(|| format!("Failed to extract asset ID from symbol: {symbol}"))?;
654
655 let cloid = Cloid::from_hex(client_order_id).map_err(|e| {
656 anyhow::anyhow!("Failed to convert client order ID '{client_order_id}' to CLOID: {e}")
657 })?;
658
659 Ok(HyperliquidExecCancelByCloidRequest { asset, cloid })
660}
661
662pub fn is_response_successful(response: &HyperliquidExchangeResponse) -> bool {
664 matches!(response, HyperliquidExchangeResponse::Status { status, .. } if status == "ok")
665}
666
667pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
669 match response {
670 HyperliquidExchangeResponse::Status { status, response } => {
671 if status == "ok" {
672 "Operation successful".to_string()
673 } else {
674 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
676 error_msg.to_string()
677 } else {
678 format!("Request failed with status: {status}")
679 }
680 }
681 }
682 HyperliquidExchangeResponse::Error { error } => error.clone(),
683 }
684}
685
686pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&HyperliquidTpSl>) -> bool {
692 trigger_px.is_some() && tpsl.is_some()
693}
694
695pub fn parse_trigger_order_type(is_market: bool, tpsl: &HyperliquidTpSl) -> OrderType {
701 match (is_market, tpsl) {
702 (true, HyperliquidTpSl::Sl) => OrderType::StopMarket,
703 (false, HyperliquidTpSl::Sl) => OrderType::StopLimit,
704 (true, HyperliquidTpSl::Tp) => OrderType::MarketIfTouched,
705 (false, HyperliquidTpSl::Tp) => OrderType::LimitIfTouched,
706 }
707}
708
709pub fn parse_order_status_with_trigger(
715 status: &str,
716 trigger_activated: Option<bool>,
717) -> (OrderStatus, Option<String>) {
718 use crate::common::enums::hyperliquid_status_to_order_status;
719
720 let base_status = hyperliquid_status_to_order_status(status);
721
722 if let Some(activated) = trigger_activated {
724 let trigger_status = if activated {
725 Some("activated".to_string())
726 } else {
727 Some("pending".to_string())
728 };
729 (base_status, trigger_status)
730 } else {
731 (base_status, None)
732 }
733}
734
735pub fn format_trailing_stop_info(
737 offset: &str,
738 offset_type: TrailingOffsetType,
739 callback_price: Option<&str>,
740) -> String {
741 let offset_desc = offset_type.format_offset(offset);
742
743 if let Some(callback) = callback_price {
744 format!("Trailing stop: {offset_desc} offset, callback at {callback}")
745 } else {
746 format!("Trailing stop: {offset_desc} offset")
747 }
748}
749
750pub fn validate_conditional_order_params(
760 trigger_px: Option<&str>,
761 tpsl: Option<&HyperliquidTpSl>,
762 is_market: Option<bool>,
763) -> anyhow::Result<()> {
764 if trigger_px.is_none() {
765 anyhow::bail!("Conditional order missing trigger price");
766 }
767
768 if tpsl.is_none() {
769 anyhow::bail!("Conditional order missing tpsl indicator");
770 }
771
772 if is_market.is_none() {
775 anyhow::bail!("Conditional order missing is_market flag");
776 }
777
778 Ok(())
779}
780
781pub fn parse_trigger_price(trigger_px: &str) -> anyhow::Result<Decimal> {
787 Decimal::from_str_exact(trigger_px)
788 .with_context(|| format!("Failed to parse trigger price: {trigger_px}"))
789}
790
791pub fn parse_account_balances_and_margins(
797 cross_margin_summary: &CrossMarginSummary,
798) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
799 let mut balances = Vec::new();
800 let mut margins = Vec::new();
801
802 let currency = Currency::USD(); let total_value = cross_margin_summary
807 .account_value
808 .to_string()
809 .parse::<f64>()?;
810
811 let withdrawable = cross_margin_summary
813 .withdrawable
814 .to_string()
815 .parse::<f64>()?;
816
817 let margin_used = cross_margin_summary
819 .total_margin_used
820 .to_string()
821 .parse::<f64>()?;
822
823 let total = Money::new(total_value, currency);
825 let locked = Money::new(margin_used, currency);
826 let free = Money::new(withdrawable, currency);
827
828 let balance = AccountBalance::new(total, locked, free);
829 balances.push(balance);
830
831 if margin_used > 0.0 {
836 let margin_instrument_id =
837 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
838
839 let initial_margin = Money::new(margin_used, currency);
840 let maintenance_margin = Money::new(margin_used, currency);
841
842 let margin_balance =
843 MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
844
845 margins.push(margin_balance);
846 }
847
848 Ok((balances, margins))
849}
850
851#[cfg(test)]
852mod tests {
853 use rstest::rstest;
854 use serde::{Deserialize, Serialize};
855
856 use super::*;
857
858 #[derive(Serialize, Deserialize)]
859 struct TestStruct {
860 #[serde(
861 serialize_with = "serialize_decimal_as_str",
862 deserialize_with = "deserialize_decimal_from_str"
863 )]
864 value: Decimal,
865 #[serde(
866 serialize_with = "serialize_optional_decimal_as_str",
867 deserialize_with = "deserialize_optional_decimal_from_str"
868 )]
869 optional_value: Option<Decimal>,
870 }
871
872 #[rstest]
873 fn test_decimal_serialization_roundtrip() {
874 let original = TestStruct {
875 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
876 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
877 };
878
879 let json = serde_json::to_string(&original).unwrap();
880 println!("Serialized: {json}");
881
882 assert!(json.contains("\"123.45678901234567890123456789\""));
884 assert!(json.contains("\"0.000000001\""));
885
886 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
887 assert_eq!(original.value, deserialized.value);
888 assert_eq!(original.optional_value, deserialized.optional_value);
889 }
890
891 #[rstest]
892 fn test_decimal_precision_preservation() {
893 let test_cases = [
894 "0",
895 "1",
896 "0.1",
897 "0.01",
898 "0.001",
899 "123.456789012345678901234567890",
900 "999999999999999999.999999999999999999",
901 ];
902
903 for case in test_cases {
904 let decimal = Decimal::from_str(case).unwrap();
905 let test_struct = TestStruct {
906 value: decimal,
907 optional_value: Some(decimal),
908 };
909
910 let json = serde_json::to_string(&test_struct).unwrap();
911 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
912
913 assert_eq!(decimal, parsed.value, "Failed for case: {case}");
914 assert_eq!(
915 Some(decimal),
916 parsed.optional_value,
917 "Failed for case: {case}"
918 );
919 }
920 }
921
922 #[rstest]
923 fn test_optional_none_handling() {
924 let test_struct = TestStruct {
925 value: Decimal::from_str("42.0").unwrap(),
926 optional_value: None,
927 };
928
929 let json = serde_json::to_string(&test_struct).unwrap();
930 assert!(json.contains("null"));
931
932 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
933 assert_eq!(test_struct.value, parsed.value);
934 assert_eq!(None, parsed.optional_value);
935 }
936
937 #[rstest]
938 fn test_round_down_to_tick() {
939 use rust_decimal_macros::dec;
940
941 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
942 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
943 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
944
945 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
947 }
948
949 #[rstest]
950 fn test_round_down_to_step() {
951 use rust_decimal_macros::dec;
952
953 assert_eq!(
954 round_down_to_step(dec!(0.12349), dec!(0.0001)),
955 dec!(0.1234)
956 );
957 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
958 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
959
960 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
962 }
963
964 #[rstest]
965 fn test_min_notional_validation() {
966 use rust_decimal_macros::dec;
967
968 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
970 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
971
972 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
974 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
975
976 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
978 }
979
980 #[rstest]
981 fn test_normalize_price() {
982 use rust_decimal_macros::dec;
983
984 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
985 assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.19));
986 assert_eq!(normalize_price(dec!(100.999), 0), dec!(100));
987 assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.1234));
988 }
989
990 #[rstest]
991 fn test_normalize_quantity() {
992 use rust_decimal_macros::dec;
993
994 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
995 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
996 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
997 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
998 }
999
1000 #[rstest]
1001 fn test_normalize_order_complete() {
1002 use rust_decimal_macros::dec;
1003
1004 let result = normalize_order(
1005 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1013
1014 assert!(result.is_ok());
1015 let (price, qty) = result.unwrap();
1016 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1019
1020 #[rstest]
1021 fn test_normalize_order_min_notional_fail() {
1022 use rust_decimal_macros::dec;
1023
1024 let result = normalize_order(
1025 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1033
1034 assert!(result.is_err());
1035 assert!(result.unwrap_err().contains("Notional value"));
1036 }
1037
1038 #[rstest]
1039 fn test_edge_cases() {
1040 use rust_decimal_macros::dec;
1041
1042 assert_eq!(
1044 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1045 dec!(0.000001)
1046 );
1047
1048 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1050
1051 assert_eq!(
1053 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1054 dec!(100.00)
1055 );
1056 }
1057
1058 #[rstest]
1063 fn test_is_conditional_order_data() {
1064 assert!(is_conditional_order_data(
1066 Some("50000.0"),
1067 Some(&HyperliquidTpSl::Sl)
1068 ));
1069
1070 assert!(!is_conditional_order_data(Some("50000.0"), None));
1072
1073 assert!(!is_conditional_order_data(None, Some(&HyperliquidTpSl::Tp)));
1075
1076 assert!(!is_conditional_order_data(None, None));
1078 }
1079
1080 #[rstest]
1081 fn test_parse_trigger_order_type() {
1082 assert_eq!(
1084 parse_trigger_order_type(true, &HyperliquidTpSl::Sl),
1085 OrderType::StopMarket
1086 );
1087
1088 assert_eq!(
1090 parse_trigger_order_type(false, &HyperliquidTpSl::Sl),
1091 OrderType::StopLimit
1092 );
1093
1094 assert_eq!(
1096 parse_trigger_order_type(true, &HyperliquidTpSl::Tp),
1097 OrderType::MarketIfTouched
1098 );
1099
1100 assert_eq!(
1102 parse_trigger_order_type(false, &HyperliquidTpSl::Tp),
1103 OrderType::LimitIfTouched
1104 );
1105 }
1106
1107 #[rstest]
1108 fn test_parse_order_status_with_trigger() {
1109 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(true));
1111 assert_eq!(status, OrderStatus::Accepted);
1112 assert_eq!(trigger_status, Some("activated".to_string()));
1113
1114 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(false));
1116 assert_eq!(status, OrderStatus::Accepted);
1117 assert_eq!(trigger_status, Some("pending".to_string()));
1118
1119 let (status, trigger_status) = parse_order_status_with_trigger("open", None);
1121 assert_eq!(status, OrderStatus::Accepted);
1122 assert_eq!(trigger_status, None);
1123 }
1124
1125 #[rstest]
1126 fn test_format_trailing_stop_info() {
1127 let info = format_trailing_stop_info("100.0", TrailingOffsetType::Price, Some("50000.0"));
1129 assert!(info.contains("100.0"));
1130 assert!(info.contains("callback at 50000.0"));
1131
1132 let info = format_trailing_stop_info("5.0", TrailingOffsetType::Percentage, None);
1134 assert!(info.contains("5.0%"));
1135 assert!(info.contains("Trailing stop"));
1136
1137 let info =
1139 format_trailing_stop_info("250", TrailingOffsetType::BasisPoints, Some("49000.0"));
1140 assert!(info.contains("250 bps"));
1141 assert!(info.contains("49000.0"));
1142 }
1143
1144 #[rstest]
1145 fn test_parse_trigger_price() {
1146 use rust_decimal_macros::dec;
1147
1148 let result = parse_trigger_price("50000.0");
1150 assert!(result.is_ok());
1151 assert_eq!(result.unwrap(), dec!(50000.0));
1152
1153 let result = parse_trigger_price("49000");
1155 assert!(result.is_ok());
1156 assert_eq!(result.unwrap(), dec!(49000));
1157
1158 let result = parse_trigger_price("invalid");
1160 assert!(result.is_err());
1161
1162 let result = parse_trigger_price("");
1164 assert!(result.is_err());
1165 }
1166}