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