1pub mod common;
147pub mod config;
148pub mod error;
149mod order_recovery;
150mod perp_account;
151mod perp_dexes;
152pub mod precision;
153pub mod spot;
154mod spot_coins;
155
156use crate::client::dedup::new_dedup_cache;
157use crate::client::order_recovery::{
158 KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
159};
160use crate::{
161 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
162 UnindexedAccountEvent, UnindexedAccountSnapshot,
163 balance::AssetBalance,
164 client::{ExecutionClient, OrderStatusClient},
165 emit_stream_terminated,
166 error::{
167 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
168 UnindexedOrderError,
169 },
170 order::{
171 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
172 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
173 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
174 state::{Filled, Open, OrderState, UnindexedOrderState},
175 },
176 position::{Position, PositionReport},
177 trade::{AssetFees, Trade, TradeId, TradesRead},
178};
179use chrono::{DateTime, Utc};
180use common::{
181 CLOID_REQUIRED, CancelOnDropStream, OpenOrderListing, UnknownCoins, cancel_outcome,
182 cid_to_cloid, map_tif, millis_to_datetime, open_order_to_order, parse_decimal, parse_side,
183 span_millis, user_fills_by_time, warn_unknown_coins,
184};
185pub use config::{HyperliquidConfig, HyperliquidConfigError, Network};
186pub use error::HyperliquidConnectError;
187use error::map_order_error;
188use ethers::signers::Signer;
189use fnv::FnvHashSet;
190use futures::{StreamExt, stream::BoxStream};
191use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
192use order_recovery::{
193 ReconnectWatch, fetch_order_record, listed_cids, lookup_from_record, remember_open,
194 remember_snapshot, send_fills, send_observed, spawn_order_checks,
195};
196use perp_account::{account_mode, dex_states};
197use perp_dexes::{PerpDexes, UnconfiguredDexes, warn_unconfigured_dexes};
198pub use precision::OrderPrecision;
199use precision::{WireOrderError, wire_order};
200use rust_decimal::Decimal;
201use rustrade_instrument::{
202 Side,
203 asset::name::AssetNameExchange,
204 exchange::ExchangeId,
205 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
206};
207use smol_str::format_smolstr;
208use std::{
209 collections::HashSet,
210 sync::{
211 Arc,
212 atomic::{AtomicBool, Ordering},
213 },
214};
215use tokio::sync::{Notify, mpsc};
216use tokio_util::sync::CancellationToken;
217use tracing::{debug, error, info, warn};
218
219#[derive(Debug, Clone)]
224pub struct HyperliquidClient {
225 config: HyperliquidConfig,
226 info_client: Arc<InfoClient>,
227 exchange_client: Arc<ExchangeClient>,
228 dexes: Arc<PerpDexes>,
230 known_live: SharedKnownLiveOrders,
233}
234
235impl HyperliquidClient {
236 pub async fn connect(config: HyperliquidConfig) -> Result<Self, HyperliquidConnectError> {
257 let base_url = config.base_url();
258
259 let info_client = InfoClient::new(None, Some(base_url))
260 .await
261 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
262
263 let mut dexes = PerpDexes::fetch(&info_client, &config.dexes).await?;
264
265 let wallet = config.wallet.clone();
266 let mut exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
267 .await
268 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
269 exchange_client.coin_to_asset.extend(dexes.asset_ids());
271 dexes.add_default_perps(&exchange_client.meta.universe);
272
273 info!(
274 network = ?config.network,
275 dexes = ?config.dexes,
276 wallet = %config.wallet_address_hex(),
277 "Created HyperliquidClient"
278 );
279
280 Ok(Self {
281 config,
282 info_client: Arc::new(info_client),
283 exchange_client: Arc::new(exchange_client),
284 dexes: Arc::new(dexes),
285 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
286 })
287 }
288
289 pub fn wallet_address(&self) -> String {
291 self.config.wallet_address_hex()
292 }
293
294 pub fn order_precision(&self, instrument: &InstrumentNameExchange) -> Option<OrderPrecision> {
304 let coin = self.dexes.coin(instrument).ok()?;
305 self.dexes.sz_decimals(coin).map(OrderPrecision::perp)
306 }
307
308 fn wallet_h160(&self) -> ethers::types::H160 {
310 self.config.wallet.address()
311 }
312
313 fn check_instruments(
317 &self,
318 instruments: &[InstrumentNameExchange],
319 ) -> Result<(), UnindexedClientError> {
320 for instrument in instruments {
321 self.dexes.coin(instrument).map_err(|reason| {
322 UnindexedClientError::Api(crate::error::ApiError::InstrumentInvalid(
323 instrument.clone(),
324 reason,
325 ))
326 })?;
327 }
328 Ok(())
329 }
330}
331
332impl ExecutionClient for HyperliquidClient {
333 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
334
335 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
338 &[InstrumentKindDiscriminant::Perpetual];
339
340 type Config = HyperliquidConfig;
341 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
342
343 fn new(config: Self::Config) -> Self {
357 tokio::runtime::Handle::current()
360 .block_on(Self::connect(config))
361 .unwrap_or_else(|e| panic!("Failed to create HyperliquidClient: {e}"))
362 }
363
364 async fn account_snapshot(
407 &self,
408 _assets: &[AssetNameExchange],
409 instruments: &[InstrumentNameExchange],
410 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
411 self.check_instruments(instruments)?;
412 let address = self.wallet_h160();
413 let info_client = &*self.info_client;
414 let dexes = &*self.dexes;
415
416 let (mode, states, open_orders) = tokio::try_join!(
417 account_mode(info_client, address),
418 dex_states(info_client, address, dexes),
419 perp_account::open_orders(info_client, address, dexes),
420 )?;
421 let now = Utc::now();
422 let balances =
423 perp_account::snapshot_balances(info_client, address, dexes, mode, &states, now)
424 .await?;
425
426 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
428 None
429 } else {
430 let mut set = HashSet::with_capacity(instruments.len());
431 set.extend(instruments.iter().cloned());
432 Some(set)
433 };
434
435 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
436 let mut listing = OpenOrderListing::new(
437 &open_orders,
438 ExchangeId::HyperliquidPerp,
439 instruments,
440 |coin| dexes.instrument(coin),
441 );
442
443 let mut instrument_snapshots = Vec::new();
445 for asset_pos in states.iter().flat_map(|(_, state)| &state.asset_positions) {
446 let pos = &asset_pos.position;
447 let Some(instrument) = dexes.instrument(&pos.coin) else {
448 continue;
449 };
450
451 if instrument_filter
452 .as_ref()
453 .is_some_and(|f| !f.contains(&instrument))
454 {
455 continue;
456 }
457
458 let position = perp_position_report(pos, now);
459
460 let (orders, orders_complete) = match listing.remove(&instrument) {
461 Some(listed) => (listed.orders, listed.orders_complete),
462 None => (Vec::new(), true),
465 };
466
467 instrument_snapshots.push(InstrumentAccountSnapshot {
468 instrument,
469 orders,
470 orders_complete,
471 position,
472 isolated: None,
473 });
474 }
475
476 instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
479 snapshot.position = PositionReport::Flat;
480 snapshot
481 }));
482
483 let snapshot = AccountSnapshot {
484 exchange: ExchangeId::HyperliquidPerp,
485 balances,
486 instruments: instrument_snapshots,
487 };
488 remember_snapshot(&self.known_live, &snapshot);
489 Ok(snapshot)
490 }
491
492 async fn account_stream(
548 &self,
549 _assets: &[AssetNameExchange],
550 _instruments: &[InstrumentNameExchange],
551 ) -> Result<Self::AccountStream, UnindexedClientError> {
552 let user = self.wallet_h160();
553 let base_url = self.config.base_url();
554
555 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
558 .await
559 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
560
561 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
563 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
564
565 ws_client
567 .subscribe(Subscription::UserFills { user }, fills_tx)
568 .await
569 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
570
571 ws_client
573 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
574 .await
575 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
576
577 info!(%user, "Subscribed to Hyperliquid account stream");
578
579 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
581
582 let cancel_token = CancellationToken::new();
584
585 let terminated = Arc::new(AtomicBool::new(false));
590
591 let fills_dedup = new_dedup_cache();
595
596 let reconnected = Arc::new(Notify::new());
600 let checks_cancel = cancel_token.child_token();
601 let (list_client, lookup_client) = (self.info_client.clone(), self.info_client.clone());
602 let (list_dexes, lookup_dexes) = (self.dexes.clone(), self.dexes.clone());
603 spawn_order_checks(
604 ExchangeId::HyperliquidPerp,
605 self.known_live.clone(),
606 reconnected.clone(),
607 checks_cancel.clone(),
608 event_tx.clone(),
609 move |instruments| {
610 perp_listed_cids(list_client.clone(), list_dexes.clone(), user, instruments)
611 },
612 move |key| perp_order_lookup(lookup_client.clone(), lookup_dexes.clone(), user, key),
613 );
614
615 let fills_event_tx = event_tx.clone();
617 let fills_cancel = cancel_token.clone();
618 let fills_terminated = terminated.clone();
619 let fills_checks_cancel = checks_cancel.clone();
620 let fills_dexes = self.dexes.clone();
621 tokio::spawn(async move {
622 let mut unknown_coins = UnknownCoins::default();
623 let mut unconfigured = UnconfiguredDexes::default();
624 let mut reconnects = ReconnectWatch::new(reconnected);
625 loop {
626 tokio::select! {
627 biased;
628 () = fills_cancel.cancelled() => {
629 debug!("Fills task cancelled");
630 return;
631 }
632 msg = fills_rx.recv() => {
633 let Some(msg) = msg else {
634 debug!("Fills receiver closed");
635 fills_checks_cancel.cancel();
636 if fills_terminated
639 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
640 .is_ok()
641 {
642 emit_stream_terminated(
643 &fills_event_tx,
644 ExchangeId::HyperliquidPerp,
645 StreamTerminationReason::Error(
646 "hyperliquid account stream closed".to_string(),
647 ),
648 );
649 }
650 return;
651 };
652 match msg {
653 Message::UserFills(fills) => {
654 let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
655 unknown_coins.warn_once(&fill.coin);
656 unconfigured.warn_once(&fills_dexes, &fill.coin);
657 fill_to_account_event(fill, &fills_dexes)
658 };
659 if !send_fills(
660 &fills.data,
661 convert,
662 &fills_dedup,
663 &fills_event_tx,
664 &mut reconnects,
665 ) {
666 debug!("Fills event channel closed");
667 return;
668 }
669 }
670 Message::NoData => {
671 warn!("UserFills WebSocket disconnected");
672 reconnects.dropped();
673 }
674 Message::HyperliquidError(e) => {
675 error!(%e, "UserFills WebSocket error");
678 }
679 _ => {}
680 }
681 }
682 }
683 }
684 });
685
686 let orders_event_tx = event_tx;
691 let orders_cancel = cancel_token.clone();
692 let orders_terminated = terminated;
693 let orders_known = self.known_live.clone();
694 let orders_dexes = self.dexes.clone();
695 tokio::spawn(async move {
696 let _ws_client = ws_client;
697 let mut unknown_coins = UnknownCoins::default();
698 let mut unconfigured = UnconfiguredDexes::default();
699
700 loop {
701 tokio::select! {
702 biased;
703 () = orders_cancel.cancelled() => {
704 debug!("Orders task cancelled");
705 return;
706 }
707 msg = orders_rx.recv() => {
708 let Some(msg) = msg else {
709 debug!("Orders receiver closed");
710 checks_cancel.cancel();
711 if orders_terminated
714 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
715 .is_ok()
716 {
717 emit_stream_terminated(
718 &orders_event_tx,
719 ExchangeId::HyperliquidPerp,
720 StreamTerminationReason::Error(
721 "hyperliquid account stream closed".to_string(),
722 ),
723 );
724 }
725 return;
726 };
727 match msg {
728 Message::OrderUpdates(updates) => {
729 for update in updates.data {
730 unknown_coins.warn_once(&update.order.coin);
731 unconfigured.warn_once(&orders_dexes, &update.order.coin);
732 if let Some(event) =
733 order_update_to_account_event(&update, &orders_dexes)
734 && !send_observed(&orders_known, &orders_event_tx, event)
735 {
736 debug!("Orders event channel closed");
737 return;
738 }
739 }
740 }
741 Message::NoData => {
742 warn!("OrderUpdates WebSocket disconnected");
743 }
744 Message::HyperliquidError(e) => {
745 error!(%e, "OrderUpdates WebSocket error");
748 }
749 _ => {}
750 }
751 }
752 }
753 }
754 });
755
756 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
758 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
759 Ok(guarded_stream.boxed())
760 }
761
762 async fn cancel_order(
763 &self,
764 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
765 ) -> UnindexedOrderResponseCancel {
766 use crate::order::{request::OrderResponseCancel, state::Cancelled};
767 use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
768 use uuid::Uuid;
769
770 let coin = match self.dexes.coin(request.key.instrument) {
771 Ok(coin) => coin.to_owned(),
772 Err(reason) => {
773 warn!(%reason, cid = %request.key.cid, "Cannot cancel order on this instrument");
774 return OrderResponseCancel {
775 key: OrderKey {
776 exchange: ExchangeId::HyperliquidPerp,
777 instrument: request.key.instrument.clone(),
778 strategy: request.key.strategy.clone(),
779 cid: request.key.cid.clone(),
780 },
781 state: Err(UnindexedOrderError::Rejected(
782 crate::error::ApiError::InstrumentInvalid(
783 request.key.instrument.clone(),
784 reason,
785 ),
786 )),
787 };
788 }
789 };
790
791 enum CancelMethod {
792 ByOid(u64),
793 ByCloid(Uuid),
794 }
795
796 let cancel_method = match &request.state.id {
801 Some(VenueOrderId::Assigned(id)) => {
802 id.0.parse::<u64>()
803 .map(CancelMethod::ByOid)
804 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
805 }
806 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
810 .map(CancelMethod::ByCloid)
811 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
812 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
813 };
814
815 let cancel_method = match cancel_method {
816 Ok(method) => method,
817 Err(reason) => {
818 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
819 return OrderResponseCancel {
820 key: OrderKey {
821 exchange: ExchangeId::HyperliquidPerp,
822 instrument: request.key.instrument.clone(),
823 strategy: request.key.strategy.clone(),
824 cid: request.key.cid.clone(),
825 },
826 state: Err(UnindexedOrderError::Rejected(
827 crate::error::ApiError::OrderRejected(reason.to_string()),
828 )),
829 };
830 }
831 };
832
833 let cancelled_id = match &request.state.id {
836 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
837 None => OrderId(request.key.cid.0.clone()),
838 };
839
840 let response = match cancel_method {
841 CancelMethod::ByOid(oid) => {
842 debug!(oid, "Cancelling by OID");
843 let cancel_request = ClientCancelRequest { asset: coin, oid };
844 self.exchange_client.cancel(cancel_request, None).await
845 }
846 CancelMethod::ByCloid(cloid) => {
847 debug!(%cloid, "Cancelling by cloid (no oid assigned)");
848 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
849 self.exchange_client
850 .cancel_by_cloid(cancel_request, None)
851 .await
852 }
853 };
854
855 let response = match response {
856 Ok(r) => r,
857 Err(e) => {
858 warn!(%e, "Cancel order failed (transport)");
859 return OrderResponseCancel {
860 key: OrderKey {
861 exchange: ExchangeId::HyperliquidPerp,
862 instrument: request.key.instrument.clone(),
863 strategy: request.key.strategy.clone(),
864 cid: request.key.cid.clone(),
865 },
866 state: Err(map_order_error(e, request.key.instrument)),
867 };
868 }
869 };
870
871 match cancel_outcome(response) {
872 Ok(()) => {
873 debug!("Cancel order accepted");
874 self.known_live.lock().ended(&request.key.cid);
876 OrderResponseCancel {
878 key: OrderKey {
879 exchange: ExchangeId::HyperliquidPerp,
880 instrument: request.key.instrument.clone(),
881 strategy: request.key.strategy.clone(),
882 cid: request.key.cid.clone(),
883 },
884 state: Ok(Cancelled::new(
885 cancelled_id.clone(),
886 Utc::now(),
887 None, )),
889 }
890 }
891 Err(reason) => {
893 warn!(%reason, cid = %request.key.cid, "Cancel rejected by exchange");
894 OrderResponseCancel {
895 key: OrderKey {
896 exchange: ExchangeId::HyperliquidPerp,
897 instrument: request.key.instrument.clone(),
898 strategy: request.key.strategy.clone(),
899 cid: request.key.cid.clone(),
900 },
901 state: Err(UnindexedOrderError::Rejected(
902 crate::error::ApiError::OrderRejected(reason),
903 )),
904 }
905 }
906 }
907 }
908
909 async fn open_order(
910 &self,
911 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
912 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
913 use hyperliquid_rust_sdk::{
914 ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
915 };
916
917 let make_inactive =
918 |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
919 Order {
920 key: OrderKey {
921 exchange: ExchangeId::HyperliquidPerp,
922 instrument: request.key.instrument.clone(),
923 strategy: request.key.strategy.clone(),
924 cid: request.key.cid.clone(),
925 },
926 side: request.state.side,
927 price: request.state.price,
928 quantity: request.state.quantity,
929 kind: request.state.kind,
930 time_in_force: request.state.time_in_force,
931 state: OrderState::inactive(error),
932 }
933 };
934 let make_rejected = |msg: String| {
935 make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
936 msg,
937 )))
938 };
939 let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
940 let make_invalid_instrument = |reason: String| {
941 make_inactive(OrderError::Rejected(
942 crate::error::ApiError::InstrumentInvalid(request.key.instrument.clone(), reason),
943 ))
944 };
945
946 let coin = match self.dexes.coin(request.key.instrument) {
947 Ok(coin) => coin.to_owned(),
948 Err(reason) => return make_invalid_instrument(reason),
949 };
950 let is_buy = request.state.side == Side::Buy;
951
952 match request.state.kind {
953 OrderKind::Market => {
954 return make_unsupported(
955 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
956 .to_string(),
957 );
958 }
959 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
960 return make_unsupported(
961 "Hyperliquid does not support trailing stop orders".to_string(),
962 );
963 }
964 OrderKind::Limit
965 | OrderKind::Stop { .. }
966 | OrderKind::StopLimit { .. }
967 | OrderKind::TakeProfit { .. }
968 | OrderKind::TakeProfitLimit { .. } => {}
969 }
970
971 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
974 return make_rejected(CLOID_REQUIRED.to_string());
975 };
976
977 let tif = map_tif(&request.state.time_in_force).to_string();
978
979 let Some(precision) = self.order_precision(request.key.instrument) else {
980 return make_invalid_instrument(format!(
981 "{coin} was not listed when the client connected; reconnect to trade a perpetual \
982 listed since"
983 ));
984 };
985 let wire = match wire_order(
986 &precision,
987 request.state.kind,
988 tif,
989 request.state.price,
990 request.state.quantity,
991 ) {
992 Ok(wire) => wire,
993 Err(WireOrderError::Precision(violation)) => {
994 return make_inactive(OrderError::InvalidPrecision(violation));
995 }
996 Err(WireOrderError::MissingPrice) => {
997 return make_rejected(
998 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
999 .to_string(),
1000 );
1001 }
1002 Err(WireOrderError::UnsupportedKind) => {
1003 return make_unsupported(format!(
1004 "Hyperliquid does not support {} orders",
1005 request.state.kind
1006 ));
1007 }
1008 };
1009
1010 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
1012 warn!(
1013 instrument = %request.key.instrument,
1014 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
1015 );
1016 }
1017
1018 let order_request = ClientOrderRequest {
1019 asset: coin,
1020 is_buy,
1021 reduce_only: request.state.reduce_only,
1022 limit_px: wire.limit_px,
1023 sz: wire.sz,
1024 cloid: Some(cloid),
1025 order_type: wire.order_type,
1026 };
1027
1028 let response = match self.exchange_client.order(order_request, None).await {
1029 Ok(r) => r,
1030 Err(e) => {
1031 warn!(%e, "Open order failed");
1032 return Order {
1033 key: OrderKey {
1034 exchange: ExchangeId::HyperliquidPerp,
1035 instrument: request.key.instrument.clone(),
1036 strategy: request.key.strategy.clone(),
1037 cid: request.key.cid.clone(),
1038 },
1039 side: request.state.side,
1040 price: request.state.price,
1041 quantity: request.state.quantity,
1042 kind: request.state.kind,
1043 time_in_force: request.state.time_in_force,
1044 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
1045 };
1046 }
1047 };
1048
1049 let state = match response {
1051 ExchangeResponseStatus::Ok(exchange_resp) => {
1052 let status = exchange_resp
1054 .data
1055 .and_then(|d| d.statuses.into_iter().next());
1056
1057 match status {
1058 Some(ExchangeDataStatus::Resting(resting)) => {
1059 debug!(oid = resting.oid, "Order resting");
1060 OrderState::active(Open {
1061 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
1062 time_exchange: Utc::now(),
1063 filled_quantity: Decimal::ZERO,
1064 })
1065 }
1066 Some(ExchangeDataStatus::Filled(filled)) => {
1067 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
1068 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
1070 OrderState::fully_filled(Filled::new(
1071 OrderId(format_smolstr!("{}", filled.oid)),
1072 Utc::now(),
1073 parse_decimal(&filled.total_sz, "total_sz")
1074 .unwrap_or(request.state.quantity),
1075 avg_price,
1076 ))
1077 }
1078 Some(ExchangeDataStatus::Error(msg)) => {
1079 warn!(%msg, "Order rejected by exchange");
1080 OrderState::inactive(OrderError::Rejected(
1081 crate::error::ApiError::OrderRejected(msg),
1082 ))
1083 }
1084 Some(
1085 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
1086 ) => {
1087 debug!(cid = %request.key.cid, "Order waiting under its cloid");
1091 OrderState::active(Open {
1092 id: VenueOrderId::ClientAssigned,
1093 time_exchange: Utc::now(),
1094 filled_quantity: Decimal::ZERO,
1095 })
1096 }
1097 Some(ExchangeDataStatus::Success) | None => {
1098 warn!("Order accepted but no order ID returned");
1101 OrderState::inactive(OrderError::Rejected(
1102 crate::error::ApiError::OrderRejected(
1103 "no order ID in response".to_string(),
1104 ),
1105 ))
1106 }
1107 }
1108 }
1109 ExchangeResponseStatus::Err(msg) => {
1110 warn!(%msg, "Order rejected");
1111 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
1112 msg,
1113 )))
1114 }
1115 };
1116 let order = Order {
1117 key: OrderKey {
1118 exchange: ExchangeId::HyperliquidPerp,
1119 instrument: request.key.instrument.clone(),
1120 strategy: request.key.strategy.clone(),
1121 cid: request.key.cid.clone(),
1122 },
1123 side: request.state.side,
1124 price: request.state.price,
1125 quantity: request.state.quantity,
1126 kind: request.state.kind,
1127 time_in_force: request.state.time_in_force,
1128 state,
1129 };
1130 self.known_live
1131 .lock()
1132 .placed(&order.key, order.quantity, &order.state);
1133 order
1134 }
1135
1136 async fn fetch_balances(
1140 &self,
1141 _assets: &[AssetNameExchange],
1142 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1143 perp_account::fetch_balances(&self.info_client, self.wallet_h160(), &self.dexes).await
1144 }
1145
1146 async fn fetch_open_orders(
1153 &self,
1154 instruments: &[InstrumentNameExchange],
1155 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1156 self.check_instruments(instruments)?;
1157 let address = self.wallet_h160();
1158
1159 let open_orders =
1160 perp_account::open_orders(&self.info_client, address, &self.dexes).await?;
1161 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
1162
1163 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1164 None
1165 } else {
1166 let mut set = HashSet::with_capacity(instruments.len());
1167 set.extend(instruments.iter().cloned());
1168 Some(set)
1169 };
1170
1171 let orders: Vec<_> = open_orders
1172 .iter()
1173 .filter_map(|order| {
1174 let instrument = self.dexes.instrument(&order.coin)?;
1175 if instrument_filter
1176 .as_ref()
1177 .is_some_and(|f| !f.contains(&instrument))
1178 {
1179 return None;
1180 }
1181 open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1182 })
1183 .collect();
1184 remember_open(&self.known_live, &orders);
1185 Ok(orders)
1186 }
1187
1188 async fn fetch_trades(
1198 &self,
1199 start: DateTime<Utc>,
1200 end: DateTime<Utc>,
1201 instruments: &[InstrumentNameExchange],
1202 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1203 self.check_instruments(instruments)?;
1204 let Some((start_ms, end_ms)) = span_millis(start, end) else {
1205 return Ok(TradesRead::complete(Vec::new()));
1206 };
1207 let address = self.wallet_h160();
1208
1209 let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1210 warn_unknown_coins(fills.iter().map(|fill| fill.coin.as_str()));
1211 warn_unconfigured_dexes(&self.dexes, fills.iter().map(|fill| fill.coin.as_str()));
1212
1213 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1214 None
1215 } else {
1216 let mut set = HashSet::with_capacity(instruments.len());
1217 set.extend(instruments.iter().cloned());
1218 Some(set)
1219 };
1220
1221 let mut result = Vec::new();
1222 for fill in fills {
1223 let Some((instrument, collateral)) = self.dexes.perp(&fill.coin) else {
1224 continue;
1225 };
1226
1227 if instrument_filter
1228 .as_ref()
1229 .is_some_and(|f| !f.contains(&instrument))
1230 {
1231 continue;
1232 }
1233
1234 let Some(side) = parse_side(&fill.side) else {
1235 continue;
1236 };
1237 let Some(price) = parse_decimal(&fill.px, "px") else {
1238 continue;
1239 };
1240 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1241 continue;
1242 };
1243 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1244
1245 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1246 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1247 continue;
1248 };
1249 if !(start..=end).contains(&time_exchange) {
1250 continue;
1251 }
1252
1253 result.push(Trade {
1254 id: TradeId(format_smolstr!("{}", fill.tid)),
1255 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1256 instrument,
1257 strategy: StrategyId::unknown(),
1258 time_exchange,
1259 side,
1260 price,
1261 quantity,
1262 order_filled_quantity: None,
1265 fees: perp_fill_fees(fill.fee_token.as_deref(), fee, collateral),
1266 });
1267 }
1268
1269 Ok(TradesRead::complete(result))
1270 }
1271}
1272
1273impl OrderStatusClient for HyperliquidClient {
1288 async fn fetch_ended_orders(
1289 &self,
1290 orders: &[UnindexedOrderKey],
1291 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1292 let address = self.wallet_h160();
1293 fetch_ended_by_key(orders, |key| {
1294 perp_order_lookup(self.info_client.clone(), self.dexes.clone(), address, key)
1295 })
1296 .await
1297 }
1298}
1299
1300async fn perp_order_lookup(
1302 info_client: Arc<InfoClient>,
1303 dexes: Arc<PerpDexes>,
1304 address: ethers::types::H160,
1305 key: UnindexedOrderKey,
1306) -> Result<OrderLookup, UnindexedClientError> {
1307 let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1308 return Ok(OrderLookup::Unknown);
1309 };
1310 let instrument = dexes.instrument(&record.order.coin);
1311 Ok(lookup_from_record(key, &record, instrument))
1312}
1313
1314async fn perp_listed_cids(
1317 info_client: Arc<InfoClient>,
1318 dexes: Arc<PerpDexes>,
1319 address: ethers::types::H160,
1320 instruments: Vec<InstrumentNameExchange>,
1321) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1322 let rows = perp_account::open_orders(&info_client, address, &dexes).await?;
1323 Ok(listed_cids(&rows, &instruments, |coin| {
1324 dexes.instrument(coin)
1325 }))
1326}
1327
1328fn perp_position_report(
1334 position: &hyperliquid_rust_sdk::PositionData,
1335 now: DateTime<Utc>,
1336) -> PositionReport {
1337 let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1338 return PositionReport::Unreported;
1339 };
1340 PositionReport::from_position(Position::new(
1341 quantity,
1342 position
1343 .entry_px
1344 .as_ref()
1345 .and_then(|p| parse_decimal(p, "entry_px")),
1346 parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1347 parse_decimal(&position.margin_used, "margin_used"),
1348 position
1349 .liquidation_px
1350 .as_ref()
1351 .and_then(|p| parse_decimal(p, "liquidation_px")),
1352 Some(Decimal::from(position.leverage.value)),
1353 now,
1354 ))
1355}
1356
1357fn perp_fill_fees(
1367 fee_token: Option<&str>,
1368 fee: Decimal,
1369 collateral: &str,
1370) -> AssetFees<AssetNameExchange> {
1371 let asset = fee_token.unwrap_or(collateral);
1372 let fees_quote = (asset == collateral).then_some(fee);
1373 AssetFees::new(AssetNameExchange::from(asset), fee, fees_quote)
1374}
1375
1376fn fill_to_account_event(
1379 fill: &hyperliquid_rust_sdk::TradeInfo,
1380 dexes: &PerpDexes,
1381) -> Option<UnindexedAccountEvent> {
1382 let (instrument, collateral) = dexes.perp(&fill.coin)?;
1383 let side = parse_side(&fill.side)?;
1384 let price = parse_decimal(&fill.px, "fill.px")?;
1385 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1386 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1387 let time_exchange = millis_to_datetime(fill.time)?;
1388 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1389
1390 let trade = Trade {
1391 id: TradeId(format_smolstr!("{}", fill.tid)),
1392 order_id,
1393 instrument,
1394 strategy: StrategyId::unknown(),
1395 time_exchange,
1396 side,
1397 price,
1398 quantity,
1399 order_filled_quantity: None,
1402 fees: perp_fill_fees(Some(&fill.fee_token), fee, collateral),
1403 };
1404
1405 Some(AccountEvent::new(
1406 ExchangeId::HyperliquidPerp,
1407 AccountEventKind::Trade(trade),
1408 ))
1409}
1410
1411fn order_update_to_account_event(
1414 update: &hyperliquid_rust_sdk::OrderUpdate,
1415 dexes: &PerpDexes,
1416) -> Option<UnindexedAccountEvent> {
1417 common::order_update_to_account_event(
1418 update,
1419 ExchangeId::HyperliquidPerp,
1420 dexes.instrument(&update.order.coin)?,
1421 )
1422}
1423
1424#[cfg(test)]
1425#[allow(clippy::unwrap_used)]
1427mod tests {
1428 use super::*;
1429 use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1430 use rust_decimal_macros::dec;
1431 use rustrade_integration::collection::snapshot::Snapshot;
1432 use std::collections::HashMap;
1433
1434 fn default_dex() -> PerpDexes {
1436 PerpDexes::default()
1437 }
1438
1439 mod order_recovery {
1440 use super::super::common::info_tests::info_client_against;
1441 use super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1442 use super::*;
1443 use crate::order::state::InactiveOrderState;
1444 use wiremock::matchers::{method, path};
1445 use wiremock::{Mock, MockServer, ResponseTemplate};
1446
1447 async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>) {
1448 let server = MockServer::start().await;
1449 Mock::given(method("POST"))
1450 .and(path("/info"))
1451 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1452 .mount(&server)
1453 .await;
1454 let client = Arc::new(info_client_against(server.uri()).await);
1455 (server, client)
1456 }
1457
1458 fn key() -> UnindexedOrderKey {
1459 OrderKey {
1460 exchange: ExchangeId::HyperliquidPerp,
1461 instrument: InstrumentNameExchange::new("BTC-USDC-PERP"),
1462 strategy: StrategyId::new("strategy"),
1463 cid: ClientOrderId::new(CID),
1464 }
1465 }
1466
1467 fn order_status(coin: &str) -> serde_json::Value {
1468 serde_json::json!({
1469 "status": "order",
1470 "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1471 })
1472 }
1473
1474 #[test]
1475 fn a_reconnect_snapshot_wakes_the_check_once_its_fills_are_sent() {
1476 use futures::FutureExt as _;
1477 let notified = |reconnected: &Notify| reconnected.notified().now_or_never().is_some();
1478 let snapshot = |fills| hyperliquid_rust_sdk::UserFillsData {
1479 is_snapshot: Some(true),
1480 user: ethers::types::H160::zero(),
1481 fills,
1482 };
1483 let reconnected = Arc::new(Notify::new());
1484 let mut reconnects = ReconnectWatch::new(reconnected.clone());
1485 let dedup = new_dedup_cache();
1486 let (tx, mut rx) = mpsc::unbounded_channel();
1487 let tids = |rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>| {
1488 std::iter::from_fn(|| rx.try_recv().ok())
1489 .map(|event| match event.kind {
1490 AccountEventKind::Trade(trade) => trade.id.0.to_string(),
1491 other => panic!("expected a trade, got {other:?}"),
1492 })
1493 .collect::<Vec<_>>()
1494 };
1495
1496 let first = snapshot(vec![sweep_fill(1, "60000")]);
1498 assert!(send_fills(
1499 &first,
1500 |fill| fill_to_account_event(fill, &default_dex()),
1501 &dedup,
1502 &tx,
1503 &mut reconnects
1504 ));
1505 assert_eq!(tids(&mut rx), ["1"]);
1506 assert!(!notified(&reconnected));
1507
1508 reconnects.dropped();
1511 let resubscribed = snapshot(vec![sweep_fill(1, "60000"), sweep_fill(2, "60001")]);
1512 assert!(send_fills(
1513 &resubscribed,
1514 |fill| fill_to_account_event(fill, &default_dex()),
1515 &dedup,
1516 &tx,
1517 &mut reconnects
1518 ));
1519 assert_eq!(
1520 tids(&mut rx),
1521 ["2"],
1522 "the fill already sent is not sent again"
1523 );
1524 assert!(notified(&reconnected));
1525
1526 drop(rx);
1528 reconnects.dropped();
1529 let unsent = snapshot(vec![sweep_fill(3, "60002")]);
1530 assert!(!send_fills(
1531 &unsent,
1532 |fill| fill_to_account_event(fill, &default_dex()),
1533 &dedup,
1534 &tx,
1535 &mut reconnects
1536 ));
1537 assert!(!notified(&reconnected));
1538 }
1539
1540 #[tokio::test]
1541 async fn a_perp_lookup_reads_how_the_order_ended() {
1542 let (_server, client) = serve(order_status("BTC")).await;
1543
1544 let lookup =
1545 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1546 .await
1547 .unwrap();
1548
1549 let OrderLookup::Ended(order) = lookup else {
1550 panic!("expected an ended order, got {lookup:?}");
1551 };
1552 assert_eq!(order.key, key());
1553 assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1554 }
1555
1556 #[tokio::test]
1557 async fn a_perp_lookup_of_a_spot_order_is_unknown() {
1558 let (_server, client) = serve(order_status("@107")).await;
1559
1560 let lookup =
1561 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1562 .await
1563 .unwrap();
1564
1565 assert!(matches!(lookup, OrderLookup::Unknown));
1566 }
1567
1568 #[tokio::test]
1569 async fn a_perp_listing_names_only_the_orders_on_the_perpetuals_asked_about() {
1570 let rows = serde_json::json!([
1571 row("BTC", 1, Some(CLOID)),
1572 row("ETH", 2, None),
1573 row("@107", 3, None),
1574 ]);
1575 let (_server, client) = serve(rows).await;
1576
1577 let listed = perp_listed_cids(
1578 client,
1579 Arc::default(),
1580 ethers::types::H160::zero(),
1581 vec![InstrumentNameExchange::new("BTC-USDC-PERP")],
1582 )
1583 .await
1584 .unwrap();
1585
1586 assert_eq!(
1587 listed.into_iter().collect::<Vec<_>>(),
1588 [ClientOrderId::new(CID)]
1589 );
1590 }
1591 }
1592
1593 async fn client_against(
1597 server: &wiremock::MockServer,
1598 coin_to_asset: HashMap<String, u32>,
1599 ) -> HyperliquidClient {
1600 use super::common::info_tests::{
1601 exchange_client_against, info_client_against, test_wallet,
1602 };
1603 let mut dexes = super::perp_dexes::tests::xyz_and_flx().await;
1604 dexes.add_default_perps(&[hyperliquid_rust_sdk::AssetMeta {
1605 name: "BTC".to_owned(),
1606 sz_decimals: 5,
1607 }]);
1608 HyperliquidClient {
1609 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1610 info_client: Arc::new(info_client_against(server.uri()).await),
1611 exchange_client: Arc::new(exchange_client_against(server.uri(), coin_to_asset).await),
1612 dexes: Arc::new(dexes),
1613 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
1614 }
1615 }
1616
1617 mod precision {
1618 use super::*;
1619 use crate::error::{OrderField, PrecisionLimit, PrecisionViolation};
1620 use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1621 use wiremock::matchers::{method, path};
1622 use wiremock::{Mock, MockServer, ResponseTemplate};
1623
1624 async fn resting_server() -> MockServer {
1626 let server = MockServer::start().await;
1627 Mock::given(method("POST"))
1628 .and(path("/exchange"))
1629 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1630 "status": "ok",
1631 "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1632 })))
1633 .mount(&server)
1634 .await;
1635 server
1636 }
1637
1638 async fn place(
1639 client: &HyperliquidClient,
1640 instrument: &InstrumentNameExchange,
1641 kind: OrderKind,
1642 price: Option<Decimal>,
1643 quantity: Decimal,
1644 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1645 client
1646 .open_order(OrderEvent {
1647 key: OrderKey {
1648 exchange: ExchangeId::HyperliquidPerp,
1649 instrument,
1650 strategy: StrategyId::new("strategy"),
1651 cid: ClientOrderId::uuid(),
1652 },
1653 state: RequestOpen {
1654 side: Side::Buy,
1655 price,
1656 quantity,
1657 kind,
1658 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1659 position_id: None,
1660 reduce_only: false,
1661 market: None,
1662 },
1663 })
1664 .await
1665 }
1666
1667 async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
1669 server
1670 .received_requests()
1671 .await
1672 .unwrap_or_default()
1673 .into_iter()
1674 .filter(|request| request.url.path() == "/exchange")
1675 .map(|request| {
1676 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1677 body["action"]["orders"][0].clone()
1678 })
1679 .collect()
1680 }
1681
1682 #[tokio::test]
1683 async fn the_client_reports_each_perpetuals_precision() {
1684 let server = MockServer::start().await;
1685 let client = client_against(&server, HashMap::new()).await;
1686
1687 assert_eq!(
1688 client.order_precision(&InstrumentNameExchange::from("BTC-USDC-PERP")),
1689 Some(OrderPrecision::perp(5))
1690 );
1691 assert_eq!(
1692 client.order_precision(&InstrumentNameExchange::from("xyz:TSLA-USDC-PERP")),
1693 Some(OrderPrecision::perp(2)),
1694 "a HIP-3 perpetual's szDecimals come from its DEX's meta"
1695 );
1696 assert_eq!(
1697 client.order_precision(&InstrumentNameExchange::from("ETH-USDC-PERP")),
1698 None,
1699 "not listed when the client connected"
1700 );
1701 assert_eq!(
1702 client.order_precision(&InstrumentNameExchange::from("HYPE-USDC-SPOT")),
1703 None
1704 );
1705 }
1706
1707 #[tokio::test]
1708 async fn an_order_breaking_the_precision_rules_is_refused_unsent() {
1709 let server = resting_server().await;
1710 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1711 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1712
1713 let cases = [
1714 (
1715 OrderKind::Limit,
1716 dec!(60000),
1717 dec!(0.000001),
1718 OrderField::Quantity,
1719 PrecisionLimit::DecimalPlaces { max: 5 },
1720 ),
1721 (
1722 OrderKind::Limit,
1723 dec!(60000),
1724 dec!(0),
1725 OrderField::Quantity,
1726 PrecisionLimit::NotPositive,
1727 ),
1728 (
1729 OrderKind::Limit,
1730 dec!(65432.1),
1731 dec!(0.001),
1732 OrderField::Price,
1733 PrecisionLimit::SignificantFigures { max: 5 },
1734 ),
1735 (
1736 OrderKind::Limit,
1737 dec!(6543.21),
1738 dec!(0.001),
1739 OrderField::Price,
1740 PrecisionLimit::DecimalPlaces { max: 1 },
1741 ),
1742 (
1743 OrderKind::StopLimit {
1744 trigger_price: dec!(6543.21),
1745 },
1746 dec!(6543),
1747 dec!(0.001),
1748 OrderField::TriggerPrice,
1749 PrecisionLimit::DecimalPlaces { max: 1 },
1750 ),
1751 ];
1752 for (kind, price, quantity, field, limit) in cases {
1753 let order = place(&client, &btc, kind, Some(price), quantity).await;
1754 let expected = PrecisionViolation {
1755 field,
1756 value: match field {
1757 OrderField::Quantity => quantity,
1758 OrderField::TriggerPrice => dec!(6543.21),
1759 _ => price,
1760 },
1761 limit,
1762 };
1763 assert_eq!(
1764 order.state,
1765 OrderState::Inactive(InactiveOrderState::OpenFailed(
1766 OrderError::InvalidPrecision(expected)
1767 )),
1768 "{kind} at {price} for {quantity}"
1769 );
1770 }
1771 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1772 }
1773
1774 #[tokio::test]
1775 async fn a_valid_order_is_sent_unchanged() {
1776 let server = resting_server().await;
1777 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1778 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
1779
1780 let order = place(
1782 &client,
1783 &btc,
1784 OrderKind::Limit,
1785 Some(dec!(123456)),
1786 dec!(0.00123),
1787 )
1788 .await;
1789 assert!(
1790 matches!(order.state, OrderState::Active(_)),
1791 "{:?}",
1792 order.state
1793 );
1794
1795 let sent = sent_orders(&server).await;
1796 assert_eq!(sent.len(), 1);
1797 assert_eq!(
1798 (&sent[0]["p"], &sent[0]["s"]),
1799 (&serde_json::json!("123456"), &serde_json::json!("0.00123"))
1800 );
1801 }
1802
1803 #[tokio::test]
1804 async fn an_order_on_a_perpetual_listed_since_connecting_is_refused_unsent() {
1805 let server = resting_server().await;
1806 let client = client_against(&server, HashMap::from([("ETH".to_owned(), 1)])).await;
1807
1808 let order = place(
1809 &client,
1810 &InstrumentNameExchange::from("ETH-USDC-PERP"),
1811 OrderKind::Limit,
1812 Some(dec!(3000)),
1813 dec!(1),
1814 )
1815 .await;
1816 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1817 crate::error::ApiError::InstrumentInvalid(_, reason),
1818 ))) = &order.state
1819 else {
1820 panic!("expected InstrumentInvalid, got {:?}", order.state);
1821 };
1822 assert!(reason.contains("reconnect"), "{reason}");
1823 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1824 }
1825 }
1826
1827 mod cancel {
1828 use super::super::common::info_tests::{CANCEL_NOT_APPLIED, cancel_answer};
1829 use super::*;
1830 use crate::error::ApiError;
1831 use crate::order::{OrderEvent, request::RequestCancel};
1832 use wiremock::matchers::{method, path};
1833 use wiremock::{Mock, MockServer, ResponseTemplate};
1834
1835 async fn cancel_answered(
1838 answer: serde_json::Value,
1839 ) -> (UnindexedOrderResponseCancel, bool) {
1840 let server = MockServer::start().await;
1841 Mock::given(method("POST"))
1842 .and(path("/exchange"))
1843 .respond_with(ResponseTemplate::new(200).set_body_json(answer))
1844 .mount(&server)
1845 .await;
1846 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1847 let instrument = InstrumentNameExchange::new("BTC-USDC-PERP");
1848 let key = OrderKey {
1849 exchange: ExchangeId::HyperliquidPerp,
1850 instrument: instrument.clone(),
1851 strategy: StrategyId::new("strategy"),
1852 cid: ClientOrderId::uuid(),
1853 };
1854 let id = VenueOrderId::Assigned(OrderId::new("7"));
1855 let open = Open {
1856 id: id.clone(),
1857 time_exchange: Utc::now(),
1858 filled_quantity: Decimal::ZERO,
1859 };
1860 client.known_live.lock().live(&key, dec!(1), &open);
1861
1862 let response = client
1863 .cancel_order(OrderEvent {
1864 key: OrderKey {
1865 exchange: key.exchange,
1866 instrument: &instrument,
1867 strategy: key.strategy.clone(),
1868 cid: key.cid.clone(),
1869 },
1870 state: RequestCancel { id: Some(id) },
1871 })
1872 .await;
1873 let live = client.known_live.lock().contains(&key.cid);
1874 (response, live)
1875 }
1876
1877 #[tokio::test]
1878 async fn a_success_status_cancels_the_order() {
1879 let (response, live) =
1880 cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
1881 assert!(response.state.is_ok(), "{:?}", response.state);
1882 assert!(!live, "a cancelled order is no longer held live");
1883 }
1884
1885 #[tokio::test]
1888 async fn an_error_status_is_a_rejected_cancel_and_the_order_stays_live() {
1889 let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
1890 {"error": CANCEL_NOT_APPLIED}
1891 ])))
1892 .await;
1893 let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
1894 &response.state
1895 else {
1896 panic!("expected a rejected cancel, got {:?}", response.state);
1897 };
1898 assert_eq!(reason, CANCEL_NOT_APPLIED);
1899 assert!(live, "the order is still held live");
1900 }
1901
1902 #[tokio::test]
1903 async fn an_answer_without_a_status_is_a_rejected_cancel() {
1904 let (response, live) = cancel_answered(serde_json::json!({
1905 "status": "ok",
1906 "response": {"type": "cancel"},
1907 }))
1908 .await;
1909 assert!(
1910 matches!(
1911 response.state,
1912 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
1913 ),
1914 "{:?}",
1915 response.state
1916 );
1917 assert!(live, "the order is still held live");
1918 }
1919 }
1920
1921 mod instrument_names {
1922 use super::*;
1923 use crate::error::ApiError;
1924 use crate::order::{
1925 OrderEvent,
1926 request::{RequestCancel, RequestOpen},
1927 };
1928 use wiremock::MockServer;
1929
1930 async fn offline_client() -> (MockServer, HyperliquidClient) {
1934 let server = MockServer::start().await;
1935 let client = client_against(&server, HashMap::new()).await;
1936 (server, client)
1937 }
1938
1939 const REFUSED: [&str; 4] = [
1942 "BTC-USD-PERP",
1943 "flx:TSLA-USDC-PERP",
1944 "km:TSLA-USDH-PERP",
1945 "HYPE-USDC-SPOT",
1946 ];
1947
1948 fn is_invalid(
1949 error: &ApiError<AssetNameExchange, InstrumentNameExchange>,
1950 name: &str,
1951 ) -> bool {
1952 matches!(error, ApiError::InstrumentInvalid(instrument, _) if instrument.as_ref() == name)
1953 }
1954
1955 #[tokio::test]
1956 async fn an_order_on_a_name_the_client_does_not_trade_is_refused_unsent() {
1957 let (_server, client) = offline_client().await;
1958 for name in REFUSED {
1959 let instrument = InstrumentNameExchange::from(name);
1960 let order = client
1961 .open_order(OrderEvent {
1962 key: OrderKey {
1963 exchange: ExchangeId::HyperliquidPerp,
1964 instrument: &instrument,
1965 strategy: StrategyId::new("strategy"),
1966 cid: ClientOrderId::uuid(),
1967 },
1968 state: RequestOpen {
1969 side: Side::Buy,
1970 price: Some(dec!(1)),
1971 quantity: dec!(1),
1972 kind: OrderKind::Limit,
1973 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1974 position_id: None,
1975 reduce_only: false,
1976 market: None,
1977 },
1978 })
1979 .await;
1980 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
1981 OrderError::Rejected(error),
1982 )) = &order.state
1983 else {
1984 panic!("{name}: expected a rejection, got {:?}", order.state);
1985 };
1986 assert!(is_invalid(error, name), "{name}: {error:?}");
1987 }
1988 }
1989
1990 #[tokio::test]
1991 async fn a_cancel_on_a_name_the_client_does_not_trade_is_refused_unsent() {
1992 let (_server, client) = offline_client().await;
1993 for name in REFUSED {
1994 let instrument = InstrumentNameExchange::from(name);
1995 let response = client
1996 .cancel_order(OrderEvent {
1997 key: OrderKey {
1998 exchange: ExchangeId::HyperliquidPerp,
1999 instrument: &instrument,
2000 strategy: StrategyId::new("strategy"),
2001 cid: ClientOrderId::uuid(),
2002 },
2003 state: RequestCancel {
2004 id: Some(VenueOrderId::Assigned(OrderId::new("1"))),
2005 },
2006 })
2007 .await;
2008 let Err(UnindexedOrderError::Rejected(error)) = &response.state else {
2009 panic!("{name}: expected a rejection, got {:?}", response.state);
2010 };
2011 assert!(is_invalid(error, name), "{name}: {error:?}");
2012 }
2013 }
2014
2015 #[tokio::test]
2016 async fn a_read_filtered_to_a_name_the_client_does_not_trade_fails_unsent() {
2017 let (_server, client) = offline_client().await;
2018 for name in REFUSED {
2019 let instruments = [InstrumentNameExchange::from(name)];
2020 let snapshot = client.account_snapshot(&[], &instruments).await;
2021 let open = client.fetch_open_orders(&instruments).await;
2022 let trades = client
2023 .fetch_trades(
2024 Utc::now() - chrono::Duration::hours(1),
2025 Utc::now(),
2026 &instruments,
2027 )
2028 .await;
2029 for error in [snapshot.err(), open.err(), trades.err()] {
2030 let Some(UnindexedClientError::Api(error)) = &error else {
2031 panic!("{name}: expected an API error, got {error:?}");
2032 };
2033 assert!(is_invalid(error, name), "{name}: {error:?}");
2034 }
2035 }
2036 }
2037 }
2038
2039 fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
2040 serde_json::from_value(serde_json::json!({
2041 "coin": "BTC",
2042 "entryPx": "50000.0",
2043 "leverage": {"type": "cross", "value": 5},
2044 "liquidationPx": null,
2045 "marginUsed": "100.0",
2046 "positionValue": "500.0",
2047 "returnOnEquity": "0.0",
2048 "szi": szi,
2049 "unrealizedPnl": "1.5",
2050 "maxLeverage": 50,
2051 "cumFunding": {"allTime": "0", "sinceOpen": "0", "sinceChange": "0"}
2052 }))
2053 .unwrap()
2054 }
2055
2056 #[test]
2057 fn perp_position_report_is_open_flat_or_unreported() {
2058 let now = Utc::now();
2059 let open = perp_position_report(&perp_position("-0.01"), now);
2060 let position = open.open().unwrap();
2061 assert_eq!(
2062 (position.quantity, position.entry_price, position.leverage),
2063 (dec!(-0.01), Some(dec!(50000.0)), Some(dec!(5)))
2064 );
2065 assert_eq!(
2066 perp_position_report(&perp_position("0.0"), now),
2067 PositionReport::Flat
2068 );
2069 assert_eq!(
2070 perp_position_report(&perp_position("not a number"), now),
2071 PositionReport::Unreported
2072 );
2073 }
2074
2075 #[test]
2076 fn test_fill_to_account_event() {
2077 let fill_json = r#"{
2078 "coin": "BTC",
2079 "side": "B",
2080 "px": "65000.5",
2081 "sz": "0.1",
2082 "time": 1714100000000,
2083 "hash": "0xabc123",
2084 "startPosition": "0",
2085 "dir": "Open Long",
2086 "closedPnl": "0",
2087 "oid": 12345,
2088 "cloid": null,
2089 "crossed": false,
2090 "fee": "0.65",
2091 "feeToken": "USDC",
2092 "tid": 99999
2093 }"#;
2094
2095 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2096 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2097
2098 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2099 match event.kind {
2100 AccountEventKind::Trade(trade) => {
2101 assert_eq!(trade.instrument.as_ref(), "BTC-USDC-PERP");
2102 assert_eq!(
2103 trade.id.0, "99999",
2104 "the id is the fill's tid, not its hash"
2105 );
2106 assert_eq!(trade.side, Side::Buy);
2107 assert_eq!(trade.price, dec!(65000.5));
2108 assert_eq!(trade.quantity, dec!(0.1));
2109 assert_eq!(trade.fees.fees, dec!(0.65));
2110 assert_eq!(trade.fees.asset.as_ref(), "USDC");
2111 assert_eq!(trade.fees.fees_quote, Some(dec!(0.65)));
2112 }
2113 _ => panic!("Expected Trade event"),
2114 }
2115 }
2116
2117 #[tokio::test]
2118 async fn a_builder_deployed_fill_is_named_and_charged_in_its_dex_collateral() {
2119 let fill_json = r#"{
2121 "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2122 "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2123 "dir": "Open Long", "closedPnl": "0", "oid": 7, "cloid": null,
2124 "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2125 }"#;
2126 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2127
2128 let dexes = perp_dexes::tests::xyz_and_flx().await;
2129 let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2130 else {
2131 panic!("Expected Trade event");
2132 };
2133 assert_eq!(trade.instrument.as_ref(), "flx:TSLA-USDH-PERP");
2134 assert_eq!(trade.fees.asset.as_ref(), "USDH");
2135 assert_eq!(trade.fees.fees, dec!(0.2));
2136 assert_eq!(
2137 trade.fees.fees_quote,
2138 Some(dec!(0.2)),
2139 "the fee is in the instrument's quote"
2140 );
2141
2142 assert!(fill_to_account_event(&fill, &default_dex()).is_none());
2144 }
2145
2146 #[test]
2147 fn perp_fill_fees_reads_the_fee_token_and_falls_back_to_the_collateral() {
2148 assert_eq!(
2149 perp_fill_fees(Some("USDT0"), dec!(1.5), "USDC"),
2150 AssetFees::new(AssetNameExchange::from("USDT0"), dec!(1.5), None)
2151 );
2152 assert_eq!(
2153 perp_fill_fees(Some("USDC"), dec!(1.5), "USDC"),
2154 AssetFees::new(AssetNameExchange::from("USDC"), dec!(1.5), Some(dec!(1.5)))
2155 );
2156 assert_eq!(
2157 perp_fill_fees(None, dec!(1.5), "USDH"),
2158 AssetFees::new(AssetNameExchange::from("USDH"), dec!(1.5), Some(dec!(1.5))),
2159 "an absent feeToken is the DEX's collateral"
2160 );
2161 }
2162
2163 #[test]
2164 fn test_fill_to_account_event_sell() {
2165 let fill_json = r#"{
2166 "coin": "ETH",
2167 "side": "A",
2168 "px": "3200",
2169 "sz": "1.5",
2170 "time": 1714100000000,
2171 "hash": "0xdef456",
2172 "startPosition": "1.5",
2173 "dir": "Close Long",
2174 "closedPnl": "150.0",
2175 "oid": 12346,
2176 "cloid": null,
2177 "crossed": true,
2178 "fee": "4.8",
2179 "feeToken": "USDC",
2180 "tid": 100000
2181 }"#;
2182
2183 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2184 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2185
2186 match event.kind {
2187 AccountEventKind::Trade(trade) => {
2188 assert_eq!(trade.instrument.as_ref(), "ETH-USDC-PERP");
2189 assert_eq!(trade.side, Side::Sell);
2190 assert_eq!(trade.price, dec!(3200));
2191 assert_eq!(trade.quantity, dec!(1.5));
2192 }
2193 _ => panic!("Expected Trade event"),
2194 }
2195 }
2196
2197 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
2199 let json = format!(
2200 r#"{{
2201 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
2202 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
2203 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
2204 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
2205 }}"#
2206 );
2207 serde_json::from_str(&json).unwrap()
2208 }
2209
2210 #[test]
2211 fn a_trade_id_identifies_the_fill_not_the_transaction() {
2212 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2216 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2217
2218 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
2219 (first.kind, second.kind)
2220 else {
2221 panic!("Expected Trade events");
2222 };
2223
2224 assert_eq!(first.order_id, second.order_id, "one order produced both");
2225 assert_ne!(first.id, second.id, "but they are distinct fills");
2226 assert_eq!(first.id.0, "111");
2227 assert_eq!(second.id.0, "222");
2228 }
2229
2230 #[test]
2231 fn a_replayed_fill_is_delivered_once() {
2232 let cache = new_dedup_cache();
2235 let event = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2236 let replay = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2237
2238 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
2239 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
2240 }
2241
2242 #[test]
2243 fn both_fills_of_one_sweep_survive_dedup() {
2244 let cache = new_dedup_cache();
2247 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2248 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2249
2250 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
2251 assert!(!is_duplicate(
2252 &cache,
2253 dedup_key_from_event(&second).unwrap()
2254 ));
2255 }
2256
2257 #[test]
2258 fn one_tid_on_two_instruments_is_two_fills() {
2259 let cache = new_dedup_cache();
2262 let btc = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2263
2264 let eth_json = r#"{
2265 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
2266 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
2267 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
2268 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
2269 }"#;
2270 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
2271 let eth = fill_to_account_event(ð, &default_dex()).unwrap();
2272
2273 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
2274 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
2275 }
2276
2277 #[test]
2278 fn test_order_update_to_account_event_open() {
2279 let update_json = r#"{
2280 "order": {
2281 "coin": "BTC",
2282 "side": "B",
2283 "limitPx": "64000",
2284 "sz": "0.5",
2285 "oid": 12345,
2286 "timestamp": 1714100000000,
2287 "origSz": "0.5",
2288 "cloid": null
2289 },
2290 "status": "open",
2291 "statusTimestamp": 1714100000000
2292 }"#;
2293
2294 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2295 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2296
2297 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2298 match event.kind {
2299 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2300 assert_eq!(order.key.instrument.as_ref(), "BTC-USDC-PERP");
2301 assert_eq!(order.side, Side::Buy);
2302 assert_eq!(order.price, Some(dec!(64000)));
2303 assert_eq!(order.quantity, dec!(0.5));
2304 assert!(matches!(
2305 order.state,
2306 crate::order::state::OrderState::Active(_)
2307 ));
2308 }
2309 _ => panic!("Expected OrderSnapshot event"),
2310 }
2311 }
2312
2313 #[test]
2314 fn test_order_update_to_account_event_filled() {
2315 let update_json = r#"{
2316 "order": {
2317 "coin": "ETH",
2318 "side": "A",
2319 "limitPx": "3250",
2320 "sz": "0",
2321 "oid": 12346,
2322 "timestamp": 1714100000000,
2323 "origSz": "2.0",
2324 "cloid": null
2325 },
2326 "status": "filled",
2327 "statusTimestamp": 1714100001000
2328 }"#;
2329
2330 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2331 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2332
2333 match event.kind {
2334 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2335 assert_eq!(order.side, Side::Sell);
2336 assert!(matches!(
2337 order.state,
2338 crate::order::state::OrderState::Inactive(
2339 crate::order::state::InactiveOrderState::FullyFilled(_)
2340 )
2341 ));
2342 }
2343 _ => panic!("Expected OrderSnapshot event"),
2344 }
2345 }
2346
2347 #[test]
2348 fn test_order_update_to_account_event_cancelled() {
2349 let update_json = r#"{
2350 "order": {
2351 "coin": "SOL",
2352 "side": "B",
2353 "limitPx": "150",
2354 "sz": "10",
2355 "oid": 12347,
2356 "timestamp": 1714100000000,
2357 "origSz": "10",
2358 "cloid": null
2359 },
2360 "status": "canceled",
2361 "statusTimestamp": 1714100002000
2362 }"#;
2363
2364 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2365 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2366
2367 match event.kind {
2368 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2369 assert_eq!(order.key.instrument.as_ref(), "SOL-USDC-PERP");
2370 assert!(matches!(
2371 order.state,
2372 crate::order::state::OrderState::Inactive(
2373 crate::order::state::InactiveOrderState::Cancelled(_)
2374 )
2375 ));
2376 }
2377 _ => panic!("Expected OrderSnapshot event"),
2378 }
2379 }
2380
2381 #[test]
2382 fn test_order_update_unknown_status_returns_none() {
2383 let update_json = r#"{
2384 "order": {
2385 "coin": "BTC",
2386 "side": "B",
2387 "limitPx": "64000",
2388 "sz": "0.5",
2389 "oid": 12345,
2390 "timestamp": 1714100000000,
2391 "origSz": "0.5",
2392 "cloid": null
2393 },
2394 "status": "unknown_status",
2395 "statusTimestamp": 1714100000000
2396 }"#;
2397
2398 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2399 let event = order_update_to_account_event(&update, &default_dex());
2400 assert!(event.is_none());
2401 }
2402}