1pub mod common;
185pub mod config;
186pub mod error;
187mod funding;
188mod order_recovery;
189mod perp_account;
190mod perp_dexes;
191pub mod precision;
192pub mod spot;
193mod spot_coins;
194
195use crate::client::dedup::new_dedup_cache;
196use crate::client::order_recovery::{
197 KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
198};
199use crate::{
200 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
201 UnindexedAccountEvent, UnindexedAccountSnapshot,
202 balance::AssetBalance,
203 client::{ExecutionClient, OrderStatusClient},
204 emit_stream_terminated,
205 error::{
206 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
207 UnindexedOrderError,
208 },
209 order::{
210 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
211 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
212 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
213 state::{Filled, Open, OrderState, UnindexedOrderState},
214 },
215 position::{Position, PositionReport},
216 trade::{AssetFees, Trade, TradeId, TradeOrigin, TradesRead},
217};
218use chrono::{DateTime, Utc};
219use common::{
220 CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrderListing, UnknownCoins,
221 cancel_outcome, cid_to_cloid, cloid_to_cid, map_tif, millis_to_datetime, open_order_to_order,
222 parse_decimal, parse_side, span_millis, user_fills_by_time, warn_unknown_coins,
223};
224pub use config::{HyperliquidConfig, HyperliquidConfigError, Network};
225pub use error::HyperliquidConnectError;
226use error::{map_order_error, order_rejection};
227use ethers::signers::Signer;
228use fnv::FnvHashSet;
229use funding::{funding_cash_flow, send_fundings};
230use futures::{StreamExt, stream::BoxStream};
231use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
232use order_recovery::{
233 ReconnectWatch, fetch_order_record, listed_cids, lookup_from_record, remember_open,
234 remember_snapshot, send_fills, send_observed, spawn_order_checks,
235};
236use perp_account::{account_mode, dex_states};
237use perp_dexes::{PerpDexes, UnconfiguredDexes, warn_unconfigured_dexes};
238pub use precision::OrderPrecision;
239use precision::{WireOrderError, wire_order};
240use rust_decimal::Decimal;
241use rustrade_instrument::{
242 Side,
243 asset::name::AssetNameExchange,
244 exchange::ExchangeId,
245 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
246};
247use smol_str::format_smolstr;
248use std::sync::{
249 Arc,
250 atomic::{AtomicBool, Ordering},
251};
252use tokio::sync::{Notify, mpsc};
253use tokio_util::sync::CancellationToken;
254use tracing::{debug, error, info, warn};
255
256#[derive(Debug, Clone)]
261pub struct HyperliquidClient {
262 config: HyperliquidConfig,
263 info_client: Arc<InfoClient>,
264 exchange_client: Arc<ExchangeClient>,
265 dexes: Arc<PerpDexes>,
267 known_live: SharedKnownLiveOrders,
270}
271
272impl HyperliquidClient {
273 pub async fn connect(config: HyperliquidConfig) -> Result<Self, HyperliquidConnectError> {
294 let base_url = config.base_url();
295
296 let info_client = InfoClient::new(None, Some(base_url))
297 .await
298 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
299
300 let mut dexes = PerpDexes::fetch(&info_client, &config.dexes).await?;
301
302 let wallet = config.wallet.clone();
303 let mut exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
304 .await
305 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
306 exchange_client.coin_to_asset.extend(dexes.asset_ids());
308 dexes.add_default_perps(&exchange_client.meta.universe);
309
310 info!(
311 network = ?config.network,
312 dexes = ?config.dexes,
313 wallet = %config.wallet_address_hex(),
314 "Created HyperliquidClient"
315 );
316
317 Ok(Self {
318 config,
319 info_client: Arc::new(info_client),
320 exchange_client: Arc::new(exchange_client),
321 dexes: Arc::new(dexes),
322 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
323 })
324 }
325
326 pub fn wallet_address(&self) -> String {
328 self.config.wallet_address_hex()
329 }
330
331 pub fn order_precision(&self, instrument: &InstrumentNameExchange) -> Option<OrderPrecision> {
341 let coin = self.dexes.coin(instrument).ok()?;
342 self.dexes.sz_decimals(coin).map(OrderPrecision::perp)
343 }
344
345 fn wallet_h160(&self) -> ethers::types::H160 {
347 self.config.wallet.address()
348 }
349
350 fn check_instruments(
354 &self,
355 instruments: &[InstrumentNameExchange],
356 ) -> Result<(), UnindexedClientError> {
357 for instrument in instruments {
358 self.dexes.coin(instrument).map_err(|reason| {
359 UnindexedClientError::Api(crate::error::ApiError::InstrumentInvalid(
360 instrument.clone(),
361 reason,
362 ))
363 })?;
364 }
365 Ok(())
366 }
367}
368
369impl ExecutionClient for HyperliquidClient {
370 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
371
372 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
375 &[InstrumentKindDiscriminant::Perpetual];
376
377 type Config = HyperliquidConfig;
378 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
379
380 fn new(config: Self::Config) -> Self {
394 tokio::runtime::Handle::current()
397 .block_on(Self::connect(config))
398 .unwrap_or_else(|e| panic!("Failed to create HyperliquidClient: {e}"))
399 }
400
401 async fn account_snapshot(
444 &self,
445 _assets: &[AssetNameExchange],
446 instruments: &[InstrumentNameExchange],
447 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
448 self.check_instruments(instruments)?;
449 let address = self.wallet_h160();
450 let info_client = &*self.info_client;
451 let dexes = &*self.dexes;
452
453 let (mode, states, open_orders) = tokio::try_join!(
454 account_mode(info_client, address),
455 dex_states(info_client, address, dexes),
456 perp_account::open_orders(info_client, address, dexes),
457 )?;
458 let now = Utc::now();
459 let balances =
460 perp_account::snapshot_balances(info_client, address, dexes, mode, &states, now)
461 .await?;
462
463 let instrument_filter = InstrumentFilter::new(instruments);
464
465 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
466 let mut listing = OpenOrderListing::new(
467 &open_orders,
468 ExchangeId::HyperliquidPerp,
469 instruments,
470 |coin| dexes.instrument(coin),
471 );
472
473 let mut instrument_snapshots = Vec::new();
475 for asset_pos in states.iter().flat_map(|(_, state)| &state.asset_positions) {
476 let pos = &asset_pos.position;
477 let Some(instrument) = dexes.instrument(&pos.coin) else {
478 continue;
479 };
480
481 if !instrument_filter.matches(&instrument) {
482 continue;
483 }
484
485 let position = perp_position_report(pos, now);
486
487 let (orders, orders_complete) = match listing.remove(&instrument) {
488 Some(listed) => (listed.orders, listed.orders_complete),
489 None => (Vec::new(), true),
492 };
493
494 instrument_snapshots.push(InstrumentAccountSnapshot {
495 instrument,
496 orders,
497 orders_complete,
498 position,
499 isolated: None,
500 });
501 }
502
503 instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
506 snapshot.position = PositionReport::Flat;
507 snapshot
508 }));
509
510 let snapshot = AccountSnapshot {
511 exchange: ExchangeId::HyperliquidPerp,
512 balances,
513 instruments: instrument_snapshots,
514 };
515 remember_snapshot(&self.known_live, &snapshot);
516 Ok(snapshot)
517 }
518
519 async fn account_stream(
585 &self,
586 _assets: &[AssetNameExchange],
587 _instruments: &[InstrumentNameExchange],
588 ) -> Result<Self::AccountStream, UnindexedClientError> {
589 let user = self.wallet_h160();
590 let base_url = self.config.base_url();
591
592 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
595 .await
596 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
597
598 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
600 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
601 let (fundings_tx, mut fundings_rx) = mpsc::unbounded_channel::<Message>();
602
603 ws_client
605 .subscribe(Subscription::UserFills { user }, fills_tx)
606 .await
607 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
608
609 ws_client
611 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
612 .await
613 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
614
615 ws_client
617 .subscribe(Subscription::UserFundings { user }, fundings_tx)
618 .await
619 .map_err(|e| ConnectivityError::Socket(format!("UserFundings subscribe: {e}")))?;
620
621 info!(%user, "Subscribed to Hyperliquid account stream");
622
623 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
625
626 let cancel_token = CancellationToken::new();
628
629 let terminated = Arc::new(AtomicBool::new(false));
634
635 let fills_dedup = new_dedup_cache();
639
640 let reconnected = Arc::new(Notify::new());
644 let checks_cancel = cancel_token.child_token();
645 let (list_client, lookup_client) = (self.info_client.clone(), self.info_client.clone());
646 let (list_dexes, lookup_dexes) = (self.dexes.clone(), self.dexes.clone());
647 spawn_order_checks(
648 ExchangeId::HyperliquidPerp,
649 self.known_live.clone(),
650 reconnected.clone(),
651 checks_cancel.clone(),
652 event_tx.clone(),
653 move |instruments| {
654 perp_listed_cids(list_client.clone(), list_dexes.clone(), user, instruments)
655 },
656 move |key| perp_order_lookup(lookup_client.clone(), lookup_dexes.clone(), user, key),
657 );
658
659 let fills_event_tx = event_tx.clone();
661 let fills_cancel = cancel_token.clone();
662 let fills_terminated = terminated.clone();
663 let fills_checks_cancel = checks_cancel.clone();
664 let fills_dexes = self.dexes.clone();
665 tokio::spawn(async move {
666 let mut unknown_coins = UnknownCoins::default();
667 let mut unconfigured = UnconfiguredDexes::default();
668 let mut reconnects = ReconnectWatch::new(reconnected);
669 loop {
670 tokio::select! {
671 biased;
672 () = fills_cancel.cancelled() => {
673 debug!("Fills task cancelled");
674 return;
675 }
676 msg = fills_rx.recv() => {
677 let Some(msg) = msg else {
678 debug!("Fills receiver closed");
679 fills_checks_cancel.cancel();
680 if fills_terminated
683 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
684 .is_ok()
685 {
686 emit_stream_terminated(
687 &fills_event_tx,
688 ExchangeId::HyperliquidPerp,
689 StreamTerminationReason::Error(
690 "hyperliquid account stream closed".to_string(),
691 ),
692 );
693 }
694 return;
695 };
696 match msg {
697 Message::UserFills(fills) => {
698 let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
699 unknown_coins.warn_once(&fill.coin);
700 unconfigured.warn_once(&fills_dexes, &fill.coin);
701 fill_to_account_event(fill, &fills_dexes)
702 };
703 if !send_fills(
704 &fills.data,
705 convert,
706 &fills_dedup,
707 &fills_event_tx,
708 &mut reconnects,
709 ) {
710 debug!("Fills event channel closed");
711 return;
712 }
713 }
714 Message::NoData => {
715 warn!("UserFills WebSocket disconnected");
716 reconnects.dropped();
717 }
718 Message::HyperliquidError(e) => {
719 error!(%e, "UserFills WebSocket error");
722 }
723 _ => {}
724 }
725 }
726 }
727 }
728 });
729
730 let fundings_event_tx = event_tx.clone();
733 let fundings_cancel = cancel_token.clone();
734 let fundings_dexes = self.dexes.clone();
735 let fundings_dedup = new_dedup_cache();
737 tokio::spawn(async move {
738 let mut unknown_coins = UnknownCoins::default();
739 let mut unconfigured = UnconfiguredDexes::default();
740 loop {
741 tokio::select! {
742 biased;
743 () = fundings_cancel.cancelled() => {
744 debug!("Fundings task cancelled");
745 return;
746 }
747 msg = fundings_rx.recv() => {
748 let Some(msg) = msg else {
749 debug!("Fundings receiver closed");
750 return;
751 };
752 match msg {
753 Message::UserFundings(fundings) => {
754 let convert = |funding: &hyperliquid_rust_sdk::UserFunding| {
755 unknown_coins.warn_once(&funding.coin);
756 unconfigured.warn_once(&fundings_dexes, &funding.coin);
757 funding_cash_flow(funding, &fundings_dexes)
758 };
759 if !send_fundings(
760 &fundings.data,
761 convert,
762 &fundings_dedup,
763 &fundings_event_tx,
764 ) {
765 debug!("Fundings event channel closed");
766 return;
767 }
768 }
769 Message::NoData => {
770 warn!("UserFundings WebSocket disconnected");
771 }
772 Message::HyperliquidError(e) => {
773 error!(%e, "UserFundings WebSocket error");
775 }
776 _ => {}
777 }
778 }
779 }
780 }
781 });
782
783 let orders_event_tx = event_tx;
788 let orders_cancel = cancel_token.clone();
789 let orders_terminated = terminated;
790 let orders_known = self.known_live.clone();
791 let orders_dexes = self.dexes.clone();
792 tokio::spawn(async move {
793 let _ws_client = ws_client;
794 let mut unknown_coins = UnknownCoins::default();
795 let mut unconfigured = UnconfiguredDexes::default();
796
797 loop {
798 tokio::select! {
799 biased;
800 () = orders_cancel.cancelled() => {
801 debug!("Orders task cancelled");
802 return;
803 }
804 msg = orders_rx.recv() => {
805 let Some(msg) = msg else {
806 debug!("Orders receiver closed");
807 checks_cancel.cancel();
808 if orders_terminated
811 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
812 .is_ok()
813 {
814 emit_stream_terminated(
815 &orders_event_tx,
816 ExchangeId::HyperliquidPerp,
817 StreamTerminationReason::Error(
818 "hyperliquid account stream closed".to_string(),
819 ),
820 );
821 }
822 return;
823 };
824 match msg {
825 Message::OrderUpdates(updates) => {
826 for update in updates.data {
827 unknown_coins.warn_once(&update.order.coin);
828 unconfigured.warn_once(&orders_dexes, &update.order.coin);
829 if let Some(event) =
830 order_update_to_account_event(&update, &orders_dexes)
831 && !send_observed(&orders_known, &orders_event_tx, event)
832 {
833 debug!("Orders event channel closed");
834 return;
835 }
836 }
837 }
838 Message::NoData => {
839 warn!("OrderUpdates WebSocket disconnected");
840 }
841 Message::HyperliquidError(e) => {
842 error!(%e, "OrderUpdates WebSocket error");
845 }
846 _ => {}
847 }
848 }
849 }
850 }
851 });
852
853 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
855 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
856 Ok(guarded_stream.boxed())
857 }
858
859 async fn cancel_order(
860 &self,
861 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
862 ) -> UnindexedOrderResponseCancel {
863 use crate::order::{request::OrderResponseCancel, state::Cancelled};
864 use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
865 use uuid::Uuid;
866
867 let coin = match self.dexes.coin(request.key.instrument) {
868 Ok(coin) => coin.to_owned(),
869 Err(reason) => {
870 warn!(%reason, cid = %request.key.cid, "Cannot cancel order on this instrument");
871 return OrderResponseCancel {
872 key: OrderKey {
873 exchange: ExchangeId::HyperliquidPerp,
874 instrument: request.key.instrument.clone(),
875 strategy: request.key.strategy.clone(),
876 cid: request.key.cid.clone(),
877 },
878 state: Err(UnindexedOrderError::Rejected(
879 crate::error::ApiError::InstrumentInvalid(
880 request.key.instrument.clone(),
881 reason,
882 ),
883 )),
884 };
885 }
886 };
887
888 enum CancelMethod {
889 ByOid(u64),
890 ByCloid(Uuid),
891 }
892
893 let cancel_method = match &request.state.id {
898 Some(VenueOrderId::Assigned(id)) => {
899 id.0.parse::<u64>()
900 .map(CancelMethod::ByOid)
901 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
902 }
903 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
907 .map(CancelMethod::ByCloid)
908 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
909 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
910 };
911
912 let cancel_method = match cancel_method {
913 Ok(method) => method,
914 Err(reason) => {
915 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
916 return OrderResponseCancel {
917 key: OrderKey {
918 exchange: ExchangeId::HyperliquidPerp,
919 instrument: request.key.instrument.clone(),
920 strategy: request.key.strategy.clone(),
921 cid: request.key.cid.clone(),
922 },
923 state: Err(UnindexedOrderError::Rejected(
924 crate::error::ApiError::OrderRejected(reason.to_string()),
925 )),
926 };
927 }
928 };
929
930 let cancelled_id = match &request.state.id {
933 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
934 None => OrderId(request.key.cid.0.clone()),
935 };
936
937 let response = match cancel_method {
938 CancelMethod::ByOid(oid) => {
939 debug!(oid, "Cancelling by OID");
940 let cancel_request = ClientCancelRequest { asset: coin, oid };
941 self.exchange_client.cancel(cancel_request, None).await
942 }
943 CancelMethod::ByCloid(cloid) => {
944 debug!(%cloid, "Cancelling by cloid (no oid assigned)");
945 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
946 self.exchange_client
947 .cancel_by_cloid(cancel_request, None)
948 .await
949 }
950 };
951
952 let response = match response {
953 Ok(r) => r,
954 Err(e) => {
955 warn!(%e, "Cancel order failed (transport)");
956 return OrderResponseCancel {
957 key: OrderKey {
958 exchange: ExchangeId::HyperliquidPerp,
959 instrument: request.key.instrument.clone(),
960 strategy: request.key.strategy.clone(),
961 cid: request.key.cid.clone(),
962 },
963 state: Err(map_order_error(e, request.key.instrument)),
964 };
965 }
966 };
967
968 match cancel_outcome(response) {
969 Ok(()) => {
970 debug!("Cancel order accepted");
971 self.known_live.lock().ended(&request.key.cid);
973 OrderResponseCancel {
975 key: OrderKey {
976 exchange: ExchangeId::HyperliquidPerp,
977 instrument: request.key.instrument.clone(),
978 strategy: request.key.strategy.clone(),
979 cid: request.key.cid.clone(),
980 },
981 state: Ok(Cancelled::new(
982 cancelled_id.clone(),
983 Utc::now(),
984 None, )),
986 }
987 }
988 Err(reason) => {
990 warn!(%reason, cid = %request.key.cid, "Cancel rejected by exchange");
991 OrderResponseCancel {
992 key: OrderKey {
993 exchange: ExchangeId::HyperliquidPerp,
994 instrument: request.key.instrument.clone(),
995 strategy: request.key.strategy.clone(),
996 cid: request.key.cid.clone(),
997 },
998 state: Err(UnindexedOrderError::Rejected(
999 crate::error::ApiError::OrderRejected(reason),
1000 )),
1001 }
1002 }
1003 }
1004 }
1005
1006 async fn open_order(
1007 &self,
1008 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1009 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1010 use hyperliquid_rust_sdk::{
1011 ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
1012 };
1013
1014 let make_inactive =
1015 |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1016 Order {
1017 key: OrderKey {
1018 exchange: ExchangeId::HyperliquidPerp,
1019 instrument: request.key.instrument.clone(),
1020 strategy: request.key.strategy.clone(),
1021 cid: request.key.cid.clone(),
1022 },
1023 side: request.state.side,
1024 price: request.state.price,
1025 quantity: request.state.quantity,
1026 kind: request.state.kind,
1027 time_in_force: request.state.time_in_force,
1028 state: OrderState::inactive(error),
1029 }
1030 };
1031 let make_rejected = |msg: String| {
1032 make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
1033 msg,
1034 )))
1035 };
1036 let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
1037 let make_invalid_instrument = |reason: String| {
1038 make_inactive(OrderError::Rejected(
1039 crate::error::ApiError::InstrumentInvalid(request.key.instrument.clone(), reason),
1040 ))
1041 };
1042
1043 let coin = match self.dexes.coin(request.key.instrument) {
1044 Ok(coin) => coin.to_owned(),
1045 Err(reason) => return make_invalid_instrument(reason),
1046 };
1047 let is_buy = request.state.side == Side::Buy;
1048
1049 match request.state.kind {
1050 OrderKind::Market => {
1051 return make_unsupported(
1052 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
1053 .to_string(),
1054 );
1055 }
1056 OrderKind::TrailingStop { .. }
1057 | OrderKind::TrailingStopLimit { .. }
1058 | OrderKind::TrailingStopFixedLimit { .. } => {
1059 return make_unsupported(
1060 "Hyperliquid does not support trailing stop orders".to_string(),
1061 );
1062 }
1063 OrderKind::Limit
1064 | OrderKind::Stop { .. }
1065 | OrderKind::StopLimit { .. }
1066 | OrderKind::TakeProfit { .. }
1067 | OrderKind::TakeProfitLimit { .. } => {}
1068 }
1069
1070 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
1073 return make_rejected(CLOID_REQUIRED.to_string());
1074 };
1075
1076 let tif = map_tif(&request.state.time_in_force).to_string();
1077
1078 let Some(precision) = self.order_precision(request.key.instrument) else {
1079 return make_invalid_instrument(format!(
1080 "{coin} was not listed when the client connected; reconnect to trade a perpetual \
1081 listed since"
1082 ));
1083 };
1084 let wire = match wire_order(
1085 &precision,
1086 request.state.kind,
1087 tif,
1088 request.state.price,
1089 request.state.quantity,
1090 ) {
1091 Ok(wire) => wire,
1092 Err(WireOrderError::Precision(violation)) => {
1093 return make_inactive(OrderError::InvalidPrecision(violation));
1094 }
1095 Err(WireOrderError::MissingPrice) => {
1096 return make_rejected(
1097 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
1098 .to_string(),
1099 );
1100 }
1101 Err(WireOrderError::UnsupportedKind) => {
1102 return make_unsupported(format!(
1103 "Hyperliquid does not support {} orders",
1104 request.state.kind
1105 ));
1106 }
1107 };
1108
1109 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
1111 warn!(
1112 instrument = %request.key.instrument,
1113 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
1114 );
1115 }
1116
1117 let order_request = ClientOrderRequest {
1118 asset: coin,
1119 is_buy,
1120 reduce_only: request.state.reduce_only,
1121 limit_px: wire.limit_px,
1122 sz: wire.sz,
1123 cloid: Some(cloid),
1124 order_type: wire.order_type,
1125 };
1126
1127 let response = match self.exchange_client.order(order_request, None).await {
1128 Ok(r) => r,
1129 Err(e) => {
1130 warn!(%e, "Open order failed");
1131 return Order {
1132 key: OrderKey {
1133 exchange: ExchangeId::HyperliquidPerp,
1134 instrument: request.key.instrument.clone(),
1135 strategy: request.key.strategy.clone(),
1136 cid: request.key.cid.clone(),
1137 },
1138 side: request.state.side,
1139 price: request.state.price,
1140 quantity: request.state.quantity,
1141 kind: request.state.kind,
1142 time_in_force: request.state.time_in_force,
1143 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
1144 };
1145 }
1146 };
1147
1148 let state = match response {
1150 ExchangeResponseStatus::Ok(exchange_resp) => {
1151 let status = exchange_resp
1153 .data
1154 .and_then(|d| d.statuses.into_iter().next());
1155
1156 match status {
1157 Some(ExchangeDataStatus::Resting(resting)) => {
1158 debug!(oid = resting.oid, "Order resting");
1159 OrderState::active(Open {
1160 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
1161 time_exchange: Utc::now(),
1162 filled_quantity: Decimal::ZERO,
1163 })
1164 }
1165 Some(ExchangeDataStatus::Filled(filled)) => {
1166 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
1167 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
1169 OrderState::fully_filled(Filled::new(
1170 OrderId(format_smolstr!("{}", filled.oid)),
1171 Utc::now(),
1172 parse_decimal(&filled.total_sz, "total_sz")
1173 .unwrap_or(request.state.quantity),
1174 avg_price,
1175 ))
1176 }
1177 Some(ExchangeDataStatus::Error(msg)) => {
1178 warn!(%msg, "Order rejected by exchange");
1179 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1180 }
1181 Some(
1182 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
1183 ) => {
1184 debug!(cid = %request.key.cid, "Order waiting under its cloid");
1188 OrderState::active(Open {
1189 id: VenueOrderId::ClientAssigned,
1190 time_exchange: Utc::now(),
1191 filled_quantity: Decimal::ZERO,
1192 })
1193 }
1194 Some(ExchangeDataStatus::Success) | None => {
1195 warn!("Order accepted but no order ID returned");
1198 OrderState::inactive(OrderError::Rejected(
1199 crate::error::ApiError::OrderRejected(
1200 "no order ID in response".to_string(),
1201 ),
1202 ))
1203 }
1204 }
1205 }
1206 ExchangeResponseStatus::Err(msg) => {
1207 warn!(%msg, "Order rejected");
1208 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1209 }
1210 };
1211 let order = Order {
1212 key: OrderKey {
1213 exchange: ExchangeId::HyperliquidPerp,
1214 instrument: request.key.instrument.clone(),
1215 strategy: request.key.strategy.clone(),
1216 cid: request.key.cid.clone(),
1217 },
1218 side: request.state.side,
1219 price: request.state.price,
1220 quantity: request.state.quantity,
1221 kind: request.state.kind,
1222 time_in_force: request.state.time_in_force,
1223 state,
1224 };
1225 self.known_live
1226 .lock()
1227 .placed(&order.key, order.quantity, order.kind, &order.state);
1228 order
1229 }
1230
1231 async fn fetch_balances(
1235 &self,
1236 _assets: &[AssetNameExchange],
1237 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1238 perp_account::fetch_balances(&self.info_client, self.wallet_h160(), &self.dexes).await
1239 }
1240
1241 async fn fetch_open_orders(
1248 &self,
1249 instruments: &[InstrumentNameExchange],
1250 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1251 self.check_instruments(instruments)?;
1252 let address = self.wallet_h160();
1253
1254 let open_orders =
1255 perp_account::open_orders(&self.info_client, address, &self.dexes).await?;
1256 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
1257
1258 let instrument_filter = InstrumentFilter::new(instruments);
1259
1260 let orders: Vec<_> = open_orders
1261 .iter()
1262 .filter_map(|order| {
1263 let instrument = self.dexes.instrument(&order.coin)?;
1264 if !instrument_filter.matches(&instrument) {
1265 return None;
1266 }
1267 open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1268 })
1269 .collect();
1270 remember_open(&self.known_live, &orders);
1271 Ok(orders)
1272 }
1273
1274 async fn fetch_trades(
1284 &self,
1285 start: DateTime<Utc>,
1286 end: DateTime<Utc>,
1287 instruments: &[InstrumentNameExchange],
1288 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1289 self.check_instruments(instruments)?;
1290 let Some((start_ms, end_ms)) = span_millis(start, end) else {
1291 return Ok(TradesRead::complete(Vec::new()));
1292 };
1293 let address = self.wallet_h160();
1294
1295 let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1296 warn_unknown_coins(fills.iter().map(|fill| fill.coin.as_str()));
1297 warn_unconfigured_dexes(&self.dexes, fills.iter().map(|fill| fill.coin.as_str()));
1298
1299 let instrument_filter = InstrumentFilter::new(instruments);
1300
1301 let mut result = Vec::new();
1302 for fill in fills {
1303 let Some((instrument, collateral)) = self.dexes.perp(&fill.coin) else {
1304 continue;
1305 };
1306
1307 if !instrument_filter.matches(&instrument) {
1308 continue;
1309 }
1310
1311 let Some(side) = parse_side(&fill.side) else {
1312 continue;
1313 };
1314 let Some(price) = parse_decimal(&fill.px, "px") else {
1315 continue;
1316 };
1317 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1318 continue;
1319 };
1320 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1321
1322 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1323 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1324 continue;
1325 };
1326 if !(start..=end).contains(&time_exchange) {
1327 continue;
1328 }
1329
1330 result.push(Trade {
1331 id: TradeId(format_smolstr!("{}", fill.tid)),
1332 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1333 instrument,
1334 strategy: StrategyId::unknown(),
1335 time_exchange,
1336 side,
1337 price,
1338 quantity,
1339 order_filled_quantity: None,
1342 fees: perp_fill_fees(fill.fee_token.as_deref(), fee, collateral),
1343 origin: TradeOrigin::Order,
1345 position_side: None,
1346 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1347 });
1348 }
1349
1350 Ok(TradesRead::complete(result))
1351 }
1352}
1353
1354impl OrderStatusClient for HyperliquidClient {
1369 async fn fetch_ended_orders(
1370 &self,
1371 orders: &[UnindexedOrderKey],
1372 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1373 let address = self.wallet_h160();
1374 fetch_ended_by_key(orders, |key| {
1375 perp_order_lookup(self.info_client.clone(), self.dexes.clone(), address, key)
1376 })
1377 .await
1378 }
1379}
1380
1381async fn perp_order_lookup(
1383 info_client: Arc<InfoClient>,
1384 dexes: Arc<PerpDexes>,
1385 address: ethers::types::H160,
1386 key: UnindexedOrderKey,
1387) -> Result<OrderLookup, UnindexedClientError> {
1388 let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1389 return Ok(OrderLookup::Unknown);
1390 };
1391 let instrument = dexes.instrument(&record.order.coin);
1392 Ok(lookup_from_record(key, &record, instrument))
1393}
1394
1395async fn perp_listed_cids(
1398 info_client: Arc<InfoClient>,
1399 dexes: Arc<PerpDexes>,
1400 address: ethers::types::H160,
1401 instruments: Vec<InstrumentNameExchange>,
1402) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1403 let rows = perp_account::open_orders(&info_client, address, &dexes).await?;
1404 Ok(listed_cids(&rows, &instruments, |coin| {
1405 dexes.instrument(coin)
1406 }))
1407}
1408
1409fn perp_position_report(
1415 position: &hyperliquid_rust_sdk::PositionData,
1416 now: DateTime<Utc>,
1417) -> PositionReport {
1418 let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1419 return PositionReport::Unreported;
1420 };
1421 PositionReport::from_position(Position::new(
1422 quantity,
1423 position
1424 .entry_px
1425 .as_ref()
1426 .and_then(|p| parse_decimal(p, "entry_px")),
1427 parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1428 parse_decimal(&position.margin_used, "margin_used"),
1429 position
1430 .liquidation_px
1431 .as_ref()
1432 .and_then(|p| parse_decimal(p, "liquidation_px")),
1433 Some(Decimal::from(position.leverage.value)),
1434 parse_decimal(&position.cum_funding.since_open, "cum_funding.since_open").map(|paid| -paid),
1436 now,
1437 ))
1438}
1439
1440fn perp_fill_fees(
1450 fee_token: Option<&str>,
1451 fee: Decimal,
1452 collateral: &str,
1453) -> AssetFees<AssetNameExchange> {
1454 let asset = fee_token.unwrap_or(collateral);
1455 let fees_quote = (asset == collateral).then_some(fee);
1456 AssetFees::new(AssetNameExchange::from(asset), fee, fees_quote)
1457}
1458
1459fn fill_to_account_event(
1462 fill: &hyperliquid_rust_sdk::TradeInfo,
1463 dexes: &PerpDexes,
1464) -> Option<UnindexedAccountEvent> {
1465 let (instrument, collateral) = dexes.perp(&fill.coin)?;
1466 let side = parse_side(&fill.side)?;
1467 let price = parse_decimal(&fill.px, "fill.px")?;
1468 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1469 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1470 let time_exchange = millis_to_datetime(fill.time)?;
1471 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1472
1473 let trade = Trade {
1474 id: TradeId(format_smolstr!("{}", fill.tid)),
1475 order_id,
1476 instrument,
1477 strategy: StrategyId::unknown(),
1478 time_exchange,
1479 side,
1480 price,
1481 quantity,
1482 order_filled_quantity: None,
1485 fees: perp_fill_fees(Some(&fill.fee_token), fee, collateral),
1486 origin: TradeOrigin::Order,
1489 position_side: None,
1490 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1491 };
1492
1493 Some(AccountEvent::new(
1494 ExchangeId::HyperliquidPerp,
1495 AccountEventKind::Trade(trade),
1496 ))
1497}
1498
1499fn order_update_to_account_event(
1502 update: &hyperliquid_rust_sdk::OrderUpdate,
1503 dexes: &PerpDexes,
1504) -> Option<UnindexedAccountEvent> {
1505 common::order_update_to_account_event(
1506 update,
1507 ExchangeId::HyperliquidPerp,
1508 dexes.instrument(&update.order.coin)?,
1509 )
1510}
1511
1512#[cfg(test)]
1513#[allow(clippy::unwrap_used)]
1515mod tests {
1516 use super::*;
1517 use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1518 use rust_decimal_macros::dec;
1519 use rustrade_integration::collection::snapshot::Snapshot;
1520 use std::collections::HashMap;
1521
1522 fn default_dex() -> PerpDexes {
1524 PerpDexes::default()
1525 }
1526
1527 mod order_recovery {
1528 use super::super::common::info_tests::info_client_against;
1529 use super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1530 use super::*;
1531 use crate::order::state::InactiveOrderState;
1532 use wiremock::matchers::{method, path};
1533 use wiremock::{Mock, MockServer, ResponseTemplate};
1534
1535 async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>) {
1536 let server = MockServer::start().await;
1537 Mock::given(method("POST"))
1538 .and(path("/info"))
1539 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1540 .mount(&server)
1541 .await;
1542 let client = Arc::new(info_client_against(server.uri()).await);
1543 (server, client)
1544 }
1545
1546 fn key() -> UnindexedOrderKey {
1547 OrderKey {
1548 exchange: ExchangeId::HyperliquidPerp,
1549 instrument: InstrumentNameExchange::new("BTC-USDC-PERP"),
1550 strategy: StrategyId::new("strategy"),
1551 cid: ClientOrderId::new(CID),
1552 }
1553 }
1554
1555 fn order_status(coin: &str) -> serde_json::Value {
1556 serde_json::json!({
1557 "status": "order",
1558 "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1559 })
1560 }
1561
1562 #[test]
1563 fn a_reconnect_snapshot_wakes_the_check_once_its_fills_are_sent() {
1564 use futures::FutureExt as _;
1565 let notified = |reconnected: &Notify| reconnected.notified().now_or_never().is_some();
1566 let snapshot = |fills| hyperliquid_rust_sdk::UserFillsData {
1567 is_snapshot: Some(true),
1568 user: ethers::types::H160::zero(),
1569 fills,
1570 };
1571 let reconnected = Arc::new(Notify::new());
1572 let mut reconnects = ReconnectWatch::new(reconnected.clone());
1573 let dedup = new_dedup_cache();
1574 let (tx, mut rx) = mpsc::unbounded_channel();
1575 let tids = |rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>| {
1576 std::iter::from_fn(|| rx.try_recv().ok())
1577 .map(|event| match event.kind {
1578 AccountEventKind::Trade(trade) => trade.id.0.to_string(),
1579 other => panic!("expected a trade, got {other:?}"),
1580 })
1581 .collect::<Vec<_>>()
1582 };
1583
1584 let first = snapshot(vec![sweep_fill(1, "60000")]);
1586 assert!(send_fills(
1587 &first,
1588 |fill| fill_to_account_event(fill, &default_dex()),
1589 &dedup,
1590 &tx,
1591 &mut reconnects
1592 ));
1593 assert_eq!(tids(&mut rx), ["1"]);
1594 assert!(!notified(&reconnected));
1595
1596 reconnects.dropped();
1599 let resubscribed = snapshot(vec![sweep_fill(1, "60000"), sweep_fill(2, "60001")]);
1600 assert!(send_fills(
1601 &resubscribed,
1602 |fill| fill_to_account_event(fill, &default_dex()),
1603 &dedup,
1604 &tx,
1605 &mut reconnects
1606 ));
1607 assert_eq!(
1608 tids(&mut rx),
1609 ["2"],
1610 "the fill already sent is not sent again"
1611 );
1612 assert!(notified(&reconnected));
1613
1614 drop(rx);
1616 reconnects.dropped();
1617 let unsent = snapshot(vec![sweep_fill(3, "60002")]);
1618 assert!(!send_fills(
1619 &unsent,
1620 |fill| fill_to_account_event(fill, &default_dex()),
1621 &dedup,
1622 &tx,
1623 &mut reconnects
1624 ));
1625 assert!(!notified(&reconnected));
1626 }
1627
1628 #[tokio::test]
1629 async fn a_perp_lookup_reads_how_the_order_ended() {
1630 let (_server, client) = serve(order_status("BTC")).await;
1631
1632 let lookup =
1633 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1634 .await
1635 .unwrap();
1636
1637 let OrderLookup::Ended(order) = lookup else {
1638 panic!("expected an ended order, got {lookup:?}");
1639 };
1640 assert_eq!(order.key, key());
1641 assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1642 }
1643
1644 #[tokio::test]
1645 async fn a_perp_lookup_of_a_spot_order_is_unknown() {
1646 let (_server, client) = serve(order_status("@107")).await;
1647
1648 let lookup =
1649 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1650 .await
1651 .unwrap();
1652
1653 assert!(matches!(lookup, OrderLookup::Unknown));
1654 }
1655
1656 #[tokio::test]
1657 async fn a_perp_listing_names_only_the_orders_on_the_perpetuals_asked_about() {
1658 let rows = serde_json::json!([
1659 row("BTC", 1, Some(CLOID)),
1660 row("ETH", 2, None),
1661 row("@107", 3, None),
1662 ]);
1663 let (_server, client) = serve(rows).await;
1664
1665 let listed = perp_listed_cids(
1666 client,
1667 Arc::default(),
1668 ethers::types::H160::zero(),
1669 vec![InstrumentNameExchange::new("BTC-USDC-PERP")],
1670 )
1671 .await
1672 .unwrap();
1673
1674 assert_eq!(
1675 listed.into_iter().collect::<Vec<_>>(),
1676 [ClientOrderId::new(CID)]
1677 );
1678 }
1679 }
1680
1681 async fn client_against(
1685 server: &wiremock::MockServer,
1686 coin_to_asset: HashMap<String, u32>,
1687 ) -> HyperliquidClient {
1688 use super::common::info_tests::{
1689 exchange_client_against, info_client_against, test_wallet,
1690 };
1691 let mut dexes = super::perp_dexes::tests::xyz_and_flx().await;
1692 dexes.add_default_perps(&[hyperliquid_rust_sdk::AssetMeta {
1693 name: "BTC".to_owned(),
1694 sz_decimals: 5,
1695 }]);
1696 HyperliquidClient {
1697 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1698 info_client: Arc::new(info_client_against(server.uri()).await),
1699 exchange_client: Arc::new(exchange_client_against(server.uri(), coin_to_asset).await),
1700 dexes: Arc::new(dexes),
1701 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
1702 }
1703 }
1704
1705 mod open_order {
1706 use super::*;
1707 use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1708 use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1709 use wiremock::matchers::{method, path};
1710 use wiremock::{Mock, MockServer, ResponseTemplate};
1711
1712 async fn resting_server() -> MockServer {
1714 server_responding(serde_json::json!({
1715 "status": "ok",
1716 "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1717 }))
1718 .await
1719 }
1720
1721 async fn server_responding(body: serde_json::Value) -> MockServer {
1723 let server = MockServer::start().await;
1724 Mock::given(method("POST"))
1725 .and(path("/exchange"))
1726 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1727 .mount(&server)
1728 .await;
1729 server
1730 }
1731
1732 #[tokio::test]
1736 async fn an_order_refused_for_margin_is_balance_insufficient() {
1737 const MARGIN: &str = "Insufficient margin to place order. asset=0";
1738 const MINIMUM: &str = "Order must have minimum value of $10. asset=0";
1739 let in_status = |error: &str| {
1740 serde_json::json!({
1741 "status": "ok",
1742 "response": {"type": "order", "data": {"statuses": [{"error": error}]}},
1743 })
1744 };
1745 let in_response = |error: &str| serde_json::json!({"status": "err", "response": error});
1746 for (body, expected) in [
1747 (
1748 in_status(MARGIN),
1749 ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1750 ),
1751 (
1752 in_response(MARGIN),
1753 ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1754 ),
1755 (
1756 in_status(MINIMUM),
1757 ApiError::OrderRejected(MINIMUM.to_owned()),
1758 ),
1759 (
1760 in_response(MINIMUM),
1761 ApiError::OrderRejected(MINIMUM.to_owned()),
1762 ),
1763 ] {
1764 let server = server_responding(body.clone()).await;
1765 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1766 let order = place(
1767 &client,
1768 &InstrumentNameExchange::from("BTC-USDC-PERP"),
1769 OrderKind::Limit,
1770 Some(dec!(50000)),
1771 dec!(0.001),
1772 )
1773 .await;
1774 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1775 reported,
1776 ))) = &order.state
1777 else {
1778 panic!("{body}: expected a rejection, got {:?}", order.state);
1779 };
1780 assert_eq!(reported, &expected, "{body}");
1781 }
1782 }
1783
1784 async fn place(
1785 client: &HyperliquidClient,
1786 instrument: &InstrumentNameExchange,
1787 kind: OrderKind,
1788 price: Option<Decimal>,
1789 quantity: Decimal,
1790 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1791 client
1792 .open_order(OrderEvent {
1793 key: OrderKey {
1794 exchange: ExchangeId::HyperliquidPerp,
1795 instrument,
1796 strategy: StrategyId::new("strategy"),
1797 cid: ClientOrderId::uuid(),
1798 },
1799 state: RequestOpen {
1800 side: Side::Buy,
1801 price,
1802 quantity,
1803 kind,
1804 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1805 position_id: None,
1806 reduce_only: false,
1807 market: None,
1808 },
1809 })
1810 .await
1811 }
1812
1813 async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
1815 server
1816 .received_requests()
1817 .await
1818 .unwrap_or_default()
1819 .into_iter()
1820 .filter(|request| request.url.path() == "/exchange")
1821 .map(|request| {
1822 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1823 body["action"]["orders"][0].clone()
1824 })
1825 .collect()
1826 }
1827
1828 #[tokio::test]
1829 async fn the_client_reports_each_perpetuals_precision() {
1830 let server = MockServer::start().await;
1831 let client = client_against(&server, HashMap::new()).await;
1832
1833 assert_eq!(
1834 client.order_precision(&InstrumentNameExchange::from("BTC-USDC-PERP")),
1835 Some(OrderPrecision::perp(5))
1836 );
1837 assert_eq!(
1838 client.order_precision(&InstrumentNameExchange::from("xyz:TSLA-USDC-PERP")),
1839 Some(OrderPrecision::perp(2)),
1840 "a HIP-3 perpetual's szDecimals come from its DEX's meta"
1841 );
1842 assert_eq!(
1843 client.order_precision(&InstrumentNameExchange::from("ETH-USDC-PERP")),
1844 None,
1845 "not listed when the client connected"
1846 );
1847 assert_eq!(
1848 client.order_precision(&InstrumentNameExchange::from("HYPE-USDC-SPOT")),
1849 None
1850 );
1851 }
1852
1853 #[tokio::test]
1854 async fn an_order_breaking_the_precision_rules_is_refused_unsent() {
1855 let server = resting_server().await;
1856 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1857 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1858
1859 let cases = [
1860 (
1861 OrderKind::Limit,
1862 dec!(60000),
1863 dec!(0.000001),
1864 OrderField::Quantity,
1865 PrecisionLimit::DecimalPlaces { max: 5 },
1866 ),
1867 (
1868 OrderKind::Limit,
1869 dec!(60000),
1870 dec!(0),
1871 OrderField::Quantity,
1872 PrecisionLimit::NotPositive,
1873 ),
1874 (
1875 OrderKind::Limit,
1876 dec!(65432.1),
1877 dec!(0.001),
1878 OrderField::Price,
1879 PrecisionLimit::SignificantFigures { max: 5 },
1880 ),
1881 (
1882 OrderKind::Limit,
1883 dec!(6543.21),
1884 dec!(0.001),
1885 OrderField::Price,
1886 PrecisionLimit::DecimalPlaces { max: 1 },
1887 ),
1888 (
1889 OrderKind::StopLimit {
1890 trigger_price: dec!(6543.21),
1891 },
1892 dec!(6543),
1893 dec!(0.001),
1894 OrderField::TriggerPrice,
1895 PrecisionLimit::DecimalPlaces { max: 1 },
1896 ),
1897 ];
1898 for (kind, price, quantity, field, limit) in cases {
1899 let order = place(&client, &btc, kind, Some(price), quantity).await;
1900 let expected = PrecisionViolation {
1901 field,
1902 value: match field {
1903 OrderField::Quantity => quantity,
1904 OrderField::TriggerPrice => dec!(6543.21),
1905 _ => price,
1906 },
1907 limit,
1908 };
1909 assert_eq!(
1910 order.state,
1911 OrderState::Inactive(InactiveOrderState::OpenFailed(
1912 OrderError::InvalidPrecision(expected)
1913 )),
1914 "{kind} at {price} for {quantity}"
1915 );
1916 }
1917 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1918 }
1919
1920 #[tokio::test]
1921 async fn a_valid_order_is_sent_unchanged() {
1922 let server = resting_server().await;
1923 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1924 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1925
1926 let order = place(
1928 &client,
1929 &btc,
1930 OrderKind::Limit,
1931 Some(dec!(123456)),
1932 dec!(0.00123),
1933 )
1934 .await;
1935 assert!(
1936 matches!(order.state, OrderState::Active(_)),
1937 "{:?}",
1938 order.state
1939 );
1940
1941 let sent = sent_orders(&server).await;
1942 assert_eq!(sent.len(), 1);
1943 assert_eq!(
1944 (&sent[0]["p"], &sent[0]["s"]),
1945 (&serde_json::json!("123456"), &serde_json::json!("0.00123"))
1946 );
1947 }
1948
1949 #[tokio::test]
1950 async fn an_order_on_a_perpetual_listed_since_connecting_is_refused_unsent() {
1951 let server = resting_server().await;
1952 let client = client_against(&server, HashMap::from([("ETH".to_owned(), 1)])).await;
1953
1954 let order = place(
1955 &client,
1956 &InstrumentNameExchange::from("ETH-USDC-PERP"),
1957 OrderKind::Limit,
1958 Some(dec!(3000)),
1959 dec!(1),
1960 )
1961 .await;
1962 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1963 crate::error::ApiError::InstrumentInvalid(_, reason),
1964 ))) = &order.state
1965 else {
1966 panic!("expected InstrumentInvalid, got {:?}", order.state);
1967 };
1968 assert!(reason.contains("reconnect"), "{reason}");
1969 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1970 }
1971 }
1972
1973 mod cancel {
1974 use super::super::common::info_tests::{CANCEL_NOT_APPLIED, cancel_answer};
1975 use super::*;
1976 use crate::error::ApiError;
1977 use crate::order::{OrderEvent, request::RequestCancel};
1978 use wiremock::matchers::{method, path};
1979 use wiremock::{Mock, MockServer, ResponseTemplate};
1980
1981 async fn cancel_answered(
1984 answer: serde_json::Value,
1985 ) -> (UnindexedOrderResponseCancel, bool) {
1986 let server = MockServer::start().await;
1987 Mock::given(method("POST"))
1988 .and(path("/exchange"))
1989 .respond_with(ResponseTemplate::new(200).set_body_json(answer))
1990 .mount(&server)
1991 .await;
1992 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1993 let instrument = InstrumentNameExchange::new("BTC-USDC-PERP");
1994 let key = OrderKey {
1995 exchange: ExchangeId::HyperliquidPerp,
1996 instrument: instrument.clone(),
1997 strategy: StrategyId::new("strategy"),
1998 cid: ClientOrderId::uuid(),
1999 };
2000 let id = VenueOrderId::Assigned(OrderId::new("7"));
2001 let open = Open {
2002 id: id.clone(),
2003 time_exchange: Utc::now(),
2004 filled_quantity: Decimal::ZERO,
2005 };
2006 client
2007 .known_live
2008 .lock()
2009 .live(&key, dec!(1), OrderKind::Limit, &open);
2010
2011 let response = client
2012 .cancel_order(OrderEvent {
2013 key: OrderKey {
2014 exchange: key.exchange,
2015 instrument: &instrument,
2016 strategy: key.strategy.clone(),
2017 cid: key.cid.clone(),
2018 },
2019 state: RequestCancel { id: Some(id) },
2020 })
2021 .await;
2022 let live = client.known_live.lock().contains(&key.cid);
2023 (response, live)
2024 }
2025
2026 #[tokio::test]
2027 async fn a_success_status_cancels_the_order() {
2028 let (response, live) =
2029 cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
2030 assert!(response.state.is_ok(), "{:?}", response.state);
2031 assert!(!live, "a cancelled order is no longer held live");
2032 }
2033
2034 #[tokio::test]
2037 async fn an_error_status_is_a_rejected_cancel_and_the_order_stays_live() {
2038 let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
2039 {"error": CANCEL_NOT_APPLIED}
2040 ])))
2041 .await;
2042 let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
2043 &response.state
2044 else {
2045 panic!("expected a rejected cancel, got {:?}", response.state);
2046 };
2047 assert_eq!(reason, CANCEL_NOT_APPLIED);
2048 assert!(live, "the order is still held live");
2049 }
2050
2051 #[tokio::test]
2052 async fn an_answer_without_a_status_is_a_rejected_cancel() {
2053 let (response, live) = cancel_answered(serde_json::json!({
2054 "status": "ok",
2055 "response": {"type": "cancel"},
2056 }))
2057 .await;
2058 assert!(
2059 matches!(
2060 response.state,
2061 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
2062 ),
2063 "{:?}",
2064 response.state
2065 );
2066 assert!(live, "the order is still held live");
2067 }
2068 }
2069
2070 mod instrument_names {
2071 use super::*;
2072 use crate::error::ApiError;
2073 use crate::order::{
2074 OrderEvent,
2075 request::{RequestCancel, RequestOpen},
2076 };
2077 use wiremock::MockServer;
2078
2079 async fn offline_client() -> (MockServer, HyperliquidClient) {
2083 let server = MockServer::start().await;
2084 let client = client_against(&server, HashMap::new()).await;
2085 (server, client)
2086 }
2087
2088 const REFUSED: [&str; 4] = [
2091 "BTC-USD-PERP",
2092 "flx:TSLA-USDC-PERP",
2093 "km:TSLA-USDH-PERP",
2094 "HYPE-USDC-SPOT",
2095 ];
2096
2097 fn is_invalid(
2098 error: &ApiError<AssetNameExchange, InstrumentNameExchange>,
2099 name: &str,
2100 ) -> bool {
2101 matches!(error, ApiError::InstrumentInvalid(instrument, _) if instrument.as_ref() == name)
2102 }
2103
2104 #[tokio::test]
2105 async fn an_order_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2106 let (_server, client) = offline_client().await;
2107 for name in REFUSED {
2108 let instrument = InstrumentNameExchange::from(name);
2109 let order = client
2110 .open_order(OrderEvent {
2111 key: OrderKey {
2112 exchange: ExchangeId::HyperliquidPerp,
2113 instrument: &instrument,
2114 strategy: StrategyId::new("strategy"),
2115 cid: ClientOrderId::uuid(),
2116 },
2117 state: RequestOpen {
2118 side: Side::Buy,
2119 price: Some(dec!(1)),
2120 quantity: dec!(1),
2121 kind: OrderKind::Limit,
2122 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
2123 position_id: None,
2124 reduce_only: false,
2125 market: None,
2126 },
2127 })
2128 .await;
2129 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
2130 OrderError::Rejected(error),
2131 )) = &order.state
2132 else {
2133 panic!("{name}: expected a rejection, got {:?}", order.state);
2134 };
2135 assert!(is_invalid(error, name), "{name}: {error:?}");
2136 }
2137 }
2138
2139 #[tokio::test]
2140 async fn a_cancel_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2141 let (_server, client) = offline_client().await;
2142 for name in REFUSED {
2143 let instrument = InstrumentNameExchange::from(name);
2144 let response = client
2145 .cancel_order(OrderEvent {
2146 key: OrderKey {
2147 exchange: ExchangeId::HyperliquidPerp,
2148 instrument: &instrument,
2149 strategy: StrategyId::new("strategy"),
2150 cid: ClientOrderId::uuid(),
2151 },
2152 state: RequestCancel {
2153 id: Some(VenueOrderId::Assigned(OrderId::new("1"))),
2154 },
2155 })
2156 .await;
2157 let Err(UnindexedOrderError::Rejected(error)) = &response.state else {
2158 panic!("{name}: expected a rejection, got {:?}", response.state);
2159 };
2160 assert!(is_invalid(error, name), "{name}: {error:?}");
2161 }
2162 }
2163
2164 #[tokio::test]
2165 async fn a_read_filtered_to_a_name_the_client_does_not_trade_fails_unsent() {
2166 let (_server, client) = offline_client().await;
2167 for name in REFUSED {
2168 let instruments = [InstrumentNameExchange::from(name)];
2169 let snapshot = client.account_snapshot(&[], &instruments).await;
2170 let open = client.fetch_open_orders(&instruments).await;
2171 let trades = client
2172 .fetch_trades(
2173 Utc::now() - chrono::Duration::hours(1),
2174 Utc::now(),
2175 &instruments,
2176 )
2177 .await;
2178 for error in [snapshot.err(), open.err(), trades.err()] {
2179 let Some(UnindexedClientError::Api(error)) = &error else {
2180 panic!("{name}: expected an API error, got {error:?}");
2181 };
2182 assert!(is_invalid(error, name), "{name}: {error:?}");
2183 }
2184 }
2185 }
2186 }
2187
2188 fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
2189 serde_json::from_value(serde_json::json!({
2190 "coin": "BTC",
2191 "entryPx": "50000.0",
2192 "leverage": {"type": "cross", "value": 5},
2193 "liquidationPx": null,
2194 "marginUsed": "100.0",
2195 "positionValue": "500.0",
2196 "returnOnEquity": "0.0",
2197 "szi": szi,
2198 "unrealizedPnl": "1.5",
2199 "maxLeverage": 50,
2200 "cumFunding": {"allTime": "3.5", "sinceOpen": "1.25", "sinceChange": "0"}
2201 }))
2202 .unwrap()
2203 }
2204
2205 #[test]
2206 fn perp_position_report_is_open_flat_or_unreported() {
2207 let now = Utc::now();
2208 let open = perp_position_report(&perp_position("-0.01"), now);
2209 let position = open.open().unwrap();
2210 assert_eq!(
2212 (
2213 position.quantity,
2214 position.entry_price,
2215 position.leverage,
2216 position.carry
2217 ),
2218 (
2219 dec!(-0.01),
2220 Some(dec!(50000.0)),
2221 Some(dec!(5)),
2222 Some(dec!(-1.25))
2223 )
2224 );
2225 assert_eq!(
2226 perp_position_report(&perp_position("0.0"), now),
2227 PositionReport::Flat
2228 );
2229 assert_eq!(
2230 perp_position_report(&perp_position("not a number"), now),
2231 PositionReport::Unreported
2232 );
2233 }
2234
2235 #[test]
2236 fn test_fill_to_account_event() {
2237 let fill_json = r#"{
2238 "coin": "BTC",
2239 "side": "B",
2240 "px": "65000.5",
2241 "sz": "0.1",
2242 "time": 1714100000000,
2243 "hash": "0xabc123",
2244 "startPosition": "0",
2245 "dir": "Open Long",
2246 "closedPnl": "0",
2247 "oid": 12345,
2248 "cloid": null,
2249 "crossed": false,
2250 "fee": "0.65",
2251 "feeToken": "USDC",
2252 "tid": 99999
2253 }"#;
2254
2255 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2256 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2257
2258 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2259 match event.kind {
2260 AccountEventKind::Trade(trade) => {
2261 assert_eq!(trade.instrument.as_ref(), "BTC-USDC-PERP");
2262 assert_eq!(
2263 trade.id.0, "99999",
2264 "the id is the fill's tid, not its hash"
2265 );
2266 assert_eq!(trade.side, Side::Buy);
2267 assert_eq!(trade.price, dec!(65000.5));
2268 assert_eq!(trade.quantity, dec!(0.1));
2269 assert_eq!(trade.fees.fees, dec!(0.65));
2270 assert_eq!(trade.fees.asset.as_ref(), "USDC");
2271 assert_eq!(trade.fees.fees_quote, Some(dec!(0.65)));
2272 }
2273 _ => panic!("Expected Trade event"),
2274 }
2275 }
2276
2277 #[tokio::test]
2279 async fn a_fill_carries_the_client_order_id_of_its_cloid() {
2280 let fill_json = r#"{
2281 "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2282 "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2283 "dir": "Open Long", "closedPnl": "0", "oid": 7,
2284 "cloid": "0x0123456789abcdef0123456789abcdef",
2285 "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2286 }"#;
2287 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2288
2289 let dexes = perp_dexes::tests::xyz_and_flx().await;
2290 let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2291 else {
2292 panic!("Expected Trade event");
2293 };
2294 assert_eq!(
2295 trade.client_order_id,
2296 cloid_to_cid("0x0123456789abcdef0123456789abcdef")
2297 );
2298 assert!(trade.client_order_id.is_some());
2299 }
2300
2301 #[tokio::test]
2302 async fn a_builder_deployed_fill_is_named_and_charged_in_its_dex_collateral() {
2303 let fill_json = r#"{
2305 "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2306 "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2307 "dir": "Open Long", "closedPnl": "0", "oid": 7, "cloid": null,
2308 "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2309 }"#;
2310 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2311
2312 let dexes = perp_dexes::tests::xyz_and_flx().await;
2313 let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2314 else {
2315 panic!("Expected Trade event");
2316 };
2317 assert_eq!(trade.instrument.as_ref(), "flx:TSLA-USDH-PERP");
2318 assert_eq!(trade.client_order_id, None, "placed without a cloid");
2319 assert_eq!(trade.fees.asset.as_ref(), "USDH");
2320 assert_eq!(trade.fees.fees, dec!(0.2));
2321 assert_eq!(
2322 trade.fees.fees_quote,
2323 Some(dec!(0.2)),
2324 "the fee is in the instrument's quote"
2325 );
2326
2327 assert!(fill_to_account_event(&fill, &default_dex()).is_none());
2329 }
2330
2331 #[test]
2332 fn perp_fill_fees_reads_the_fee_token_and_falls_back_to_the_collateral() {
2333 assert_eq!(
2334 perp_fill_fees(Some("USDT0"), dec!(1.5), "USDC"),
2335 AssetFees::new(AssetNameExchange::from("USDT0"), dec!(1.5), None)
2336 );
2337 assert_eq!(
2338 perp_fill_fees(Some("USDC"), dec!(1.5), "USDC"),
2339 AssetFees::new(AssetNameExchange::from("USDC"), dec!(1.5), Some(dec!(1.5)))
2340 );
2341 assert_eq!(
2342 perp_fill_fees(None, dec!(1.5), "USDH"),
2343 AssetFees::new(AssetNameExchange::from("USDH"), dec!(1.5), Some(dec!(1.5))),
2344 "an absent feeToken is the DEX's collateral"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_fill_to_account_event_sell() {
2350 let fill_json = r#"{
2351 "coin": "ETH",
2352 "side": "A",
2353 "px": "3200",
2354 "sz": "1.5",
2355 "time": 1714100000000,
2356 "hash": "0xdef456",
2357 "startPosition": "1.5",
2358 "dir": "Close Long",
2359 "closedPnl": "150.0",
2360 "oid": 12346,
2361 "cloid": null,
2362 "crossed": true,
2363 "fee": "4.8",
2364 "feeToken": "USDC",
2365 "tid": 100000
2366 }"#;
2367
2368 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2369 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2370
2371 match event.kind {
2372 AccountEventKind::Trade(trade) => {
2373 assert_eq!(trade.instrument.as_ref(), "ETH-USDC-PERP");
2374 assert_eq!(trade.side, Side::Sell);
2375 assert_eq!(trade.price, dec!(3200));
2376 assert_eq!(trade.quantity, dec!(1.5));
2377 }
2378 _ => panic!("Expected Trade event"),
2379 }
2380 }
2381
2382 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
2384 let json = format!(
2385 r#"{{
2386 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
2387 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
2388 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
2389 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
2390 }}"#
2391 );
2392 serde_json::from_str(&json).unwrap()
2393 }
2394
2395 #[test]
2396 fn a_trade_id_identifies_the_fill_not_the_transaction() {
2397 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2401 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2402
2403 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
2404 (first.kind, second.kind)
2405 else {
2406 panic!("Expected Trade events");
2407 };
2408
2409 assert_eq!(first.order_id, second.order_id, "one order produced both");
2410 assert_ne!(first.id, second.id, "but they are distinct fills");
2411 assert_eq!(first.id.0, "111");
2412 assert_eq!(second.id.0, "222");
2413 }
2414
2415 #[test]
2416 fn a_replayed_fill_is_delivered_once() {
2417 let cache = new_dedup_cache();
2420 let event = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2421 let replay = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2422
2423 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
2424 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
2425 }
2426
2427 #[test]
2428 fn both_fills_of_one_sweep_survive_dedup() {
2429 let cache = new_dedup_cache();
2432 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2433 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2434
2435 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
2436 assert!(!is_duplicate(
2437 &cache,
2438 dedup_key_from_event(&second).unwrap()
2439 ));
2440 }
2441
2442 #[test]
2443 fn one_tid_on_two_instruments_is_two_fills() {
2444 let cache = new_dedup_cache();
2447 let btc = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2448
2449 let eth_json = r#"{
2450 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
2451 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
2452 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
2453 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
2454 }"#;
2455 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
2456 let eth = fill_to_account_event(ð, &default_dex()).unwrap();
2457
2458 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
2459 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
2460 }
2461
2462 #[test]
2463 fn test_order_update_to_account_event_open() {
2464 let update_json = r#"{
2465 "order": {
2466 "coin": "BTC",
2467 "side": "B",
2468 "limitPx": "64000",
2469 "sz": "0.5",
2470 "oid": 12345,
2471 "timestamp": 1714100000000,
2472 "origSz": "0.5",
2473 "cloid": null
2474 },
2475 "status": "open",
2476 "statusTimestamp": 1714100000000
2477 }"#;
2478
2479 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2480 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2481
2482 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2483 match event.kind {
2484 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2485 assert_eq!(order.key.instrument.as_ref(), "BTC-USDC-PERP");
2486 assert_eq!(order.side, Side::Buy);
2487 assert_eq!(order.price, Some(dec!(64000)));
2488 assert_eq!(order.quantity, dec!(0.5));
2489 assert!(matches!(
2490 order.state,
2491 crate::order::state::OrderState::Active(_)
2492 ));
2493 }
2494 _ => panic!("Expected OrderSnapshot event"),
2495 }
2496 }
2497
2498 #[test]
2499 fn test_order_update_to_account_event_filled() {
2500 let update_json = r#"{
2501 "order": {
2502 "coin": "ETH",
2503 "side": "A",
2504 "limitPx": "3250",
2505 "sz": "0",
2506 "oid": 12346,
2507 "timestamp": 1714100000000,
2508 "origSz": "2.0",
2509 "cloid": null
2510 },
2511 "status": "filled",
2512 "statusTimestamp": 1714100001000
2513 }"#;
2514
2515 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2516 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2517
2518 match event.kind {
2519 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2520 assert_eq!(order.side, Side::Sell);
2521 assert!(matches!(
2522 order.state,
2523 crate::order::state::OrderState::Inactive(
2524 crate::order::state::InactiveOrderState::FullyFilled(_)
2525 )
2526 ));
2527 }
2528 _ => panic!("Expected OrderSnapshot event"),
2529 }
2530 }
2531
2532 #[test]
2533 fn test_order_update_to_account_event_cancelled() {
2534 let update_json = r#"{
2535 "order": {
2536 "coin": "SOL",
2537 "side": "B",
2538 "limitPx": "150",
2539 "sz": "10",
2540 "oid": 12347,
2541 "timestamp": 1714100000000,
2542 "origSz": "10",
2543 "cloid": null
2544 },
2545 "status": "canceled",
2546 "statusTimestamp": 1714100002000
2547 }"#;
2548
2549 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2550 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2551
2552 match event.kind {
2553 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2554 assert_eq!(order.key.instrument.as_ref(), "SOL-USDC-PERP");
2555 assert!(matches!(
2556 order.state,
2557 crate::order::state::OrderState::Inactive(
2558 crate::order::state::InactiveOrderState::Cancelled(_)
2559 )
2560 ));
2561 }
2562 _ => panic!("Expected OrderSnapshot event"),
2563 }
2564 }
2565
2566 #[test]
2567 fn test_order_update_unknown_status_returns_none() {
2568 let update_json = r#"{
2569 "order": {
2570 "coin": "BTC",
2571 "side": "B",
2572 "limitPx": "64000",
2573 "sz": "0.5",
2574 "oid": 12345,
2575 "timestamp": 1714100000000,
2576 "origSz": "0.5",
2577 "cloid": null
2578 },
2579 "status": "unknown_status",
2580 "statusTimestamp": 1714100000000
2581 }"#;
2582
2583 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2584 let event = order_update_to_account_event(&update, &default_dex());
2585 assert!(event.is_none());
2586 }
2587}