1use std::str::FromStr;
101
102use anyhow::Context;
103use nautilus_model::{
104 enums::{OrderSide, OrderStatus, OrderType, TimeInForce},
105 identifiers::{InstrumentId, Symbol, Venue},
106 orders::{Order, any::OrderAny},
107 types::{AccountBalance, Currency, MarginBalance, Money},
108};
109use rust_decimal::Decimal;
110use serde::{Deserialize, Deserializer, Serializer};
111use serde_json::Value;
112
113use crate::http::models::{
114 AssetId, Cloid, CrossMarginSummary, HyperliquidExchangeResponse,
115 HyperliquidExecCancelByCloidRequest, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
116 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
117 HyperliquidExecTriggerParams,
118};
119
120pub fn serialize_decimal_as_str<S>(decimal: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
122where
123 S: Serializer,
124{
125 serializer.serialize_str(&decimal.normalize().to_string())
126}
127
128pub fn deserialize_decimal_from_str<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
130where
131 D: Deserializer<'de>,
132{
133 let s = String::deserialize(deserializer)?;
134 Decimal::from_str(&s).map_err(serde::de::Error::custom)
135}
136
137pub fn serialize_optional_decimal_as_str<S>(
139 decimal: &Option<Decimal>,
140 serializer: S,
141) -> Result<S::Ok, S::Error>
142where
143 S: Serializer,
144{
145 match decimal {
146 Some(d) => serializer.serialize_str(&d.normalize().to_string()),
147 None => serializer.serialize_none(),
148 }
149}
150
151pub fn deserialize_optional_decimal_from_str<'de, D>(
153 deserializer: D,
154) -> Result<Option<Decimal>, D::Error>
155where
156 D: Deserializer<'de>,
157{
158 let opt = Option::<String>::deserialize(deserializer)?;
159 match opt {
160 Some(s) => {
161 let decimal = Decimal::from_str(&s).map_err(serde::de::Error::custom)?;
162 Ok(Some(decimal))
163 }
164 None => Ok(None),
165 }
166}
167
168pub fn serialize_vec_decimal_as_str<S>(
170 decimals: &Vec<Decimal>,
171 serializer: S,
172) -> Result<S::Ok, S::Error>
173where
174 S: Serializer,
175{
176 use serde::ser::SerializeSeq;
177 let mut seq = serializer.serialize_seq(Some(decimals.len()))?;
178 for decimal in decimals {
179 seq.serialize_element(&decimal.normalize().to_string())?;
180 }
181 seq.end()
182}
183
184pub fn deserialize_vec_decimal_from_str<'de, D>(deserializer: D) -> Result<Vec<Decimal>, D::Error>
186where
187 D: Deserializer<'de>,
188{
189 let strings = Vec::<String>::deserialize(deserializer)?;
190 strings
191 .into_iter()
192 .map(|s| Decimal::from_str(&s).map_err(serde::de::Error::custom))
193 .collect()
194}
195
196#[inline]
202pub fn round_down_to_tick(price: Decimal, tick_size: Decimal) -> Decimal {
203 if tick_size.is_zero() {
204 return price;
205 }
206 (price / tick_size).floor() * tick_size
207}
208
209#[inline]
211pub fn round_down_to_step(qty: Decimal, step_size: Decimal) -> Decimal {
212 if step_size.is_zero() {
213 return qty;
214 }
215 (qty / step_size).floor() * step_size
216}
217
218#[inline]
220pub fn ensure_min_notional(
221 price: Decimal,
222 qty: Decimal,
223 min_notional: Decimal,
224) -> Result<(), String> {
225 let notional = price * qty;
226 if notional < min_notional {
227 Err(format!(
228 "Notional value {} is less than minimum required {}",
229 notional, min_notional
230 ))
231 } else {
232 Ok(())
233 }
234}
235
236pub fn normalize_price(price: Decimal, decimals: u8) -> Decimal {
238 let scale = Decimal::from(10_u64.pow(decimals as u32));
239 (price * scale).floor() / scale
240}
241
242pub fn normalize_quantity(qty: Decimal, decimals: u8) -> Decimal {
244 let scale = Decimal::from(10_u64.pow(decimals as u32));
245 (qty * scale).floor() / scale
246}
247
248pub fn normalize_order(
250 price: Decimal,
251 qty: Decimal,
252 tick_size: Decimal,
253 step_size: Decimal,
254 min_notional: Decimal,
255 price_decimals: u8,
256 size_decimals: u8,
257) -> Result<(Decimal, Decimal), String> {
258 let normalized_price = normalize_price(price, price_decimals);
260 let normalized_qty = normalize_quantity(qty, size_decimals);
261
262 let final_price = round_down_to_tick(normalized_price, tick_size);
264 let final_qty = round_down_to_step(normalized_qty, step_size);
265
266 ensure_min_notional(final_price, final_qty, min_notional)?;
268
269 Ok((final_price, final_qty))
270}
271
272pub fn time_in_force_to_hyperliquid_tif(
282 tif: TimeInForce,
283 is_post_only: bool,
284) -> anyhow::Result<HyperliquidExecTif> {
285 match (tif, is_post_only) {
286 (_, true) => Ok(HyperliquidExecTif::Alo), (TimeInForce::Gtc, false) => Ok(HyperliquidExecTif::Gtc),
288 (TimeInForce::Ioc, false) => Ok(HyperliquidExecTif::Ioc),
289 (TimeInForce::Fok, false) => Ok(HyperliquidExecTif::Ioc), _ => anyhow::bail!("Unsupported time in force for Hyperliquid: {tif:?}"),
291 }
292}
293
294pub fn extract_asset_id_from_symbol(symbol: &str) -> anyhow::Result<AssetId> {
303 if let Some(base) = symbol.strip_suffix("-USD") {
305 Ok(match base {
308 "BTC" => 0,
309 "ETH" => 1,
310 "DOGE" => 3,
311 "SOL" => 4,
312 "WIF" => 8,
313 "SHIB" => 10,
314 "PEPE" => 11,
315 _ => {
316 anyhow::bail!("Asset ID mapping not found for symbol: {symbol}")
319 }
320 })
321 } else {
322 anyhow::bail!("Cannot extract asset ID from symbol: {symbol}")
323 }
324}
325
326fn determine_tpsl_type(
339 order_type: OrderType,
340 order_side: OrderSide,
341 trigger_price: Decimal,
342 current_price: Option<Decimal>,
343) -> HyperliquidExecTpSl {
344 match order_type {
345 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
347
348 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
350
351 _ => {
353 if let Some(current) = current_price {
354 match order_side {
355 OrderSide::Buy => {
356 if trigger_price > current {
358 HyperliquidExecTpSl::Sl
359 } else {
360 HyperliquidExecTpSl::Tp
361 }
362 }
363 OrderSide::Sell => {
364 if trigger_price < current {
366 HyperliquidExecTpSl::Sl
367 } else {
368 HyperliquidExecTpSl::Tp
369 }
370 }
371 _ => HyperliquidExecTpSl::Sl, }
373 } else {
374 HyperliquidExecTpSl::Sl
376 }
377 }
378 }
379}
380
381pub fn order_to_hyperliquid_request(
405 order: &OrderAny,
406) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
407 let instrument_id = order.instrument_id();
408 let symbol = instrument_id.symbol.as_str();
409 let asset = extract_asset_id_from_symbol(symbol)
410 .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
411
412 let is_buy = matches!(order.order_side(), OrderSide::Buy);
413 let reduce_only = order.is_reduce_only();
414 let order_side = order.order_side();
415 let order_type = order.order_type();
416
417 let price_decimal = match order.price() {
419 Some(price) => Decimal::from_str_exact(&price.to_string())
420 .with_context(|| format!("Failed to convert price to decimal: {}", price))?,
421 None => {
422 if matches!(
425 order_type,
426 OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
427 ) {
428 Decimal::ZERO
429 } else {
430 anyhow::bail!("Limit orders require a price")
431 }
432 }
433 };
434
435 let size_decimal =
437 Decimal::from_str_exact(&order.quantity().to_string()).with_context(|| {
438 format!(
439 "Failed to convert quantity to decimal: {}",
440 order.quantity()
441 )
442 })?;
443
444 let kind = match order_type {
446 OrderType::Market => {
447 HyperliquidExecOrderKind::Limit {
449 limit: HyperliquidExecLimitParams {
450 tif: HyperliquidExecTif::Ioc,
451 },
452 }
453 }
454 OrderType::Limit => {
455 let tif =
456 time_in_force_to_hyperliquid_tif(order.time_in_force(), order.is_post_only())?;
457 HyperliquidExecOrderKind::Limit {
458 limit: HyperliquidExecLimitParams { tif },
459 }
460 }
461 OrderType::StopMarket => {
462 if let Some(trigger_price) = order.trigger_price() {
463 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
464 .with_context(|| {
465 format!(
466 "Failed to convert trigger price to decimal: {}",
467 trigger_price
468 )
469 })?;
470
471 let tpsl = determine_tpsl_type(
473 order_type,
474 order_side,
475 trigger_price_decimal,
476 None, );
478
479 HyperliquidExecOrderKind::Trigger {
480 trigger: HyperliquidExecTriggerParams {
481 is_market: true,
482 trigger_px: trigger_price_decimal,
483 tpsl,
484 },
485 }
486 } else {
487 anyhow::bail!("Stop market orders require a trigger price")
488 }
489 }
490 OrderType::StopLimit => {
491 if let Some(trigger_price) = order.trigger_price() {
492 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
493 .with_context(|| {
494 format!(
495 "Failed to convert trigger price to decimal: {}",
496 trigger_price
497 )
498 })?;
499
500 let tpsl = determine_tpsl_type(order_type, order_side, trigger_price_decimal, None);
502
503 HyperliquidExecOrderKind::Trigger {
504 trigger: HyperliquidExecTriggerParams {
505 is_market: false,
506 trigger_px: trigger_price_decimal,
507 tpsl,
508 },
509 }
510 } else {
511 anyhow::bail!("Stop limit orders require a trigger price")
512 }
513 }
514 OrderType::MarketIfTouched => {
515 if let Some(trigger_price) = order.trigger_price() {
518 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
519 .with_context(|| {
520 format!(
521 "Failed to convert trigger price to decimal: {}",
522 trigger_price
523 )
524 })?;
525
526 HyperliquidExecOrderKind::Trigger {
527 trigger: HyperliquidExecTriggerParams {
528 is_market: true,
529 trigger_px: trigger_price_decimal,
530 tpsl: HyperliquidExecTpSl::Tp, },
532 }
533 } else {
534 anyhow::bail!("Market-if-touched orders require a trigger price")
535 }
536 }
537 OrderType::LimitIfTouched => {
538 if let Some(trigger_price) = order.trigger_price() {
541 let trigger_price_decimal = Decimal::from_str_exact(&trigger_price.to_string())
542 .with_context(|| {
543 format!(
544 "Failed to convert trigger price to decimal: {}",
545 trigger_price
546 )
547 })?;
548
549 HyperliquidExecOrderKind::Trigger {
550 trigger: HyperliquidExecTriggerParams {
551 is_market: false,
552 trigger_px: trigger_price_decimal,
553 tpsl: HyperliquidExecTpSl::Tp, },
555 }
556 } else {
557 anyhow::bail!("Limit-if-touched orders require a trigger price")
558 }
559 }
560 _ => anyhow::bail!("Unsupported order type for Hyperliquid: {:?}", order_type),
561 };
562
563 let cloid = match Cloid::from_hex(order.client_order_id()) {
565 Ok(cloid) => Some(cloid),
566 Err(err) => {
567 anyhow::bail!(
568 "Failed to convert client order ID '{}' to CLOID: {}",
569 order.client_order_id(),
570 err
571 )
572 }
573 };
574
575 Ok(HyperliquidExecPlaceOrderRequest {
576 asset,
577 is_buy,
578 price: price_decimal,
579 size: size_decimal,
580 reduce_only,
581 kind,
582 cloid,
583 })
584}
585
586pub fn orders_to_hyperliquid_requests(
588 orders: &[&OrderAny],
589) -> anyhow::Result<Vec<HyperliquidExecPlaceOrderRequest>> {
590 orders
591 .iter()
592 .map(|order| order_to_hyperliquid_request(order))
593 .collect()
594}
595
596pub fn orders_to_hyperliquid_action_value(orders: &[&OrderAny]) -> anyhow::Result<Value> {
598 let requests = orders_to_hyperliquid_requests(orders)?;
599 serde_json::to_value(requests).context("Failed to serialize orders to JSON")
600}
601
602pub fn order_any_to_hyperliquid_request(
604 order: &OrderAny,
605) -> anyhow::Result<HyperliquidExecPlaceOrderRequest> {
606 order_to_hyperliquid_request(order)
607}
608
609pub fn client_order_id_to_cancel_request(
615 client_order_id: &str,
616 symbol: &str,
617) -> anyhow::Result<HyperliquidExecCancelByCloidRequest> {
618 let asset = extract_asset_id_from_symbol(symbol)
619 .with_context(|| format!("Failed to extract asset ID from symbol: {}", symbol))?;
620
621 let cloid = Cloid::from_hex(client_order_id).map_err(|e| {
622 anyhow::anyhow!(
623 "Failed to convert client order ID '{}' to CLOID: {}",
624 client_order_id,
625 e
626 )
627 })?;
628
629 Ok(HyperliquidExecCancelByCloidRequest { asset, cloid })
630}
631
632pub fn cancel_requests_to_hyperliquid_action_value(
634 requests: &[HyperliquidExecCancelByCloidRequest],
635) -> anyhow::Result<Value> {
636 serde_json::to_value(requests).context("Failed to serialize cancel requests to JSON")
637}
638
639pub fn is_response_successful(response: &HyperliquidExchangeResponse) -> bool {
641 matches!(response, HyperliquidExchangeResponse::Status { status, .. } if status == "ok")
642}
643
644pub fn extract_error_message(response: &HyperliquidExchangeResponse) -> String {
646 match response {
647 HyperliquidExchangeResponse::Status { status, response } => {
648 if status == "ok" {
649 "Operation successful".to_string()
650 } else {
651 if let Some(error_msg) = response.get("error").and_then(|v| v.as_str()) {
653 error_msg.to_string()
654 } else {
655 format!("Request failed with status: {}", status)
656 }
657 }
658 }
659 HyperliquidExchangeResponse::Error { error } => error.clone(),
660 }
661}
662
663pub fn is_conditional_order_data(trigger_px: Option<&str>, tpsl: Option<&str>) -> bool {
674 trigger_px.is_some() && tpsl.is_some()
675}
676
677pub fn parse_trigger_order_type(is_market: bool, tpsl: &str) -> OrderType {
688 match (is_market, tpsl) {
689 (true, "sl") => OrderType::StopMarket,
690 (false, "sl") => OrderType::StopLimit,
691 (true, "tp") => OrderType::MarketIfTouched,
692 (false, "tp") => OrderType::LimitIfTouched,
693 _ => OrderType::StopMarket, }
695}
696
697pub fn parse_order_status_with_trigger(
708 status: &str,
709 trigger_activated: Option<bool>,
710) -> (OrderStatus, Option<String>) {
711 use crate::common::enums::hyperliquid_status_to_order_status;
712
713 let base_status = hyperliquid_status_to_order_status(status);
714
715 if let Some(activated) = trigger_activated {
717 let trigger_status = if activated {
718 Some("activated".to_string())
719 } else {
720 Some("pending".to_string())
721 };
722 (base_status, trigger_status)
723 } else {
724 (base_status, None)
725 }
726}
727
728pub fn format_trailing_stop_info(
740 offset: &str,
741 offset_type: &str,
742 callback_price: Option<&str>,
743) -> String {
744 let offset_desc = match offset_type {
745 "percentage" => format!("{}%", offset),
746 "basisPoints" => format!("{} bps", offset),
747 "price" => offset.to_string(),
748 _ => offset.to_string(),
749 };
750
751 if let Some(callback) = callback_price {
752 format!(
753 "Trailing stop: {} offset, callback at {}",
754 offset_desc, callback
755 )
756 } else {
757 format!("Trailing stop: {} offset", offset_desc)
758 }
759}
760
761pub fn validate_conditional_order_params(
777 trigger_px: Option<&str>,
778 tpsl: Option<&str>,
779 is_market: Option<bool>,
780) -> anyhow::Result<()> {
781 if trigger_px.is_none() {
782 anyhow::bail!("Conditional order missing trigger price");
783 }
784
785 if tpsl.is_none() {
786 anyhow::bail!("Conditional order missing tpsl indicator");
787 }
788
789 let tpsl_value = tpsl.expect("tpsl should be Some at this point");
790 if tpsl_value != "tp" && tpsl_value != "sl" {
791 anyhow::bail!("Invalid tpsl value: {}", tpsl_value);
792 }
793
794 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> {
811 Decimal::from_str_exact(trigger_px)
812 .with_context(|| format!("Failed to parse trigger price: {}", trigger_px))
813}
814
815pub fn parse_account_balances_and_margins(
821 cross_margin_summary: &CrossMarginSummary,
822) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
823 let mut balances = Vec::new();
824 let mut margins = Vec::new();
825
826 let currency = Currency::USD(); let total_value = cross_margin_summary
831 .account_value
832 .to_string()
833 .parse::<f64>()?;
834
835 let withdrawable = cross_margin_summary
837 .withdrawable
838 .to_string()
839 .parse::<f64>()?;
840
841 let margin_used = cross_margin_summary
843 .total_margin_used
844 .to_string()
845 .parse::<f64>()?;
846
847 let total = Money::new(total_value, currency);
849 let locked = Money::new(margin_used, currency);
850 let free = Money::new(withdrawable, currency);
851
852 let balance = AccountBalance::new(total, locked, free);
853 balances.push(balance);
854
855 if margin_used > 0.0 {
860 let margin_instrument_id =
861 InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("HYPERLIQUID"));
862
863 let initial_margin = Money::new(margin_used, currency);
864 let maintenance_margin = Money::new(margin_used, currency);
865
866 let margin_balance =
867 MarginBalance::new(initial_margin, maintenance_margin, margin_instrument_id);
868
869 margins.push(margin_balance);
870 }
871
872 Ok((balances, margins))
873}
874
875#[cfg(test)]
880mod tests {
881 use rstest::rstest;
882 use serde::{Deserialize, Serialize};
883
884 use super::*;
885
886 #[derive(Serialize, Deserialize)]
887 struct TestStruct {
888 #[serde(
889 serialize_with = "serialize_decimal_as_str",
890 deserialize_with = "deserialize_decimal_from_str"
891 )]
892 value: Decimal,
893 #[serde(
894 serialize_with = "serialize_optional_decimal_as_str",
895 deserialize_with = "deserialize_optional_decimal_from_str"
896 )]
897 optional_value: Option<Decimal>,
898 }
899
900 #[rstest]
901 fn test_decimal_serialization_roundtrip() {
902 let original = TestStruct {
903 value: Decimal::from_str("123.456789012345678901234567890").unwrap(),
904 optional_value: Some(Decimal::from_str("0.000000001").unwrap()),
905 };
906
907 let json = serde_json::to_string(&original).unwrap();
908 println!("Serialized: {}", json);
909
910 assert!(json.contains("\"123.45678901234567890123456789\""));
912 assert!(json.contains("\"0.000000001\""));
913
914 let deserialized: TestStruct = serde_json::from_str(&json).unwrap();
915 assert_eq!(original.value, deserialized.value);
916 assert_eq!(original.optional_value, deserialized.optional_value);
917 }
918
919 #[rstest]
920 fn test_decimal_precision_preservation() {
921 let test_cases = [
922 "0",
923 "1",
924 "0.1",
925 "0.01",
926 "0.001",
927 "123.456789012345678901234567890",
928 "999999999999999999.999999999999999999",
929 ];
930
931 for case in test_cases {
932 let decimal = Decimal::from_str(case).unwrap();
933 let test_struct = TestStruct {
934 value: decimal,
935 optional_value: Some(decimal),
936 };
937
938 let json = serde_json::to_string(&test_struct).unwrap();
939 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
940
941 assert_eq!(decimal, parsed.value, "Failed for case: {}", case);
942 assert_eq!(
943 Some(decimal),
944 parsed.optional_value,
945 "Failed for case: {}",
946 case
947 );
948 }
949 }
950
951 #[rstest]
952 fn test_optional_none_handling() {
953 let test_struct = TestStruct {
954 value: Decimal::from_str("42.0").unwrap(),
955 optional_value: None,
956 };
957
958 let json = serde_json::to_string(&test_struct).unwrap();
959 assert!(json.contains("null"));
960
961 let parsed: TestStruct = serde_json::from_str(&json).unwrap();
962 assert_eq!(test_struct.value, parsed.value);
963 assert_eq!(None, parsed.optional_value);
964 }
965
966 #[rstest]
967 fn test_round_down_to_tick() {
968 use rust_decimal_macros::dec;
969
970 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0.05)), dec!(100.05));
971 assert_eq!(round_down_to_tick(dec!(100.03), dec!(0.05)), dec!(100.00));
972 assert_eq!(round_down_to_tick(dec!(100.05), dec!(0.05)), dec!(100.05));
973
974 assert_eq!(round_down_to_tick(dec!(100.07), dec!(0)), dec!(100.07));
976 }
977
978 #[rstest]
979 fn test_round_down_to_step() {
980 use rust_decimal_macros::dec;
981
982 assert_eq!(
983 round_down_to_step(dec!(0.12349), dec!(0.0001)),
984 dec!(0.1234)
985 );
986 assert_eq!(round_down_to_step(dec!(1.5555), dec!(0.1)), dec!(1.5));
987 assert_eq!(round_down_to_step(dec!(1.0001), dec!(0.0001)), dec!(1.0001));
988
989 assert_eq!(round_down_to_step(dec!(0.12349), dec!(0)), dec!(0.12349));
991 }
992
993 #[rstest]
994 fn test_min_notional_validation() {
995 use rust_decimal_macros::dec;
996
997 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
999 assert!(ensure_min_notional(dec!(100), dec!(0.11), dec!(10)).is_ok());
1000
1001 assert!(ensure_min_notional(dec!(100), dec!(0.05), dec!(10)).is_err());
1003 assert!(ensure_min_notional(dec!(1), dec!(5), dec!(10)).is_err());
1004
1005 assert!(ensure_min_notional(dec!(100), dec!(0.1), dec!(10)).is_ok());
1007 }
1008
1009 #[rstest]
1010 fn test_normalize_price() {
1011 use rust_decimal_macros::dec;
1012
1013 assert_eq!(normalize_price(dec!(100.12345), 2), dec!(100.12));
1014 assert_eq!(normalize_price(dec!(100.19999), 2), dec!(100.19));
1015 assert_eq!(normalize_price(dec!(100.999), 0), dec!(100));
1016 assert_eq!(normalize_price(dec!(100.12345), 4), dec!(100.1234));
1017 }
1018
1019 #[rstest]
1020 fn test_normalize_quantity() {
1021 use rust_decimal_macros::dec;
1022
1023 assert_eq!(normalize_quantity(dec!(1.12345), 3), dec!(1.123));
1024 assert_eq!(normalize_quantity(dec!(1.99999), 3), dec!(1.999));
1025 assert_eq!(normalize_quantity(dec!(1.999), 0), dec!(1));
1026 assert_eq!(normalize_quantity(dec!(1.12345), 5), dec!(1.12345));
1027 }
1028
1029 #[rstest]
1030 fn test_normalize_order_complete() {
1031 use rust_decimal_macros::dec;
1032
1033 let result = normalize_order(
1034 dec!(100.12345), dec!(0.123456), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1042
1043 assert!(result.is_ok());
1044 let (price, qty) = result.unwrap();
1045 assert_eq!(price, dec!(100.12)); assert_eq!(qty, dec!(0.1234)); }
1048
1049 #[rstest]
1050 fn test_normalize_order_min_notional_fail() {
1051 use rust_decimal_macros::dec;
1052
1053 let result = normalize_order(
1054 dec!(100.12345), dec!(0.05), dec!(0.01), dec!(0.0001), dec!(10), 2, 4, );
1062
1063 assert!(result.is_err());
1064 assert!(result.unwrap_err().contains("Notional value"));
1065 }
1066
1067 #[rstest]
1068 fn test_edge_cases() {
1069 use rust_decimal_macros::dec;
1070
1071 assert_eq!(
1073 round_down_to_tick(dec!(0.000001), dec!(0.000001)),
1074 dec!(0.000001)
1075 );
1076
1077 assert_eq!(round_down_to_tick(dec!(999999.99), dec!(1.0)), dec!(999999));
1079
1080 assert_eq!(
1082 round_down_to_tick(dec!(100.009999), dec!(0.01)),
1083 dec!(100.00)
1084 );
1085 }
1086
1087 #[rstest]
1092 fn test_is_conditional_order_data() {
1093 assert!(is_conditional_order_data(Some("50000.0"), Some("sl")));
1095
1096 assert!(!is_conditional_order_data(Some("50000.0"), None));
1098
1099 assert!(!is_conditional_order_data(None, Some("tp")));
1101
1102 assert!(!is_conditional_order_data(None, None));
1104 }
1105
1106 #[rstest]
1107 fn test_parse_trigger_order_type() {
1108 assert_eq!(parse_trigger_order_type(true, "sl"), OrderType::StopMarket);
1110
1111 assert_eq!(parse_trigger_order_type(false, "sl"), OrderType::StopLimit);
1113
1114 assert_eq!(
1116 parse_trigger_order_type(true, "tp"),
1117 OrderType::MarketIfTouched
1118 );
1119
1120 assert_eq!(
1122 parse_trigger_order_type(false, "tp"),
1123 OrderType::LimitIfTouched
1124 );
1125 }
1126
1127 #[rstest]
1128 fn test_parse_order_status_with_trigger() {
1129 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(true));
1131 assert_eq!(status, OrderStatus::Accepted);
1132 assert_eq!(trigger_status, Some("activated".to_string()));
1133
1134 let (status, trigger_status) = parse_order_status_with_trigger("open", Some(false));
1136 assert_eq!(status, OrderStatus::Accepted);
1137 assert_eq!(trigger_status, Some("pending".to_string()));
1138
1139 let (status, trigger_status) = parse_order_status_with_trigger("open", None);
1141 assert_eq!(status, OrderStatus::Accepted);
1142 assert_eq!(trigger_status, None);
1143 }
1144
1145 #[rstest]
1146 fn test_format_trailing_stop_info() {
1147 let info = format_trailing_stop_info("100.0", "price", Some("50000.0"));
1149 assert!(info.contains("100.0"));
1150 assert!(info.contains("callback at 50000.0"));
1151
1152 let info = format_trailing_stop_info("5.0", "percentage", None);
1154 assert!(info.contains("5.0%"));
1155 assert!(info.contains("Trailing stop"));
1156
1157 let info = format_trailing_stop_info("250", "basisPoints", Some("49000.0"));
1159 assert!(info.contains("250 bps"));
1160 assert!(info.contains("49000.0"));
1161 }
1162
1163 #[rstest]
1164 fn test_parse_trigger_price() {
1165 use rust_decimal_macros::dec;
1166
1167 let result = parse_trigger_price("50000.0");
1169 assert!(result.is_ok());
1170 assert_eq!(result.unwrap(), dec!(50000.0));
1171
1172 let result = parse_trigger_price("49000");
1174 assert!(result.is_ok());
1175 assert_eq!(result.unwrap(), dec!(49000));
1176
1177 let result = parse_trigger_price("invalid");
1179 assert!(result.is_err());
1180
1181 let result = parse_trigger_price("");
1183 assert!(result.is_err());
1184 }
1185}