1pub mod common;
192pub mod config;
193pub mod error;
194mod funding;
195mod order_recovery;
196mod perp_account;
197mod perp_dexes;
198pub mod precision;
199pub mod spot;
200mod spot_coins;
201
202use crate::client::dedup::new_dedup_cache;
203use crate::client::order_recovery::{
204 KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
205};
206use crate::{
207 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
208 UnindexedAccountEvent, UnindexedAccountSnapshot,
209 balance::AssetBalance,
210 client::{ExecutionClient, OrderStatusClient, VenueOrderStatus},
211 emit_stream_terminated,
212 error::{
213 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
214 UnindexedOrderError,
215 },
216 order::{
217 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
218 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
219 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
220 state::{Filled, Open, OrderState, UnindexedOrderState},
221 },
222 position::{Position, PositionReport},
223 trade::{AssetFees, Trade, TradeId, TradeOrigin, TradesRead},
224};
225use chrono::{DateTime, Utc};
226use common::{
227 CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrderListing, UnknownCoins,
228 cancel_outcome, cid_to_cloid, cloid_to_cid, map_tif, millis_to_datetime, open_order_to_order,
229 parse_decimal, parse_side, span_millis, user_fills_by_time, warn_unknown_coins,
230};
231pub use config::{HyperliquidConfig, HyperliquidConfigError, Network};
232pub use error::HyperliquidConnectError;
233use error::{map_order_error, order_outcome_unanswered, order_rejection};
234use ethers::signers::Signer;
235use fnv::FnvHashSet;
236use funding::{funding_cash_flow, send_fundings};
237use futures::{StreamExt, stream::BoxStream};
238use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
239use order_recovery::{
240 ReconnectWatch, fetch_order_record, listed_cids, lookup_of, remember_open, remember_snapshot,
241 send_fills, send_observed, spawn_order_checks, status_from_record, unreadable_record,
242};
243use perp_account::{account_mode, dex_states};
244use perp_dexes::{PerpDexes, UnconfiguredDexes, warn_unconfigured_dexes};
245pub use precision::OrderPrecision;
246use precision::{WireOrderError, wire_order};
247use rust_decimal::Decimal;
248use rustrade_instrument::{
249 Side,
250 asset::name::AssetNameExchange,
251 exchange::ExchangeId,
252 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
253};
254use smol_str::format_smolstr;
255use std::sync::{
256 Arc,
257 atomic::{AtomicBool, Ordering},
258};
259use tokio::sync::{Notify, mpsc};
260use tokio_util::sync::CancellationToken;
261use tracing::{debug, error, info, warn};
262
263#[derive(Debug, Clone)]
268pub struct HyperliquidClient {
269 config: HyperliquidConfig,
270 info_client: Arc<InfoClient>,
271 exchange_client: Arc<ExchangeClient>,
272 dexes: Arc<PerpDexes>,
274 known_live: SharedKnownLiveOrders,
277}
278
279impl HyperliquidClient {
280 pub async fn connect(config: HyperliquidConfig) -> Result<Self, HyperliquidConnectError> {
301 let base_url = config.base_url();
302
303 let info_client = InfoClient::new(None, Some(base_url))
304 .await
305 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
306
307 let mut dexes = PerpDexes::fetch(&info_client, &config.dexes).await?;
308
309 let wallet = config.wallet.clone();
310 let mut exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
311 .await
312 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
313 exchange_client.coin_to_asset.extend(dexes.asset_ids());
315 dexes.add_default_perps(&exchange_client.meta.universe);
316
317 info!(
318 network = ?config.network,
319 dexes = ?config.dexes,
320 wallet = %config.wallet_address_hex(),
321 "Created HyperliquidClient"
322 );
323
324 Ok(Self {
325 config,
326 info_client: Arc::new(info_client),
327 exchange_client: Arc::new(exchange_client),
328 dexes: Arc::new(dexes),
329 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
330 })
331 }
332
333 pub fn wallet_address(&self) -> String {
335 self.config.wallet_address_hex()
336 }
337
338 pub fn order_precision(&self, instrument: &InstrumentNameExchange) -> Option<OrderPrecision> {
348 let coin = self.dexes.coin(instrument).ok()?;
349 self.dexes.sz_decimals(coin).map(OrderPrecision::perp)
350 }
351
352 fn wallet_h160(&self) -> ethers::types::H160 {
354 self.config.wallet.address()
355 }
356
357 fn check_instruments(
361 &self,
362 instruments: &[InstrumentNameExchange],
363 ) -> Result<(), UnindexedClientError> {
364 for instrument in instruments {
365 self.dexes.coin(instrument).map_err(|reason| {
366 UnindexedClientError::Api(crate::error::ApiError::InstrumentInvalid(
367 instrument.clone(),
368 reason,
369 ))
370 })?;
371 }
372 Ok(())
373 }
374}
375
376impl ExecutionClient for HyperliquidClient {
377 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
378
379 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
382 &[InstrumentKindDiscriminant::Perpetual];
383
384 type Config = HyperliquidConfig;
385 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
386
387 fn new(config: Self::Config) -> Self {
401 tokio::runtime::Handle::current()
404 .block_on(Self::connect(config))
405 .unwrap_or_else(|e| panic!("Failed to create HyperliquidClient: {e}"))
406 }
407
408 async fn account_snapshot(
451 &self,
452 _assets: &[AssetNameExchange],
453 instruments: &[InstrumentNameExchange],
454 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
455 self.check_instruments(instruments)?;
456 let address = self.wallet_h160();
457 let info_client = &*self.info_client;
458 let dexes = &*self.dexes;
459
460 let (mode, states, open_orders) = tokio::try_join!(
461 account_mode(info_client, address),
462 dex_states(info_client, address, dexes),
463 perp_account::open_orders(info_client, address, dexes),
464 )?;
465 let now = Utc::now();
466 let balances =
467 perp_account::snapshot_balances(info_client, address, dexes, mode, &states, now)
468 .await?;
469
470 let instrument_filter = InstrumentFilter::new(instruments);
471
472 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
473 let mut listing = OpenOrderListing::new(
474 &open_orders,
475 ExchangeId::HyperliquidPerp,
476 instruments,
477 |coin| dexes.instrument(coin),
478 );
479
480 let mut instrument_snapshots = Vec::new();
482 for asset_pos in states.iter().flat_map(|(_, state)| &state.asset_positions) {
483 let pos = &asset_pos.position;
484 let Some(instrument) = dexes.instrument(&pos.coin) else {
485 continue;
486 };
487
488 if !instrument_filter.matches(&instrument) {
489 continue;
490 }
491
492 let position = perp_position_report(pos, now);
493
494 let (orders, orders_complete) = match listing.remove(&instrument) {
495 Some(listed) => (listed.orders, listed.orders_complete),
496 None => (Vec::new(), true),
499 };
500
501 instrument_snapshots.push(InstrumentAccountSnapshot {
502 instrument,
503 orders,
504 orders_complete,
505 position,
506 isolated: None,
507 });
508 }
509
510 instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
513 snapshot.position = PositionReport::Flat;
514 snapshot
515 }));
516
517 let snapshot = AccountSnapshot {
518 exchange: ExchangeId::HyperliquidPerp,
519 balances,
520 instruments: instrument_snapshots,
521 };
522 remember_snapshot(&self.known_live, &snapshot);
523 Ok(snapshot)
524 }
525
526 async fn account_stream(
592 &self,
593 _assets: &[AssetNameExchange],
594 _instruments: &[InstrumentNameExchange],
595 ) -> Result<Self::AccountStream, UnindexedClientError> {
596 let user = self.wallet_h160();
597 let base_url = self.config.base_url();
598
599 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
602 .await
603 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
604
605 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
607 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
608 let (fundings_tx, mut fundings_rx) = mpsc::unbounded_channel::<Message>();
609
610 ws_client
612 .subscribe(Subscription::UserFills { user }, fills_tx)
613 .await
614 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
615
616 ws_client
618 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
619 .await
620 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
621
622 ws_client
624 .subscribe(Subscription::UserFundings { user }, fundings_tx)
625 .await
626 .map_err(|e| ConnectivityError::Socket(format!("UserFundings subscribe: {e}")))?;
627
628 info!(%user, "Subscribed to Hyperliquid account stream");
629
630 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
632
633 let cancel_token = CancellationToken::new();
635
636 let terminated = Arc::new(AtomicBool::new(false));
641
642 let fills_dedup = new_dedup_cache();
646
647 let reconnected = Arc::new(Notify::new());
651 let checks_cancel = cancel_token.child_token();
652 let (list_client, lookup_client) = (self.info_client.clone(), self.info_client.clone());
653 let (list_dexes, lookup_dexes) = (self.dexes.clone(), self.dexes.clone());
654 spawn_order_checks(
655 ExchangeId::HyperliquidPerp,
656 self.known_live.clone(),
657 reconnected.clone(),
658 checks_cancel.clone(),
659 event_tx.clone(),
660 move |instruments| {
661 perp_listed_cids(list_client.clone(), list_dexes.clone(), user, instruments)
662 },
663 move |key| perp_order_lookup(lookup_client.clone(), lookup_dexes.clone(), user, key),
664 );
665
666 let fills_event_tx = event_tx.clone();
668 let fills_cancel = cancel_token.clone();
669 let fills_terminated = terminated.clone();
670 let fills_checks_cancel = checks_cancel.clone();
671 let fills_dexes = self.dexes.clone();
672 tokio::spawn(async move {
673 let mut unknown_coins = UnknownCoins::default();
674 let mut unconfigured = UnconfiguredDexes::default();
675 let mut reconnects = ReconnectWatch::new(reconnected);
676 loop {
677 tokio::select! {
678 biased;
679 () = fills_cancel.cancelled() => {
680 debug!("Fills task cancelled");
681 return;
682 }
683 msg = fills_rx.recv() => {
684 let Some(msg) = msg else {
685 debug!("Fills receiver closed");
686 fills_checks_cancel.cancel();
687 if fills_terminated
690 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
691 .is_ok()
692 {
693 emit_stream_terminated(
694 &fills_event_tx,
695 ExchangeId::HyperliquidPerp,
696 StreamTerminationReason::Error(
697 "hyperliquid account stream closed".to_string(),
698 ),
699 );
700 }
701 return;
702 };
703 match msg {
704 Message::UserFills(fills) => {
705 let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
706 unknown_coins.warn_once(&fill.coin);
707 unconfigured.warn_once(&fills_dexes, &fill.coin);
708 fill_to_account_event(fill, &fills_dexes)
709 };
710 if !send_fills(
711 &fills.data,
712 convert,
713 &fills_dedup,
714 &fills_event_tx,
715 &mut reconnects,
716 ) {
717 debug!("Fills event channel closed");
718 return;
719 }
720 }
721 Message::NoData => {
722 warn!("UserFills WebSocket disconnected");
723 reconnects.dropped();
724 }
725 Message::HyperliquidError(e) => {
726 error!(%e, "UserFills WebSocket error");
729 }
730 _ => {}
731 }
732 }
733 }
734 }
735 });
736
737 let fundings_event_tx = event_tx.clone();
740 let fundings_cancel = cancel_token.clone();
741 let fundings_dexes = self.dexes.clone();
742 let fundings_dedup = new_dedup_cache();
744 tokio::spawn(async move {
745 let mut unknown_coins = UnknownCoins::default();
746 let mut unconfigured = UnconfiguredDexes::default();
747 loop {
748 tokio::select! {
749 biased;
750 () = fundings_cancel.cancelled() => {
751 debug!("Fundings task cancelled");
752 return;
753 }
754 msg = fundings_rx.recv() => {
755 let Some(msg) = msg else {
756 debug!("Fundings receiver closed");
757 return;
758 };
759 match msg {
760 Message::UserFundings(fundings) => {
761 let convert = |funding: &hyperliquid_rust_sdk::UserFunding| {
762 unknown_coins.warn_once(&funding.coin);
763 unconfigured.warn_once(&fundings_dexes, &funding.coin);
764 funding_cash_flow(funding, &fundings_dexes)
765 };
766 if !send_fundings(
767 &fundings.data,
768 convert,
769 &fundings_dedup,
770 &fundings_event_tx,
771 ) {
772 debug!("Fundings event channel closed");
773 return;
774 }
775 }
776 Message::NoData => {
777 warn!("UserFundings WebSocket disconnected");
778 }
779 Message::HyperliquidError(e) => {
780 error!(%e, "UserFundings WebSocket error");
782 }
783 _ => {}
784 }
785 }
786 }
787 }
788 });
789
790 let orders_event_tx = event_tx;
795 let orders_cancel = cancel_token.clone();
796 let orders_terminated = terminated;
797 let orders_known = self.known_live.clone();
798 let orders_dexes = self.dexes.clone();
799 tokio::spawn(async move {
800 let _ws_client = ws_client;
801 let mut unknown_coins = UnknownCoins::default();
802 let mut unconfigured = UnconfiguredDexes::default();
803
804 loop {
805 tokio::select! {
806 biased;
807 () = orders_cancel.cancelled() => {
808 debug!("Orders task cancelled");
809 return;
810 }
811 msg = orders_rx.recv() => {
812 let Some(msg) = msg else {
813 debug!("Orders receiver closed");
814 checks_cancel.cancel();
815 if orders_terminated
818 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
819 .is_ok()
820 {
821 emit_stream_terminated(
822 &orders_event_tx,
823 ExchangeId::HyperliquidPerp,
824 StreamTerminationReason::Error(
825 "hyperliquid account stream closed".to_string(),
826 ),
827 );
828 }
829 return;
830 };
831 match msg {
832 Message::OrderUpdates(updates) => {
833 for update in updates.data {
834 unknown_coins.warn_once(&update.order.coin);
835 unconfigured.warn_once(&orders_dexes, &update.order.coin);
836 if let Some(event) =
837 order_update_to_account_event(&update, &orders_dexes)
838 && !send_observed(&orders_known, &orders_event_tx, event)
839 {
840 debug!("Orders event channel closed");
841 return;
842 }
843 }
844 }
845 Message::NoData => {
846 warn!("OrderUpdates WebSocket disconnected");
847 }
848 Message::HyperliquidError(e) => {
849 error!(%e, "OrderUpdates WebSocket error");
852 }
853 _ => {}
854 }
855 }
856 }
857 }
858 });
859
860 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
862 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
863 Ok(guarded_stream.boxed())
864 }
865
866 async fn cancel_order(
867 &self,
868 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
869 ) -> UnindexedOrderResponseCancel {
870 use crate::order::{request::OrderResponseCancel, state::Cancelled};
871 use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
872 use uuid::Uuid;
873
874 let coin = match self.dexes.coin(request.key.instrument) {
875 Ok(coin) => coin.to_owned(),
876 Err(reason) => {
877 warn!(%reason, cid = %request.key.cid, "Cannot cancel order on this instrument");
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(UnindexedOrderError::Rejected(
886 crate::error::ApiError::InstrumentInvalid(
887 request.key.instrument.clone(),
888 reason,
889 ),
890 )),
891 };
892 }
893 };
894
895 enum CancelMethod {
896 ByOid(u64),
897 ByCloid(Uuid),
898 }
899
900 let cancel_method = match &request.state.id {
905 Some(VenueOrderId::Assigned(id)) => {
906 id.0.parse::<u64>()
907 .map(CancelMethod::ByOid)
908 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
909 }
910 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
914 .map(CancelMethod::ByCloid)
915 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
916 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
917 };
918
919 let cancel_method = match cancel_method {
920 Ok(method) => method,
921 Err(reason) => {
922 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
923 return OrderResponseCancel {
924 key: OrderKey {
925 exchange: ExchangeId::HyperliquidPerp,
926 instrument: request.key.instrument.clone(),
927 strategy: request.key.strategy.clone(),
928 cid: request.key.cid.clone(),
929 },
930 state: Err(UnindexedOrderError::Rejected(
931 crate::error::ApiError::OrderRejected(reason.to_string()),
932 )),
933 };
934 }
935 };
936
937 let cancelled_id = match &request.state.id {
940 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
941 None => OrderId(request.key.cid.0.clone()),
942 };
943
944 let response = match cancel_method {
945 CancelMethod::ByOid(oid) => {
946 debug!(oid, "Cancelling by OID");
947 let cancel_request = ClientCancelRequest { asset: coin, oid };
948 self.exchange_client.cancel(cancel_request, None).await
949 }
950 CancelMethod::ByCloid(cloid) => {
951 debug!(%cloid, "Cancelling by cloid (no oid assigned)");
952 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
953 self.exchange_client
954 .cancel_by_cloid(cancel_request, None)
955 .await
956 }
957 };
958
959 let response = match response {
960 Ok(r) => r,
961 Err(e) => {
962 warn!(%e, "Cancel order failed");
963 return OrderResponseCancel {
964 key: OrderKey {
965 exchange: ExchangeId::HyperliquidPerp,
966 instrument: request.key.instrument.clone(),
967 strategy: request.key.strategy.clone(),
968 cid: request.key.cid.clone(),
969 },
970 state: Err(map_order_error(e, request.key.instrument)),
971 };
972 }
973 };
974
975 match cancel_outcome(response) {
976 Ok(()) => {
977 debug!("Cancel order accepted");
978 self.known_live.lock().ended(&request.key.cid);
980 OrderResponseCancel {
982 key: OrderKey {
983 exchange: ExchangeId::HyperliquidPerp,
984 instrument: request.key.instrument.clone(),
985 strategy: request.key.strategy.clone(),
986 cid: request.key.cid.clone(),
987 },
988 state: Ok(Cancelled::new(
989 cancelled_id.clone(),
990 Utc::now(),
991 None, )),
993 }
994 }
995 Err(reason) => {
997 warn!(%reason, cid = %request.key.cid, "Cancel rejected by exchange");
998 OrderResponseCancel {
999 key: OrderKey {
1000 exchange: ExchangeId::HyperliquidPerp,
1001 instrument: request.key.instrument.clone(),
1002 strategy: request.key.strategy.clone(),
1003 cid: request.key.cid.clone(),
1004 },
1005 state: Err(UnindexedOrderError::Rejected(
1006 crate::error::ApiError::OrderRejected(reason),
1007 )),
1008 }
1009 }
1010 }
1011 }
1012
1013 async fn open_order(
1014 &self,
1015 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1016 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1017 use hyperliquid_rust_sdk::{
1018 ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
1019 };
1020
1021 let make_inactive =
1022 |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1023 Order {
1024 key: OrderKey {
1025 exchange: ExchangeId::HyperliquidPerp,
1026 instrument: request.key.instrument.clone(),
1027 strategy: request.key.strategy.clone(),
1028 cid: request.key.cid.clone(),
1029 },
1030 side: request.state.side,
1031 price: request.state.price,
1032 quantity: request.state.quantity,
1033 kind: request.state.kind,
1034 time_in_force: request.state.time_in_force,
1035 state: OrderState::inactive(error),
1036 }
1037 };
1038 let make_rejected = |msg: String| {
1039 make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
1040 msg,
1041 )))
1042 };
1043 let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
1044
1045 if let Some(effect) = request.state.margin_effect {
1049 return make_inactive(OrderError::UnsupportedMarginEffect(effect));
1050 }
1051 let make_invalid_instrument = |reason: String| {
1052 make_inactive(OrderError::Rejected(
1053 crate::error::ApiError::InstrumentInvalid(request.key.instrument.clone(), reason),
1054 ))
1055 };
1056
1057 let coin = match self.dexes.coin(request.key.instrument) {
1058 Ok(coin) => coin.to_owned(),
1059 Err(reason) => return make_invalid_instrument(reason),
1060 };
1061 let is_buy = request.state.side == Side::Buy;
1062
1063 match request.state.kind {
1064 OrderKind::Market => {
1065 return make_unsupported(
1066 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
1067 .to_string(),
1068 );
1069 }
1070 OrderKind::TrailingStop { .. }
1071 | OrderKind::TrailingStopLimit { .. }
1072 | OrderKind::TrailingStopFixedLimit { .. } => {
1073 return make_unsupported(
1074 "Hyperliquid does not support trailing stop orders".to_string(),
1075 );
1076 }
1077 OrderKind::ConditionalUnresolved { .. } => {
1078 return make_unsupported(
1079 "an unresolved order kind is only read back from a venue, never placed"
1080 .to_string(),
1081 );
1082 }
1083 OrderKind::Limit
1084 | OrderKind::Stop { .. }
1085 | OrderKind::StopLimit { .. }
1086 | OrderKind::TakeProfit { .. }
1087 | OrderKind::TakeProfitLimit { .. } => {}
1088 }
1089
1090 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
1093 return make_rejected(CLOID_REQUIRED.to_string());
1094 };
1095
1096 let tif = map_tif(&request.state.time_in_force).to_string();
1097
1098 let Some(precision) = self.order_precision(request.key.instrument) else {
1099 return make_invalid_instrument(format!(
1100 "{coin} was not listed when the client connected; reconnect to trade a perpetual \
1101 listed since"
1102 ));
1103 };
1104 let wire = match wire_order(
1105 &precision,
1106 request.state.kind,
1107 tif,
1108 request.state.price,
1109 request.state.quantity,
1110 ) {
1111 Ok(wire) => wire,
1112 Err(WireOrderError::Precision(violation)) => {
1113 return make_inactive(OrderError::InvalidPrecision(violation));
1114 }
1115 Err(WireOrderError::MissingPrice) => {
1116 return make_rejected(
1117 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
1118 .to_string(),
1119 );
1120 }
1121 Err(WireOrderError::UnsupportedKind) => {
1122 return make_unsupported(format!(
1123 "Hyperliquid does not support {} orders",
1124 request.state.kind
1125 ));
1126 }
1127 };
1128
1129 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
1131 warn!(
1132 instrument = %request.key.instrument,
1133 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
1134 );
1135 }
1136
1137 let order_request = ClientOrderRequest {
1138 asset: coin,
1139 is_buy,
1140 reduce_only: request.state.reduce_only.is_reduce_only(),
1141 limit_px: wire.limit_px,
1142 sz: wire.sz,
1143 cloid: Some(cloid),
1144 order_type: wire.order_type,
1145 };
1146
1147 let response = match self.exchange_client.order(order_request, None).await {
1148 Ok(r) => r,
1149 Err(e) => {
1150 warn!(%e, "Open order failed");
1151 return Order {
1152 key: OrderKey {
1153 exchange: ExchangeId::HyperliquidPerp,
1154 instrument: request.key.instrument.clone(),
1155 strategy: request.key.strategy.clone(),
1156 cid: request.key.cid.clone(),
1157 },
1158 side: request.state.side,
1159 price: request.state.price,
1160 quantity: request.state.quantity,
1161 kind: request.state.kind,
1162 time_in_force: request.state.time_in_force,
1163 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
1164 };
1165 }
1166 };
1167
1168 let state = match response {
1170 ExchangeResponseStatus::Ok(exchange_resp) => {
1171 let status = exchange_resp
1173 .data
1174 .and_then(|d| d.statuses.into_iter().next());
1175
1176 match status {
1177 Some(ExchangeDataStatus::Resting(resting)) => {
1178 debug!(oid = resting.oid, "Order resting");
1179 OrderState::active(Open {
1180 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
1181 time_exchange: Utc::now(),
1182 filled_quantity: Decimal::ZERO,
1183 })
1184 }
1185 Some(ExchangeDataStatus::Filled(filled)) => {
1186 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
1187 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
1189 OrderState::fully_filled(Filled::new(
1190 OrderId(format_smolstr!("{}", filled.oid)),
1191 Utc::now(),
1192 parse_decimal(&filled.total_sz, "total_sz")
1193 .unwrap_or(request.state.quantity),
1194 avg_price,
1195 ))
1196 }
1197 Some(ExchangeDataStatus::Error(msg)) => {
1198 warn!(%msg, "Order rejected by exchange");
1199 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1200 }
1201 Some(
1202 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
1203 ) => {
1204 debug!(cid = %request.key.cid, "Order waiting under its cloid");
1208 OrderState::active(Open {
1209 id: VenueOrderId::ClientAssigned,
1210 time_exchange: Utc::now(),
1211 filled_quantity: Decimal::ZERO,
1212 })
1213 }
1214 Some(ExchangeDataStatus::Success) | None => {
1215 warn!(cid = %request.key.cid, "Order answered without its status; outcome unknown");
1218 OrderState::inactive(order_outcome_unanswered())
1219 }
1220 }
1221 }
1222 ExchangeResponseStatus::Err(msg) => {
1223 warn!(%msg, "Order rejected");
1224 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1225 }
1226 };
1227 let order = Order {
1228 key: OrderKey {
1229 exchange: ExchangeId::HyperliquidPerp,
1230 instrument: request.key.instrument.clone(),
1231 strategy: request.key.strategy.clone(),
1232 cid: request.key.cid.clone(),
1233 },
1234 side: request.state.side,
1235 price: request.state.price,
1236 quantity: request.state.quantity,
1237 kind: request.state.kind,
1238 time_in_force: request.state.time_in_force,
1239 state,
1240 };
1241 self.known_live
1242 .lock()
1243 .placed(&order.key, order.quantity, order.kind, &order.state);
1244 order
1245 }
1246
1247 async fn fetch_balances(
1251 &self,
1252 _assets: &[AssetNameExchange],
1253 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1254 perp_account::fetch_balances(&self.info_client, self.wallet_h160(), &self.dexes).await
1255 }
1256
1257 async fn fetch_open_orders(
1264 &self,
1265 instruments: &[InstrumentNameExchange],
1266 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1267 self.check_instruments(instruments)?;
1268 let address = self.wallet_h160();
1269
1270 let open_orders =
1271 perp_account::open_orders(&self.info_client, address, &self.dexes).await?;
1272 warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
1273
1274 let instrument_filter = InstrumentFilter::new(instruments);
1275
1276 let orders: Vec<_> = open_orders
1277 .iter()
1278 .filter_map(|order| {
1279 let instrument = self.dexes.instrument(&order.coin)?;
1280 if !instrument_filter.matches(&instrument) {
1281 return None;
1282 }
1283 open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1284 })
1285 .collect();
1286 remember_open(&self.known_live, &orders);
1287 Ok(orders)
1288 }
1289
1290 async fn fetch_trades(
1301 &self,
1302 start: DateTime<Utc>,
1303 end: DateTime<Utc>,
1304 instruments: &[InstrumentNameExchange],
1305 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1306 self.check_instruments(instruments)?;
1307 let Some((start_ms, end_ms)) = span_millis(start, end) else {
1308 return Ok(TradesRead::complete(Vec::new()));
1309 };
1310 let address = self.wallet_h160();
1311
1312 let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1313 let held_since = common::held_since(&fills, start_ms);
1314 warn_unknown_coins(fills.iter().map(|fill| fill.coin.as_str()));
1315 warn_unconfigured_dexes(&self.dexes, fills.iter().map(|fill| fill.coin.as_str()));
1316
1317 let instrument_filter = InstrumentFilter::new(instruments);
1318
1319 let mut result = Vec::new();
1320 for fill in fills {
1321 let Some((instrument, collateral)) = self.dexes.perp(&fill.coin) else {
1322 continue;
1323 };
1324
1325 if !instrument_filter.matches(&instrument) {
1326 continue;
1327 }
1328
1329 let Some(side) = parse_side(&fill.side) else {
1330 continue;
1331 };
1332 let Some(price) = parse_decimal(&fill.px, "px") else {
1333 continue;
1334 };
1335 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1336 continue;
1337 };
1338 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1339
1340 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1341 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1342 continue;
1343 };
1344 if !(start..=end).contains(&time_exchange) {
1345 continue;
1346 }
1347
1348 result.push(Trade {
1349 id: TradeId(format_smolstr!("{}", fill.tid)),
1350 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1351 instrument,
1352 strategy: StrategyId::unknown(),
1353 time_exchange,
1354 side,
1355 price,
1356 quantity,
1357 order_filled_quantity: None,
1360 fees: perp_fill_fees(fill.fee_token.as_deref(), fee, collateral),
1361 origin: TradeOrigin::Order,
1363 position_side: None,
1364 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1365 });
1366 }
1367
1368 Ok(TradesRead::complete(result).with_held_since(held_since))
1369 }
1370}
1371
1372impl OrderStatusClient for HyperliquidClient {
1387 async fn fetch_ended_orders(
1388 &self,
1389 orders: &[UnindexedOrderKey],
1390 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1391 let address = self.wallet_h160();
1392 fetch_ended_by_key(orders, |key| {
1393 perp_order_lookup(self.info_client.clone(), self.dexes.clone(), address, key)
1394 })
1395 .await
1396 }
1397
1398 async fn fetch_order_status(
1405 &self,
1406 key: &UnindexedOrderKey,
1407 ) -> Result<VenueOrderStatus, UnindexedClientError> {
1408 perp_order_status(
1409 self.info_client.clone(),
1410 self.dexes.clone(),
1411 self.wallet_h160(),
1412 key.clone(),
1413 )
1414 .await?
1415 .ok_or_else(|| unreadable_record(&key.cid))
1416 }
1417}
1418
1419async fn perp_order_lookup(
1421 info_client: Arc<InfoClient>,
1422 dexes: Arc<PerpDexes>,
1423 address: ethers::types::H160,
1424 key: UnindexedOrderKey,
1425) -> Result<OrderLookup, UnindexedClientError> {
1426 Ok(lookup_of(
1427 perp_order_status(info_client, dexes, address, key).await?,
1428 ))
1429}
1430
1431async fn perp_order_status(
1434 info_client: Arc<InfoClient>,
1435 dexes: Arc<PerpDexes>,
1436 address: ethers::types::H160,
1437 key: UnindexedOrderKey,
1438) -> Result<Option<VenueOrderStatus>, UnindexedClientError> {
1439 let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1440 return Ok(Some(VenueOrderStatus::Unknown));
1441 };
1442 let instrument = dexes.instrument(&record.order.coin);
1443 Ok(status_from_record(key, &record, instrument))
1444}
1445
1446async fn perp_listed_cids(
1449 info_client: Arc<InfoClient>,
1450 dexes: Arc<PerpDexes>,
1451 address: ethers::types::H160,
1452 instruments: Vec<InstrumentNameExchange>,
1453) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1454 let rows = perp_account::open_orders(&info_client, address, &dexes).await?;
1455 Ok(listed_cids(&rows, &instruments, |coin| {
1456 dexes.instrument(coin)
1457 }))
1458}
1459
1460fn perp_position_report(
1466 position: &hyperliquid_rust_sdk::PositionData,
1467 now: DateTime<Utc>,
1468) -> PositionReport {
1469 let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1470 return PositionReport::Unreported;
1471 };
1472 PositionReport::from_position(Position::new(
1473 quantity,
1474 position
1475 .entry_px
1476 .as_ref()
1477 .and_then(|p| parse_decimal(p, "entry_px")),
1478 parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1479 parse_decimal(&position.margin_used, "margin_used"),
1480 position
1481 .liquidation_px
1482 .as_ref()
1483 .and_then(|p| parse_decimal(p, "liquidation_px")),
1484 Some(Decimal::from(position.leverage.value)),
1485 parse_decimal(&position.cum_funding.since_open, "cum_funding.since_open").map(|paid| -paid),
1487 now,
1488 ))
1489}
1490
1491fn perp_fill_fees(
1501 fee_token: Option<&str>,
1502 fee: Decimal,
1503 collateral: &str,
1504) -> AssetFees<AssetNameExchange> {
1505 let asset = fee_token.unwrap_or(collateral);
1506 let fees_quote = (asset == collateral).then_some(fee);
1507 AssetFees::new(AssetNameExchange::from(asset), fee, fees_quote)
1508}
1509
1510fn fill_to_account_event(
1513 fill: &hyperliquid_rust_sdk::TradeInfo,
1514 dexes: &PerpDexes,
1515) -> Option<UnindexedAccountEvent> {
1516 let (instrument, collateral) = dexes.perp(&fill.coin)?;
1517 let side = parse_side(&fill.side)?;
1518 let price = parse_decimal(&fill.px, "fill.px")?;
1519 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1520 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1521 let time_exchange = millis_to_datetime(fill.time)?;
1522 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1523
1524 let trade = Trade {
1525 id: TradeId(format_smolstr!("{}", fill.tid)),
1526 order_id,
1527 instrument,
1528 strategy: StrategyId::unknown(),
1529 time_exchange,
1530 side,
1531 price,
1532 quantity,
1533 order_filled_quantity: None,
1536 fees: perp_fill_fees(Some(&fill.fee_token), fee, collateral),
1537 origin: TradeOrigin::Order,
1540 position_side: None,
1541 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1542 };
1543
1544 Some(AccountEvent::new(
1545 ExchangeId::HyperliquidPerp,
1546 AccountEventKind::Trade(trade),
1547 ))
1548}
1549
1550fn order_update_to_account_event(
1553 update: &hyperliquid_rust_sdk::OrderUpdate,
1554 dexes: &PerpDexes,
1555) -> Option<UnindexedAccountEvent> {
1556 common::order_update_to_account_event(
1557 update,
1558 ExchangeId::HyperliquidPerp,
1559 dexes.instrument(&update.order.coin)?,
1560 )
1561}
1562
1563#[cfg(test)]
1564#[allow(clippy::unwrap_used)]
1566mod tests {
1567 use super::*;
1568 use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1569 use rust_decimal_macros::dec;
1570 use rustrade_integration::collection::snapshot::Snapshot;
1571 use std::collections::HashMap;
1572
1573 fn default_dex() -> PerpDexes {
1575 PerpDexes::default()
1576 }
1577
1578 mod order_recovery {
1579 use super::super::common::info_tests::info_client_against;
1580 use super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1581 use super::*;
1582 use crate::order::state::InactiveOrderState;
1583 use wiremock::matchers::{method, path};
1584 use wiremock::{Mock, MockServer, ResponseTemplate};
1585
1586 async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>) {
1587 let server = MockServer::start().await;
1588 Mock::given(method("POST"))
1589 .and(path("/info"))
1590 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1591 .mount(&server)
1592 .await;
1593 let client = Arc::new(info_client_against(server.uri()).await);
1594 (server, client)
1595 }
1596
1597 fn key() -> UnindexedOrderKey {
1598 OrderKey {
1599 exchange: ExchangeId::HyperliquidPerp,
1600 instrument: InstrumentNameExchange::new("BTC-USDC-PERP"),
1601 strategy: StrategyId::new("strategy"),
1602 cid: ClientOrderId::new(CID),
1603 }
1604 }
1605
1606 fn order_status(coin: &str) -> serde_json::Value {
1607 serde_json::json!({
1608 "status": "order",
1609 "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1610 })
1611 }
1612
1613 #[test]
1614 fn a_reconnect_snapshot_wakes_the_check_once_its_fills_are_sent() {
1615 use futures::FutureExt as _;
1616 let notified = |reconnected: &Notify| reconnected.notified().now_or_never().is_some();
1617 let snapshot = |fills| hyperliquid_rust_sdk::UserFillsData {
1618 is_snapshot: Some(true),
1619 user: ethers::types::H160::zero(),
1620 fills,
1621 };
1622 let reconnected = Arc::new(Notify::new());
1623 let mut reconnects = ReconnectWatch::new(reconnected.clone());
1624 let dedup = new_dedup_cache();
1625 let (tx, mut rx) = mpsc::unbounded_channel();
1626 let tids = |rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>| {
1627 std::iter::from_fn(|| rx.try_recv().ok())
1628 .map(|event| match event.kind {
1629 AccountEventKind::Trade(trade) => trade.id.0.to_string(),
1630 other => panic!("expected a trade, got {other:?}"),
1631 })
1632 .collect::<Vec<_>>()
1633 };
1634
1635 let first = snapshot(vec![sweep_fill(1, "60000")]);
1637 assert!(send_fills(
1638 &first,
1639 |fill| fill_to_account_event(fill, &default_dex()),
1640 &dedup,
1641 &tx,
1642 &mut reconnects
1643 ));
1644 assert_eq!(tids(&mut rx), ["1"]);
1645 assert!(!notified(&reconnected));
1646
1647 reconnects.dropped();
1650 let resubscribed = snapshot(vec![sweep_fill(1, "60000"), sweep_fill(2, "60001")]);
1651 assert!(send_fills(
1652 &resubscribed,
1653 |fill| fill_to_account_event(fill, &default_dex()),
1654 &dedup,
1655 &tx,
1656 &mut reconnects
1657 ));
1658 assert_eq!(
1659 tids(&mut rx),
1660 ["2"],
1661 "the fill already sent is not sent again"
1662 );
1663 assert!(notified(&reconnected));
1664
1665 drop(rx);
1667 reconnects.dropped();
1668 let unsent = snapshot(vec![sweep_fill(3, "60002")]);
1669 assert!(!send_fills(
1670 &unsent,
1671 |fill| fill_to_account_event(fill, &default_dex()),
1672 &dedup,
1673 &tx,
1674 &mut reconnects
1675 ));
1676 assert!(!notified(&reconnected));
1677 }
1678
1679 #[tokio::test]
1680 async fn a_perp_lookup_reads_how_the_order_ended() {
1681 let (_server, client) = serve(order_status("BTC")).await;
1682
1683 let lookup =
1684 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1685 .await
1686 .unwrap();
1687
1688 let OrderLookup::Ended(order) = lookup else {
1689 panic!("expected an ended order, got {lookup:?}");
1690 };
1691 assert_eq!(order.key, key());
1692 assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1693 }
1694
1695 #[tokio::test]
1698 async fn a_perp_order_status_is_one_order_status_request() {
1699 let answer = |status: &str| {
1700 serde_json::json!({
1701 "status": "order",
1702 "order": record_json("BTC", status, "0.01", Some(CLOID)),
1703 })
1704 };
1705 for (body, expected) in [
1706 (answer("open"), "live"),
1707 (answer("canceled"), "ended"),
1708 (serde_json::json!({"status": "unknownOid"}), "unknown"),
1709 (answer("aStatusAddedLater"), "error"),
1710 ] {
1711 let server = MockServer::start().await;
1712 Mock::given(method("POST"))
1713 .and(path("/info"))
1714 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1715 .mount(&server)
1716 .await;
1717 let client = client_against(&server, HashMap::new()).await;
1718
1719 let status = client.fetch_order_status(&key()).await;
1720
1721 let found = match &status {
1722 Ok(VenueOrderStatus::Live(order)) if order.key == key() => "live",
1723 Ok(VenueOrderStatus::Ended(order)) if order.key == key() => "ended",
1724 Ok(VenueOrderStatus::Unknown) => "unknown",
1725 Err(UnindexedClientError::Internal(_)) => "error",
1726 _ => "other",
1727 };
1728 assert_eq!(found, expected, "{status:?}");
1729 assert_eq!(server.received_requests().await.unwrap().len(), 1);
1730 }
1731 }
1732
1733 #[tokio::test]
1734 async fn a_perp_lookup_of_a_spot_order_is_unknown() {
1735 let (_server, client) = serve(order_status("@107")).await;
1736
1737 let lookup =
1738 perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1739 .await
1740 .unwrap();
1741
1742 assert!(matches!(lookup, OrderLookup::Unknown));
1743 }
1744
1745 #[tokio::test]
1746 async fn a_perp_listing_names_only_the_orders_on_the_perpetuals_asked_about() {
1747 let rows = serde_json::json!([
1748 row("BTC", 1, Some(CLOID)),
1749 row("ETH", 2, None),
1750 row("@107", 3, None),
1751 ]);
1752 let (_server, client) = serve(rows).await;
1753
1754 let listed = perp_listed_cids(
1755 client,
1756 Arc::default(),
1757 ethers::types::H160::zero(),
1758 vec![InstrumentNameExchange::new("BTC-USDC-PERP")],
1759 )
1760 .await
1761 .unwrap();
1762
1763 assert_eq!(
1764 listed.into_iter().collect::<Vec<_>>(),
1765 [ClientOrderId::new(CID)]
1766 );
1767 }
1768 }
1769
1770 async fn client_against(
1774 server: &wiremock::MockServer,
1775 coin_to_asset: HashMap<String, u32>,
1776 ) -> HyperliquidClient {
1777 use super::common::info_tests::{
1778 exchange_client_against, info_client_against, test_wallet,
1779 };
1780 let mut dexes = super::perp_dexes::tests::xyz_and_flx().await;
1781 dexes.add_default_perps(&[hyperliquid_rust_sdk::AssetMeta {
1782 name: "BTC".to_owned(),
1783 sz_decimals: 5,
1784 }]);
1785 HyperliquidClient {
1786 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1787 info_client: Arc::new(info_client_against(server.uri()).await),
1788 exchange_client: Arc::new(exchange_client_against(server.uri(), coin_to_asset).await),
1789 dexes: Arc::new(dexes),
1790 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
1791 }
1792 }
1793
1794 mod open_order {
1795 use super::*;
1796 use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1797 use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1798 use wiremock::matchers::{method, path};
1799 use wiremock::{Mock, MockServer, ResponseTemplate};
1800
1801 async fn resting_server() -> MockServer {
1803 server_responding(serde_json::json!({
1804 "status": "ok",
1805 "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1806 }))
1807 .await
1808 }
1809
1810 async fn server_responding(body: serde_json::Value) -> MockServer {
1812 server_with(ResponseTemplate::new(200).set_body_json(body)).await
1813 }
1814
1815 async fn server_with(response: ResponseTemplate) -> MockServer {
1817 let server = MockServer::start().await;
1818 Mock::given(method("POST"))
1819 .and(path("/exchange"))
1820 .respond_with(response)
1821 .mount(&server)
1822 .await;
1823 server
1824 }
1825
1826 async fn place_on(
1828 server: &MockServer,
1829 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1830 let client = client_against(server, HashMap::from([("BTC".to_owned(), 0)])).await;
1831 place(
1832 &client,
1833 &InstrumentNameExchange::from("BTC-USDC-PERP"),
1834 OrderKind::Limit,
1835 Some(dec!(50000)),
1836 dec!(0.001),
1837 )
1838 .await
1839 }
1840
1841 #[tokio::test]
1845 async fn an_open_the_venue_may_have_acted_on_has_an_unknown_outcome() {
1846 let no_status = serde_json::json!({"status": "ok", "response": {"type": "order"}});
1847 let success = serde_json::json!({
1848 "status": "ok",
1849 "response": {"type": "order", "data": {"statuses": ["success"]}},
1850 });
1851 for (case, response) in [
1852 (
1853 "5xx",
1854 ResponseTemplate::new(502).set_body_string("Bad Gateway"),
1855 ),
1856 (
1857 "unparsable",
1858 ResponseTemplate::new(200).set_body_string("<html>"),
1859 ),
1860 (
1861 "no status",
1862 ResponseTemplate::new(200).set_body_json(no_status),
1863 ),
1864 ("success", ResponseTemplate::new(200).set_body_json(success)),
1865 ] {
1866 let order = place_on(&server_with(response).await).await;
1867 assert!(
1868 matches!(
1869 order.state,
1870 OrderState::Inactive(InactiveOrderState::OpenFailed(
1871 OrderError::Connectivity(_)
1872 ))
1873 ),
1874 "{case}: {:?}",
1875 order.state
1876 );
1877 assert!(order.state.is_outcome_unknown(), "{case}");
1878 }
1879
1880 let server = MockServer::start().await;
1883 let mut client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1884 let closed = std::net::TcpListener::bind("127.0.0.1:0")
1885 .and_then(|listener| listener.local_addr())
1886 .unwrap();
1887 client.exchange_client = Arc::new(
1888 super::super::common::info_tests::exchange_client_against(
1889 format!("http://{closed}"),
1890 HashMap::from([("BTC".to_owned(), 0)]),
1891 )
1892 .await,
1893 );
1894 let order = place(
1895 &client,
1896 &InstrumentNameExchange::from("BTC-USDC-PERP"),
1897 OrderKind::Limit,
1898 Some(dec!(50000)),
1899 dec!(0.001),
1900 )
1901 .await;
1902 assert!(order.state.is_outcome_unknown(), "{:?}", order.state);
1903 }
1904
1905 #[tokio::test]
1907 async fn an_open_turned_away_with_a_4xx_is_refused() {
1908 let order = place_on(
1909 &server_with(ResponseTemplate::new(422).set_body_string("Failed to deserialize"))
1910 .await,
1911 )
1912 .await;
1913 assert!(
1914 matches!(
1915 order.state,
1916 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1917 ApiError::OrderRejected(_)
1918 )))
1919 ),
1920 "{:?}",
1921 order.state
1922 );
1923
1924 let order = place_on(&server_with(ResponseTemplate::new(429)).await).await;
1925 assert_eq!(
1926 order.state,
1927 OrderState::inactive(OrderError::Rejected(ApiError::rate_limited(None)))
1928 );
1929 }
1930
1931 #[tokio::test]
1935 async fn an_order_refused_for_margin_is_balance_insufficient() {
1936 const MARGIN: &str = "Insufficient margin to place order. asset=0";
1937 const MINIMUM: &str = "Order must have minimum value of $10. asset=0";
1938 let in_status = |error: &str| {
1939 serde_json::json!({
1940 "status": "ok",
1941 "response": {"type": "order", "data": {"statuses": [{"error": error}]}},
1942 })
1943 };
1944 let in_response = |error: &str| serde_json::json!({"status": "err", "response": error});
1945 for (body, expected) in [
1946 (
1947 in_status(MARGIN),
1948 ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1949 ),
1950 (
1951 in_response(MARGIN),
1952 ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1953 ),
1954 (
1955 in_status(MINIMUM),
1956 ApiError::OrderRejected(MINIMUM.to_owned()),
1957 ),
1958 (
1959 in_response(MINIMUM),
1960 ApiError::OrderRejected(MINIMUM.to_owned()),
1961 ),
1962 ] {
1963 let server = server_responding(body.clone()).await;
1964 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1965 let order = place(
1966 &client,
1967 &InstrumentNameExchange::from("BTC-USDC-PERP"),
1968 OrderKind::Limit,
1969 Some(dec!(50000)),
1970 dec!(0.001),
1971 )
1972 .await;
1973 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1974 reported,
1975 ))) = &order.state
1976 else {
1977 panic!("{body}: expected a rejection, got {:?}", order.state);
1978 };
1979 assert_eq!(reported, &expected, "{body}");
1980 }
1981 }
1982
1983 async fn place(
1984 client: &HyperliquidClient,
1985 instrument: &InstrumentNameExchange,
1986 kind: OrderKind,
1987 price: Option<Decimal>,
1988 quantity: Decimal,
1989 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1990 client
1991 .open_order(OrderEvent {
1992 key: OrderKey {
1993 exchange: ExchangeId::HyperliquidPerp,
1994 instrument,
1995 strategy: StrategyId::new("strategy"),
1996 cid: ClientOrderId::uuid(),
1997 },
1998 state: RequestOpen::new(
1999 Side::Buy,
2000 price,
2001 quantity,
2002 kind,
2003 TimeInForce::GoodUntilCancelled { post_only: false },
2004 ),
2005 })
2006 .await
2007 }
2008
2009 async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
2011 server
2012 .received_requests()
2013 .await
2014 .unwrap_or_default()
2015 .into_iter()
2016 .filter(|request| request.url.path() == "/exchange")
2017 .map(|request| {
2018 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
2019 body["action"]["orders"][0].clone()
2020 })
2021 .collect()
2022 }
2023
2024 #[tokio::test]
2025 async fn the_client_reports_each_perpetuals_precision() {
2026 let server = MockServer::start().await;
2027 let client = client_against(&server, HashMap::new()).await;
2028
2029 assert_eq!(
2030 client.order_precision(&InstrumentNameExchange::from("BTC-USDC-PERP")),
2031 Some(OrderPrecision::perp(5))
2032 );
2033 assert_eq!(
2034 client.order_precision(&InstrumentNameExchange::from("xyz:TSLA-USDC-PERP")),
2035 Some(OrderPrecision::perp(2)),
2036 "a HIP-3 perpetual's szDecimals come from its DEX's meta"
2037 );
2038 assert_eq!(
2039 client.order_precision(&InstrumentNameExchange::from("ETH-USDC-PERP")),
2040 None,
2041 "not listed when the client connected"
2042 );
2043 assert_eq!(
2044 client.order_precision(&InstrumentNameExchange::from("HYPE-USDC-SPOT")),
2045 None
2046 );
2047 }
2048
2049 #[tokio::test]
2050 async fn an_order_breaking_the_precision_rules_is_refused_unsent() {
2051 let server = resting_server().await;
2052 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2053 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
2054
2055 let cases = [
2056 (
2057 OrderKind::Limit,
2058 dec!(60000),
2059 dec!(0.000001),
2060 OrderField::Quantity,
2061 PrecisionLimit::DecimalPlaces { max: 5 },
2062 ),
2063 (
2064 OrderKind::Limit,
2065 dec!(60000),
2066 dec!(0),
2067 OrderField::Quantity,
2068 PrecisionLimit::NotPositive,
2069 ),
2070 (
2071 OrderKind::Limit,
2072 dec!(65432.1),
2073 dec!(0.001),
2074 OrderField::Price,
2075 PrecisionLimit::SignificantFigures { max: 5 },
2076 ),
2077 (
2078 OrderKind::Limit,
2079 dec!(6543.21),
2080 dec!(0.001),
2081 OrderField::Price,
2082 PrecisionLimit::DecimalPlaces { max: 1 },
2083 ),
2084 (
2085 OrderKind::StopLimit {
2086 trigger_price: dec!(6543.21),
2087 },
2088 dec!(6543),
2089 dec!(0.001),
2090 OrderField::TriggerPrice,
2091 PrecisionLimit::DecimalPlaces { max: 1 },
2092 ),
2093 ];
2094 for (kind, price, quantity, field, limit) in cases {
2095 let order = place(&client, &btc, kind, Some(price), quantity).await;
2096 let expected = PrecisionViolation {
2097 field,
2098 value: match field {
2099 OrderField::Quantity => quantity,
2100 OrderField::TriggerPrice => dec!(6543.21),
2101 _ => price,
2102 },
2103 limit,
2104 };
2105 assert_eq!(
2106 order.state,
2107 OrderState::Inactive(InactiveOrderState::OpenFailed(
2108 OrderError::InvalidPrecision(expected)
2109 )),
2110 "{kind} at {price} for {quantity}"
2111 );
2112 }
2113 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
2114 }
2115
2116 #[tokio::test]
2117 async fn a_valid_order_is_sent_unchanged() {
2118 let server = resting_server().await;
2119 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2120 let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
2121
2122 let order = place(
2124 &client,
2125 &btc,
2126 OrderKind::Limit,
2127 Some(dec!(123456)),
2128 dec!(0.00123),
2129 )
2130 .await;
2131 assert!(
2132 matches!(order.state, OrderState::Active(_)),
2133 "{:?}",
2134 order.state
2135 );
2136
2137 let sent = sent_orders(&server).await;
2138 assert_eq!(sent.len(), 1);
2139 assert_eq!(
2140 (&sent[0]["p"], &sent[0]["s"]),
2141 (&serde_json::json!("123456"), &serde_json::json!("0.00123"))
2142 );
2143 }
2144
2145 #[tokio::test]
2146 async fn an_order_on_a_perpetual_listed_since_connecting_is_refused_unsent() {
2147 let server = resting_server().await;
2148 let client = client_against(&server, HashMap::from([("ETH".to_owned(), 1)])).await;
2149
2150 let order = place(
2151 &client,
2152 &InstrumentNameExchange::from("ETH-USDC-PERP"),
2153 OrderKind::Limit,
2154 Some(dec!(3000)),
2155 dec!(1),
2156 )
2157 .await;
2158 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
2159 crate::error::ApiError::InstrumentInvalid(_, reason),
2160 ))) = &order.state
2161 else {
2162 panic!("expected InstrumentInvalid, got {:?}", order.state);
2163 };
2164 assert!(reason.contains("reconnect"), "{reason}");
2165 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
2166 }
2167 }
2168
2169 mod cancel {
2170 use super::super::common::info_tests::{CANCEL_NOT_APPLIED, cancel_answer};
2171 use super::*;
2172 use crate::error::ApiError;
2173 use crate::order::{OrderEvent, request::RequestCancel};
2174 use wiremock::matchers::{method, path};
2175 use wiremock::{Mock, MockServer, ResponseTemplate};
2176
2177 async fn cancel_answered(
2180 answer: serde_json::Value,
2181 ) -> (UnindexedOrderResponseCancel, bool) {
2182 cancel_responded(ResponseTemplate::new(200).set_body_json(answer)).await
2183 }
2184
2185 async fn cancel_responded(
2187 response: ResponseTemplate,
2188 ) -> (UnindexedOrderResponseCancel, bool) {
2189 let server = MockServer::start().await;
2190 Mock::given(method("POST"))
2191 .and(path("/exchange"))
2192 .respond_with(response)
2193 .mount(&server)
2194 .await;
2195 let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2196 let instrument = InstrumentNameExchange::new("BTC-USDC-PERP");
2197 let key = OrderKey {
2198 exchange: ExchangeId::HyperliquidPerp,
2199 instrument: instrument.clone(),
2200 strategy: StrategyId::new("strategy"),
2201 cid: ClientOrderId::uuid(),
2202 };
2203 let id = VenueOrderId::Assigned(OrderId::new("7"));
2204 let open = Open {
2205 id: id.clone(),
2206 time_exchange: Utc::now(),
2207 filled_quantity: Decimal::ZERO,
2208 };
2209 client
2210 .known_live
2211 .lock()
2212 .live(&key, dec!(1), OrderKind::Limit, &open);
2213
2214 let response = client
2215 .cancel_order(OrderEvent {
2216 key: OrderKey {
2217 exchange: key.exchange,
2218 instrument: &instrument,
2219 strategy: key.strategy.clone(),
2220 cid: key.cid.clone(),
2221 },
2222 state: RequestCancel { id: Some(id) },
2223 })
2224 .await;
2225 let live = client.known_live.lock().contains(&key.cid);
2226 (response, live)
2227 }
2228
2229 #[tokio::test]
2230 async fn a_success_status_cancels_the_order() {
2231 let (response, live) =
2232 cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
2233 assert!(response.state.is_ok(), "{:?}", response.state);
2234 assert!(!live, "a cancelled order is no longer held live");
2235 }
2236
2237 #[tokio::test]
2240 async fn an_error_status_is_a_rejected_cancel_and_the_order_stays_live() {
2241 let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
2242 {"error": CANCEL_NOT_APPLIED}
2243 ])))
2244 .await;
2245 let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
2246 &response.state
2247 else {
2248 panic!("expected a rejected cancel, got {:?}", response.state);
2249 };
2250 assert_eq!(reason, CANCEL_NOT_APPLIED);
2251 assert!(live, "the order is still held live");
2252 }
2253
2254 #[tokio::test]
2257 async fn a_cancel_failing_after_the_send_has_an_unknown_outcome() {
2258 let (response, live) = cancel_responded(ResponseTemplate::new(503)).await;
2259 assert!(
2260 matches!(response.state, Err(UnindexedOrderError::Connectivity(_))),
2261 "{:?}",
2262 response.state
2263 );
2264 assert!(live, "the order is still held live");
2265
2266 let (response, live) =
2267 cancel_responded(ResponseTemplate::new(422).set_body_string("bad request")).await;
2268 assert!(
2269 matches!(
2270 response.state,
2271 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
2272 ),
2273 "{:?}",
2274 response.state
2275 );
2276 assert!(live, "the order is still held live");
2277 }
2278
2279 #[tokio::test]
2280 async fn an_answer_without_a_status_is_a_rejected_cancel() {
2281 let (response, live) = cancel_answered(serde_json::json!({
2282 "status": "ok",
2283 "response": {"type": "cancel"},
2284 }))
2285 .await;
2286 assert!(
2287 matches!(
2288 response.state,
2289 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
2290 ),
2291 "{:?}",
2292 response.state
2293 );
2294 assert!(live, "the order is still held live");
2295 }
2296 }
2297
2298 mod instrument_names {
2299 use super::*;
2300 use crate::error::ApiError;
2301 use crate::order::{
2302 OrderEvent,
2303 request::{RequestCancel, RequestOpen},
2304 };
2305 use wiremock::MockServer;
2306
2307 async fn offline_client() -> (MockServer, HyperliquidClient) {
2311 let server = MockServer::start().await;
2312 let client = client_against(&server, HashMap::new()).await;
2313 (server, client)
2314 }
2315
2316 const REFUSED: [&str; 4] = [
2319 "BTC-USD-PERP",
2320 "flx:TSLA-USDC-PERP",
2321 "km:TSLA-USDH-PERP",
2322 "HYPE-USDC-SPOT",
2323 ];
2324
2325 fn is_invalid(
2326 error: &ApiError<AssetNameExchange, InstrumentNameExchange>,
2327 name: &str,
2328 ) -> bool {
2329 matches!(error, ApiError::InstrumentInvalid(instrument, _) if instrument.as_ref() == name)
2330 }
2331
2332 #[tokio::test]
2333 async fn an_order_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2334 let (_server, client) = offline_client().await;
2335 for name in REFUSED {
2336 let instrument = InstrumentNameExchange::from(name);
2337 let order = client
2338 .open_order(OrderEvent {
2339 key: OrderKey {
2340 exchange: ExchangeId::HyperliquidPerp,
2341 instrument: &instrument,
2342 strategy: StrategyId::new("strategy"),
2343 cid: ClientOrderId::uuid(),
2344 },
2345 state: RequestOpen::new(
2346 Side::Buy,
2347 Some(dec!(1)),
2348 dec!(1),
2349 OrderKind::Limit,
2350 TimeInForce::GoodUntilCancelled { post_only: false },
2351 ),
2352 })
2353 .await;
2354 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
2355 OrderError::Rejected(error),
2356 )) = &order.state
2357 else {
2358 panic!("{name}: expected a rejection, got {:?}", order.state);
2359 };
2360 assert!(is_invalid(error, name), "{name}: {error:?}");
2361 }
2362 }
2363
2364 #[tokio::test]
2365 async fn a_cancel_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2366 let (_server, client) = offline_client().await;
2367 for name in REFUSED {
2368 let instrument = InstrumentNameExchange::from(name);
2369 let response = client
2370 .cancel_order(OrderEvent {
2371 key: OrderKey {
2372 exchange: ExchangeId::HyperliquidPerp,
2373 instrument: &instrument,
2374 strategy: StrategyId::new("strategy"),
2375 cid: ClientOrderId::uuid(),
2376 },
2377 state: RequestCancel {
2378 id: Some(VenueOrderId::Assigned(OrderId::new("1"))),
2379 },
2380 })
2381 .await;
2382 let Err(UnindexedOrderError::Rejected(error)) = &response.state else {
2383 panic!("{name}: expected a rejection, got {:?}", response.state);
2384 };
2385 assert!(is_invalid(error, name), "{name}: {error:?}");
2386 }
2387 }
2388
2389 #[tokio::test]
2390 async fn a_read_filtered_to_a_name_the_client_does_not_trade_fails_unsent() {
2391 let (_server, client) = offline_client().await;
2392 for name in REFUSED {
2393 let instruments = [InstrumentNameExchange::from(name)];
2394 let snapshot = client.account_snapshot(&[], &instruments).await;
2395 let open = client.fetch_open_orders(&instruments).await;
2396 let trades = client
2397 .fetch_trades(
2398 Utc::now() - chrono::Duration::hours(1),
2399 Utc::now(),
2400 &instruments,
2401 )
2402 .await;
2403 for error in [snapshot.err(), open.err(), trades.err()] {
2404 let Some(UnindexedClientError::Api(error)) = &error else {
2405 panic!("{name}: expected an API error, got {error:?}");
2406 };
2407 assert!(is_invalid(error, name), "{name}: {error:?}");
2408 }
2409 }
2410 }
2411 }
2412
2413 fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
2414 serde_json::from_value(serde_json::json!({
2415 "coin": "BTC",
2416 "entryPx": "50000.0",
2417 "leverage": {"type": "cross", "value": 5},
2418 "liquidationPx": null,
2419 "marginUsed": "100.0",
2420 "positionValue": "500.0",
2421 "returnOnEquity": "0.0",
2422 "szi": szi,
2423 "unrealizedPnl": "1.5",
2424 "maxLeverage": 50,
2425 "cumFunding": {"allTime": "3.5", "sinceOpen": "1.25", "sinceChange": "0"}
2426 }))
2427 .unwrap()
2428 }
2429
2430 #[test]
2431 fn perp_position_report_is_open_flat_or_unreported() {
2432 let now = Utc::now();
2433 let open = perp_position_report(&perp_position("-0.01"), now);
2434 let position = open.open().unwrap();
2435 assert_eq!(
2437 (
2438 position.quantity,
2439 position.entry_price,
2440 position.leverage,
2441 position.carry
2442 ),
2443 (
2444 dec!(-0.01),
2445 Some(dec!(50000.0)),
2446 Some(dec!(5)),
2447 Some(dec!(-1.25))
2448 )
2449 );
2450 assert_eq!(
2451 perp_position_report(&perp_position("0.0"), now),
2452 PositionReport::Flat
2453 );
2454 assert_eq!(
2455 perp_position_report(&perp_position("not a number"), now),
2456 PositionReport::Unreported
2457 );
2458 }
2459
2460 #[test]
2461 fn test_fill_to_account_event() {
2462 let fill_json = r#"{
2463 "coin": "BTC",
2464 "side": "B",
2465 "px": "65000.5",
2466 "sz": "0.1",
2467 "time": 1714100000000,
2468 "hash": "0xabc123",
2469 "startPosition": "0",
2470 "dir": "Open Long",
2471 "closedPnl": "0",
2472 "oid": 12345,
2473 "cloid": null,
2474 "crossed": false,
2475 "fee": "0.65",
2476 "feeToken": "USDC",
2477 "tid": 99999
2478 }"#;
2479
2480 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2481 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2482
2483 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2484 match event.kind {
2485 AccountEventKind::Trade(trade) => {
2486 assert_eq!(trade.instrument.as_ref(), "BTC-USDC-PERP");
2487 assert_eq!(
2488 trade.id.0, "99999",
2489 "the id is the fill's tid, not its hash"
2490 );
2491 assert_eq!(trade.side, Side::Buy);
2492 assert_eq!(trade.price, dec!(65000.5));
2493 assert_eq!(trade.quantity, dec!(0.1));
2494 assert_eq!(trade.fees.fees, dec!(0.65));
2495 assert_eq!(trade.fees.asset.as_ref(), "USDC");
2496 assert_eq!(trade.fees.fees_quote, Some(dec!(0.65)));
2497 }
2498 _ => panic!("Expected Trade event"),
2499 }
2500 }
2501
2502 #[tokio::test]
2504 async fn a_fill_carries_the_client_order_id_of_its_cloid() {
2505 let fill_json = r#"{
2506 "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2507 "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2508 "dir": "Open Long", "closedPnl": "0", "oid": 7,
2509 "cloid": "0x0123456789abcdef0123456789abcdef",
2510 "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2511 }"#;
2512 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2513
2514 let dexes = perp_dexes::tests::xyz_and_flx().await;
2515 let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2516 else {
2517 panic!("Expected Trade event");
2518 };
2519 assert_eq!(
2520 trade.client_order_id,
2521 cloid_to_cid("0x0123456789abcdef0123456789abcdef")
2522 );
2523 assert!(trade.client_order_id.is_some());
2524 }
2525
2526 #[tokio::test]
2527 async fn a_builder_deployed_fill_is_named_and_charged_in_its_dex_collateral() {
2528 let fill_json = r#"{
2530 "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2531 "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2532 "dir": "Open Long", "closedPnl": "0", "oid": 7, "cloid": null,
2533 "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2534 }"#;
2535 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2536
2537 let dexes = perp_dexes::tests::xyz_and_flx().await;
2538 let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2539 else {
2540 panic!("Expected Trade event");
2541 };
2542 assert_eq!(trade.instrument.as_ref(), "flx:TSLA-USDH-PERP");
2543 assert_eq!(trade.client_order_id, None, "placed without a cloid");
2544 assert_eq!(trade.fees.asset.as_ref(), "USDH");
2545 assert_eq!(trade.fees.fees, dec!(0.2));
2546 assert_eq!(
2547 trade.fees.fees_quote,
2548 Some(dec!(0.2)),
2549 "the fee is in the instrument's quote"
2550 );
2551
2552 assert!(fill_to_account_event(&fill, &default_dex()).is_none());
2554 }
2555
2556 #[test]
2557 fn perp_fill_fees_reads_the_fee_token_and_falls_back_to_the_collateral() {
2558 assert_eq!(
2559 perp_fill_fees(Some("USDT0"), dec!(1.5), "USDC"),
2560 AssetFees::new(AssetNameExchange::from("USDT0"), dec!(1.5), None)
2561 );
2562 assert_eq!(
2563 perp_fill_fees(Some("USDC"), dec!(1.5), "USDC"),
2564 AssetFees::new(AssetNameExchange::from("USDC"), dec!(1.5), Some(dec!(1.5)))
2565 );
2566 assert_eq!(
2567 perp_fill_fees(None, dec!(1.5), "USDH"),
2568 AssetFees::new(AssetNameExchange::from("USDH"), dec!(1.5), Some(dec!(1.5))),
2569 "an absent feeToken is the DEX's collateral"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_fill_to_account_event_sell() {
2575 let fill_json = r#"{
2576 "coin": "ETH",
2577 "side": "A",
2578 "px": "3200",
2579 "sz": "1.5",
2580 "time": 1714100000000,
2581 "hash": "0xdef456",
2582 "startPosition": "1.5",
2583 "dir": "Close Long",
2584 "closedPnl": "150.0",
2585 "oid": 12346,
2586 "cloid": null,
2587 "crossed": true,
2588 "fee": "4.8",
2589 "feeToken": "USDC",
2590 "tid": 100000
2591 }"#;
2592
2593 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2594 let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2595
2596 match event.kind {
2597 AccountEventKind::Trade(trade) => {
2598 assert_eq!(trade.instrument.as_ref(), "ETH-USDC-PERP");
2599 assert_eq!(trade.side, Side::Sell);
2600 assert_eq!(trade.price, dec!(3200));
2601 assert_eq!(trade.quantity, dec!(1.5));
2602 }
2603 _ => panic!("Expected Trade event"),
2604 }
2605 }
2606
2607 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
2609 let json = format!(
2610 r#"{{
2611 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
2612 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
2613 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
2614 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
2615 }}"#
2616 );
2617 serde_json::from_str(&json).unwrap()
2618 }
2619
2620 #[test]
2621 fn a_trade_id_identifies_the_fill_not_the_transaction() {
2622 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2626 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2627
2628 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
2629 (first.kind, second.kind)
2630 else {
2631 panic!("Expected Trade events");
2632 };
2633
2634 assert_eq!(first.order_id, second.order_id, "one order produced both");
2635 assert_ne!(first.id, second.id, "but they are distinct fills");
2636 assert_eq!(first.id.0, "111");
2637 assert_eq!(second.id.0, "222");
2638 }
2639
2640 #[test]
2641 fn a_replayed_fill_is_delivered_once() {
2642 let cache = new_dedup_cache();
2645 let event = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2646 let replay = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2647
2648 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
2649 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
2650 }
2651
2652 #[test]
2653 fn both_fills_of_one_sweep_survive_dedup() {
2654 let cache = new_dedup_cache();
2657 let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2658 let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2659
2660 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
2661 assert!(!is_duplicate(
2662 &cache,
2663 dedup_key_from_event(&second).unwrap()
2664 ));
2665 }
2666
2667 #[test]
2668 fn one_tid_on_two_instruments_is_two_fills() {
2669 let cache = new_dedup_cache();
2672 let btc = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2673
2674 let eth_json = r#"{
2675 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
2676 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
2677 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
2678 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
2679 }"#;
2680 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
2681 let eth = fill_to_account_event(ð, &default_dex()).unwrap();
2682
2683 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
2684 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
2685 }
2686
2687 #[test]
2688 fn test_order_update_to_account_event_open() {
2689 let update_json = r#"{
2690 "order": {
2691 "coin": "BTC",
2692 "side": "B",
2693 "limitPx": "64000",
2694 "sz": "0.5",
2695 "oid": 12345,
2696 "timestamp": 1714100000000,
2697 "origSz": "0.5",
2698 "cloid": null
2699 },
2700 "status": "open",
2701 "statusTimestamp": 1714100000000
2702 }"#;
2703
2704 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2705 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2706
2707 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2708 match event.kind {
2709 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2710 assert_eq!(order.key.instrument.as_ref(), "BTC-USDC-PERP");
2711 assert_eq!(order.side, Side::Buy);
2712 assert_eq!(order.price, Some(dec!(64000)));
2713 assert_eq!(order.quantity, dec!(0.5));
2714 assert!(matches!(
2715 order.state,
2716 crate::order::state::OrderState::Active(_)
2717 ));
2718 }
2719 _ => panic!("Expected OrderSnapshot event"),
2720 }
2721 }
2722
2723 #[test]
2724 fn test_order_update_to_account_event_filled() {
2725 let update_json = r#"{
2726 "order": {
2727 "coin": "ETH",
2728 "side": "A",
2729 "limitPx": "3250",
2730 "sz": "0",
2731 "oid": 12346,
2732 "timestamp": 1714100000000,
2733 "origSz": "2.0",
2734 "cloid": null
2735 },
2736 "status": "filled",
2737 "statusTimestamp": 1714100001000
2738 }"#;
2739
2740 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2741 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2742
2743 match event.kind {
2744 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2745 assert_eq!(order.side, Side::Sell);
2746 assert!(matches!(
2747 order.state,
2748 crate::order::state::OrderState::Inactive(
2749 crate::order::state::InactiveOrderState::FullyFilled(_)
2750 )
2751 ));
2752 }
2753 _ => panic!("Expected OrderSnapshot event"),
2754 }
2755 }
2756
2757 #[test]
2758 fn test_order_update_to_account_event_cancelled() {
2759 let update_json = r#"{
2760 "order": {
2761 "coin": "SOL",
2762 "side": "B",
2763 "limitPx": "150",
2764 "sz": "10",
2765 "oid": 12347,
2766 "timestamp": 1714100000000,
2767 "origSz": "10",
2768 "cloid": null
2769 },
2770 "status": "canceled",
2771 "statusTimestamp": 1714100002000
2772 }"#;
2773
2774 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2775 let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2776
2777 match event.kind {
2778 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2779 assert_eq!(order.key.instrument.as_ref(), "SOL-USDC-PERP");
2780 assert!(matches!(
2781 order.state,
2782 crate::order::state::OrderState::Inactive(
2783 crate::order::state::InactiveOrderState::Cancelled(_)
2784 )
2785 ));
2786 }
2787 _ => panic!("Expected OrderSnapshot event"),
2788 }
2789 }
2790
2791 #[test]
2792 fn test_order_update_unknown_status_returns_none() {
2793 let update_json = r#"{
2794 "order": {
2795 "coin": "BTC",
2796 "side": "B",
2797 "limitPx": "64000",
2798 "sz": "0.5",
2799 "oid": 12345,
2800 "timestamp": 1714100000000,
2801 "origSz": "0.5",
2802 "cloid": null
2803 },
2804 "status": "unknown_status",
2805 "statusTimestamp": 1714100000000
2806 }"#;
2807
2808 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2809 let event = order_update_to_account_event(&update, &default_dex());
2810 assert!(event.is_none());
2811 }
2812}