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