1use super::common::{
79 CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrder, OpenOrderListing, UserFill,
80 cancel_outcome, cid_to_cloid, cloid_to_cid, instrument_to_spot_coin, map_tif,
81 millis_to_datetime, open_order_to_order, open_orders, parse_decimal, parse_side, span_millis,
82 spot_balances, spot_base_quote, spot_pair_to_instrument, user_fills_by_time,
83};
84use super::config::HyperliquidConfig;
85use super::error::{map_order_error, map_sdk_error, order_rejection};
86use super::order_recovery::{
87 ReconnectWatch, fetch_order_record, listed_cids, lookup_from_record, remember_open,
88 remember_snapshot, send_fills, send_observed, spawn_order_checks,
89};
90use super::precision::{OrderPrecision, WireOrderError, wire_order};
91use super::spot_coins::{SpotCoins, spot_pair};
92use crate::client::dedup::new_dedup_cache;
93use crate::client::order_recovery::{
94 KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
95};
96use crate::{
97 AccountEvent, AccountEventKind, AccountSnapshot, UnindexedAccountEvent,
98 UnindexedAccountSnapshot,
99 balance::AssetBalance,
100 client::{ExecutionClient, OrderStatusClient},
101 emit_stream_terminated,
102 error::{
103 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
104 UnindexedOrderError,
105 },
106 order::{
107 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
108 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
109 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
110 state::{Filled, Open, OrderState, UnindexedOrderState},
111 },
112 trade::{AssetFees, Trade, TradeId, TradeOrigin, TradesRead},
113};
114use chrono::{DateTime, Utc};
115use ethers::signers::Signer;
116use fnv::FnvHashSet;
117use futures::{StreamExt, stream::BoxStream};
118use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
119use rust_decimal::Decimal;
120use rustrade_instrument::{
121 Side,
122 asset::name::AssetNameExchange,
123 exchange::ExchangeId,
124 hyperliquid::{CoinKind, SpotPair, SpotPairs},
125 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
126};
127use smol_str::format_smolstr;
128use std::{
129 collections::HashSet,
130 sync::{
131 Arc,
132 atomic::{AtomicBool, Ordering},
133 },
134};
135use tokio::sync::{Notify, mpsc};
136use tokio_util::sync::CancellationToken;
137use tracing::{debug, error, info, warn};
138
139#[derive(Debug, Clone)]
144pub struct HyperliquidSpotClient {
145 config: HyperliquidConfig,
146 info_client: Arc<InfoClient>,
147 exchange_client: Arc<ExchangeClient>,
148 spot_coins: SpotCoins,
149 known_live: SharedKnownLiveOrders,
152}
153
154impl HyperliquidSpotClient {
155 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
165 let base_url = config.base_url();
166
167 let info_client = InfoClient::new(None, Some(base_url))
168 .await
169 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
170
171 let wallet = config.wallet.clone();
172 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
173 .await
174 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
175
176 let info_client = Arc::new(info_client);
177 let spot_coins = SpotCoins::fetch(Arc::clone(&info_client))
178 .await
179 .map_err(|e| ConnectivityError::Socket(format!("spotMeta: {e}")))?;
180
181 info!(
182 network = ?config.network,
183 wallet = %config.wallet_address_hex(),
184 "Created HyperliquidSpotClient"
185 );
186
187 Ok(Self {
188 config,
189 info_client,
190 exchange_client: Arc::new(exchange_client),
191 spot_coins,
192 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
193 })
194 }
195
196 pub fn wallet_address(&self) -> String {
198 self.config.wallet_address_hex()
199 }
200
201 pub async fn order_precision(
217 &self,
218 instrument: &InstrumentNameExchange,
219 ) -> Option<OrderPrecision> {
220 let (base, quote) = spot_base_quote(instrument)?;
221 let pair = self.traded_pair(base, quote).await.ok()?;
222 Some(OrderPrecision::spot(pair.base_sz_decimals()))
223 }
224
225 async fn traded_pair(&self, base: &str, quote: &str) -> Result<SpotPair, UntradedPair> {
228 let pair = self
229 .spot_coins
230 .find(base, quote)
231 .await
232 .ok_or(UntradedPair::Unlisted)?;
233 if pair.base() == base && pair.quote() == quote {
234 Ok(pair)
235 } else {
236 Err(UntradedPair::Misspelled(pair))
237 }
238 }
239
240 fn wallet_h160(&self) -> ethers::types::H160 {
242 self.config.wallet.address()
243 }
244}
245
246#[derive(Debug)]
248enum UntradedPair {
249 Unlisted,
251 Misspelled(SpotPair),
255}
256
257impl ExecutionClient for HyperliquidSpotClient {
258 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
259
260 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
262 &[InstrumentKindDiscriminant::Spot];
263
264 type Config = HyperliquidConfig;
265 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
266
267 fn new(config: Self::Config) -> Self {
281 let base_url = config.base_url();
282
283 let handle = tokio::runtime::Handle::current();
284
285 let info_client = handle.block_on(async {
286 InfoClient::new(None, Some(base_url))
287 .await
288 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
289 });
290
291 let wallet = config.wallet.clone();
292 let exchange_client = handle.block_on(async {
293 ExchangeClient::new(None, wallet, Some(base_url), None, None)
294 .await
295 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
296 });
297
298 let info_client = Arc::new(info_client);
299 let spot_coins = handle.block_on(async {
300 SpotCoins::fetch(Arc::clone(&info_client))
301 .await
302 .unwrap_or_else(|e| panic!("Failed to read Hyperliquid spotMeta: {e}"))
303 });
304
305 info!(
306 network = ?config.network,
307 wallet = %config.wallet_address_hex(),
308 "Created HyperliquidSpotClient"
309 );
310
311 Self {
312 config,
313 info_client,
314 exchange_client: Arc::new(exchange_client),
315 spot_coins,
316 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
317 }
318 }
319
320 async fn account_snapshot(
321 &self,
322 _assets: &[AssetNameExchange],
323 instruments: &[InstrumentNameExchange],
324 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
325 let address = self.wallet_h160();
326
327 let (token_balances, open_orders) = tokio::try_join!(
329 async {
330 self.info_client
331 .user_token_balances(address)
332 .await
333 .map_err(map_sdk_error)
334 },
335 open_orders(&self.info_client, address)
336 )?;
337
338 let now = Utc::now();
339 let balances = spot_balances(&token_balances.balances, now).collect();
340
341 let coins = open_orders.iter().map(|order| order.coin.as_str());
342 let pairs = self.spot_coins.covering(coins.clone()).await;
343 for coin in coins {
344 spot_pair(&pairs, coin)?;
345 }
346
347 let instrument_snapshots = OpenOrderListing::new(
349 &open_orders,
350 ExchangeId::HyperliquidSpot,
351 instruments,
352 |coin| pairs.get(coin).map(spot_pair_to_instrument),
353 )
354 .into_snapshots()
355 .collect();
356
357 let snapshot = AccountSnapshot {
358 exchange: ExchangeId::HyperliquidSpot,
359 balances,
360 instruments: instrument_snapshots,
361 };
362 remember_snapshot(&self.known_live, &snapshot);
363 Ok(snapshot)
364 }
365
366 async fn account_stream(
389 &self,
390 _assets: &[AssetNameExchange],
391 _instruments: &[InstrumentNameExchange],
392 ) -> Result<Self::AccountStream, UnindexedClientError> {
393 let user = self.wallet_h160();
394 let base_url = self.config.base_url();
395
396 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
397 .await
398 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
399
400 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
401 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
402
403 ws_client
404 .subscribe(Subscription::UserFills { user }, fills_tx)
405 .await
406 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
407
408 ws_client
409 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
410 .await
411 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
412
413 info!(%user, "Subscribed to Hyperliquid spot account stream");
414
415 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
416 let cancel_token = CancellationToken::new();
417
418 let terminated = Arc::new(AtomicBool::new(false));
422
423 let fills_dedup = new_dedup_cache();
427
428 let reconnected = Arc::new(Notify::new());
432 let checks_cancel = cancel_token.child_token();
433 let (list_client, list_coins) = (self.info_client.clone(), self.spot_coins.clone());
434 let (lookup_client, lookup_coins) = (self.info_client.clone(), self.spot_coins.clone());
435 spawn_order_checks(
436 ExchangeId::HyperliquidSpot,
437 self.known_live.clone(),
438 reconnected.clone(),
439 checks_cancel.clone(),
440 event_tx.clone(),
441 move |instruments| {
442 spot_listed_cids(list_client.clone(), list_coins.clone(), user, instruments)
443 },
444 move |key| spot_order_lookup(lookup_client.clone(), lookup_coins.clone(), user, key),
445 );
446
447 let fills_event_tx = event_tx.clone();
449 let fills_cancel = cancel_token.clone();
450 let fills_terminated = terminated.clone();
451 let fills_coins = self.spot_coins.clone();
452 let fills_checks_cancel = checks_cancel.clone();
453 tokio::spawn(async move {
454 let mut missing_coins = HashSet::new();
456 let mut reconnects = ReconnectWatch::new(reconnected);
457 loop {
458 tokio::select! {
459 biased;
460 () = fills_cancel.cancelled() => {
461 debug!("Spot fills task cancelled");
462 return;
463 }
464 msg = fills_rx.recv() => {
465 let Some(msg) = msg else {
466 debug!("Spot fills receiver closed");
467 fills_checks_cancel.cancel();
468 if fills_terminated
471 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
472 .is_ok()
473 {
474 emit_stream_terminated(
475 &fills_event_tx,
476 ExchangeId::HyperliquidSpot,
477 StreamTerminationReason::Error(
478 "hyperliquid spot account stream closed".to_string(),
479 ),
480 );
481 }
482 return;
483 };
484 match msg {
485 Message::UserFills(fills) => {
486 let pairs = fills_coins
487 .covering(fills.data.fills.iter().map(|fill| fill.coin.as_str()))
488 .await;
489 let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
490 if CoinKind::of(&fill.coin) != CoinKind::Spot {
492 return None;
493 }
494 let Some(pair) = pairs.get(&fill.coin) else {
495 if missing_coins.insert(fill.coin.clone()) {
496 error!(
497 coin = %fill.coin,
498 tid = fill.tid,
499 "Hyperliquid spot fills on a coin not in \
500 spotMeta are left out (fetch_trades recovers \
501 them); logged once per stream"
502 );
503 }
504 return None;
505 };
506 fill_to_account_event(fill, pair)
507 };
508 if !send_fills(
509 &fills.data,
510 convert,
511 &fills_dedup,
512 &fills_event_tx,
513 &mut reconnects,
514 ) {
515 debug!("Spot fills event channel closed");
516 return;
517 }
518 }
519 Message::NoData => {
520 warn!("Spot UserFills WebSocket disconnected");
521 reconnects.dropped();
522 }
523 Message::HyperliquidError(e) => {
524 error!(%e, "Spot UserFills WebSocket error");
527 }
528 _ => {}
529 }
530 }
531 }
532 }
533 });
534
535 let orders_event_tx = event_tx;
540 let orders_cancel = cancel_token.clone();
541 let orders_terminated = terminated;
542 let orders_coins = self.spot_coins.clone();
543 let orders_known = self.known_live.clone();
544 tokio::spawn(async move {
545 let _ws_client = ws_client;
546 let mut missing_coins = HashSet::new();
548
549 loop {
550 tokio::select! {
551 biased;
552 () = orders_cancel.cancelled() => {
553 debug!("Spot orders task cancelled");
554 return;
555 }
556 msg = orders_rx.recv() => {
557 let Some(msg) = msg else {
558 debug!("Spot orders receiver closed");
559 checks_cancel.cancel();
560 if orders_terminated
563 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
564 .is_ok()
565 {
566 emit_stream_terminated(
567 &orders_event_tx,
568 ExchangeId::HyperliquidSpot,
569 StreamTerminationReason::Error(
570 "hyperliquid spot account stream closed".to_string(),
571 ),
572 );
573 }
574 return;
575 };
576 match msg {
577 Message::OrderUpdates(updates) => {
578 let pairs = orders_coins
579 .covering(updates.data.iter().map(|update| update.order.coin.as_str()))
580 .await;
581 for update in updates.data {
582 let coin = update.order.coin.as_str();
584 if CoinKind::of(coin) != CoinKind::Spot {
585 continue;
586 }
587 let Some(pair) = pairs.get(coin) else {
588 if missing_coins.insert(coin.to_owned()) {
589 warn!(
590 %coin,
591 oid = update.order.oid,
592 "Hyperliquid spot order updates on a coin not in \
593 spotMeta are left out; logged once per stream"
594 );
595 }
596 continue;
597 };
598 if let Some(event) = order_update_to_account_event(&update, pair)
599 && !send_observed(&orders_known, &orders_event_tx, event)
600 {
601 debug!("Spot orders event channel closed");
602 return;
603 }
604 }
605 }
606 Message::NoData => {
607 warn!("Spot OrderUpdates WebSocket disconnected");
608 }
609 Message::HyperliquidError(e) => {
610 error!(%e, "Spot OrderUpdates WebSocket error");
613 }
614 _ => {}
615 }
616 }
617 }
618 }
619 });
620
621 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
622 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
623 Ok(guarded_stream.boxed())
624 }
625
626 async fn cancel_order(
627 &self,
628 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
629 ) -> UnindexedOrderResponseCancel {
630 use crate::order::{request::OrderResponseCancel, state::Cancelled};
631 use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
632 use uuid::Uuid;
633
634 let coin = match instrument_to_spot_coin(request.key.instrument) {
638 Some(c) => c,
639 None => {
640 warn!(
641 instrument = %request.key.instrument,
642 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
643 );
644 return OrderResponseCancel {
645 key: OrderKey {
646 exchange: ExchangeId::HyperliquidSpot,
647 instrument: request.key.instrument.clone(),
648 strategy: request.key.strategy.clone(),
649 cid: request.key.cid.clone(),
650 },
651 state: Err(UnindexedOrderError::Rejected(
652 crate::error::ApiError::OrderRejected(format!(
653 "Invalid instrument format: {}",
654 request.key.instrument
655 )),
656 )),
657 };
658 }
659 };
660
661 enum CancelMethod {
662 ByOid(u64),
663 ByCloid(Uuid),
664 }
665
666 let cancel_method = match &request.state.id {
671 Some(VenueOrderId::Assigned(id)) => {
672 id.0.parse::<u64>()
673 .map(CancelMethod::ByOid)
674 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
675 }
676 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
680 .map(CancelMethod::ByCloid)
681 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
682 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
683 };
684
685 let cancel_method = match cancel_method {
686 Ok(method) => method,
687 Err(reason) => {
688 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
689 return OrderResponseCancel {
690 key: OrderKey {
691 exchange: ExchangeId::HyperliquidSpot,
692 instrument: request.key.instrument.clone(),
693 strategy: request.key.strategy.clone(),
694 cid: request.key.cid.clone(),
695 },
696 state: Err(UnindexedOrderError::Rejected(
697 crate::error::ApiError::OrderRejected(reason.to_string()),
698 )),
699 };
700 }
701 };
702
703 let cancelled_id = match &request.state.id {
706 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
707 None => OrderId(request.key.cid.0.clone()),
708 };
709
710 let response = match cancel_method {
711 CancelMethod::ByOid(oid) => {
712 debug!(oid, "Cancelling spot order by OID");
713 let cancel_request = ClientCancelRequest { asset: coin, oid };
714 self.exchange_client.cancel(cancel_request, None).await
715 }
716 CancelMethod::ByCloid(cloid) => {
717 debug!(%cloid, "Cancelling spot order by cloid (no oid assigned)");
718 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
719 self.exchange_client
720 .cancel_by_cloid(cancel_request, None)
721 .await
722 }
723 };
724
725 let response = match response {
726 Ok(r) => r,
727 Err(e) => {
728 warn!(%e, "Spot cancel order failed (transport)");
729 return OrderResponseCancel {
730 key: OrderKey {
731 exchange: ExchangeId::HyperliquidSpot,
732 instrument: request.key.instrument.clone(),
733 strategy: request.key.strategy.clone(),
734 cid: request.key.cid.clone(),
735 },
736 state: Err(map_order_error(e, request.key.instrument)),
737 };
738 }
739 };
740
741 match cancel_outcome(response) {
742 Ok(()) => {
743 debug!("Spot cancel order accepted");
744 self.known_live.lock().ended(&request.key.cid);
746 OrderResponseCancel {
747 key: OrderKey {
748 exchange: ExchangeId::HyperliquidSpot,
749 instrument: request.key.instrument.clone(),
750 strategy: request.key.strategy.clone(),
751 cid: request.key.cid.clone(),
752 },
753 state: Ok(Cancelled::new(
754 cancelled_id.clone(),
755 Utc::now(),
757 None,
759 )),
760 }
761 }
762 Err(reason) => {
764 warn!(%reason, cid = %request.key.cid, "Spot cancel rejected by exchange");
765 OrderResponseCancel {
766 key: OrderKey {
767 exchange: ExchangeId::HyperliquidSpot,
768 instrument: request.key.instrument.clone(),
769 strategy: request.key.strategy.clone(),
770 cid: request.key.cid.clone(),
771 },
772 state: Err(UnindexedOrderError::Rejected(
773 crate::error::ApiError::OrderRejected(reason),
774 )),
775 }
776 }
777 }
778 }
779
780 async fn open_order(
781 &self,
782 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
783 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
784 use hyperliquid_rust_sdk::{
785 ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
786 };
787
788 let make_inactive =
789 |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
790 Order {
791 key: OrderKey {
792 exchange: ExchangeId::HyperliquidSpot,
793 instrument: request.key.instrument.clone(),
794 strategy: request.key.strategy.clone(),
795 cid: request.key.cid.clone(),
796 },
797 side: request.state.side,
798 price: request.state.price,
799 quantity: request.state.quantity,
800 kind: request.state.kind,
801 time_in_force: request.state.time_in_force,
802 state: OrderState::inactive(error),
803 }
804 };
805 let make_rejected = |msg: String| {
806 make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
807 msg,
808 )))
809 };
810 let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
811
812 let Some((base, quote)) = spot_base_quote(request.key.instrument) else {
813 warn!(
814 instrument = %request.key.instrument,
815 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
816 );
817 return make_rejected(format!(
818 "Invalid instrument format: {}",
819 request.key.instrument
820 ));
821 };
822 let is_buy = request.state.side == Side::Buy;
823
824 match request.state.kind {
825 OrderKind::Market => {
826 return make_unsupported(
827 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
828 .to_string(),
829 );
830 }
831 OrderKind::TrailingStop { .. }
832 | OrderKind::TrailingStopLimit { .. }
833 | OrderKind::TrailingStopFixedLimit { .. } => {
834 return make_unsupported(
835 "Hyperliquid does not support trailing stop orders".to_string(),
836 );
837 }
838 OrderKind::Limit
839 | OrderKind::Stop { .. }
840 | OrderKind::StopLimit { .. }
841 | OrderKind::TakeProfit { .. }
842 | OrderKind::TakeProfitLimit { .. } => {}
843 }
844
845 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
848 return make_rejected(CLOID_REQUIRED.to_string());
849 };
850
851 let tif = map_tif(&request.state.time_in_force).to_string();
852
853 let pair = match self.traded_pair(base, quote).await {
854 Ok(pair) => pair,
855 Err(untraded) => {
856 let reason = match untraded {
857 UntradedPair::Unlisted => {
858 format!("Hyperliquid's spotMeta lists no spot pair {base}/{quote}")
859 }
860 UntradedPair::Misspelled(pair) => format!(
861 "Hyperliquid spells this spot pair {}/{}, and reports its orders under \
862 {}; name the instrument that way",
863 pair.base(),
864 pair.quote(),
865 spot_pair_to_instrument(&pair)
866 ),
867 };
868 warn!(instrument = %request.key.instrument, %reason, "Spot order refused unsent");
869 return make_inactive(OrderError::Rejected(
870 crate::error::ApiError::InstrumentInvalid(
871 request.key.instrument.clone(),
872 reason,
873 ),
874 ));
875 }
876 };
877 let precision = OrderPrecision::spot(pair.base_sz_decimals());
878 let coin = format!("{}/{}", pair.base(), pair.quote());
881 let wire = match wire_order(
882 &precision,
883 request.state.kind,
884 tif,
885 request.state.price,
886 request.state.quantity,
887 ) {
888 Ok(wire) => wire,
889 Err(WireOrderError::Precision(violation)) => {
890 return make_inactive(OrderError::InvalidPrecision(violation));
891 }
892 Err(WireOrderError::MissingPrice) => {
893 return make_rejected(
894 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
895 .to_string(),
896 );
897 }
898 Err(WireOrderError::UnsupportedKind) => {
899 return make_unsupported(format!(
900 "Hyperliquid does not support {} orders",
901 request.state.kind
902 ));
903 }
904 };
905
906 let notional_price = match request.state.kind {
909 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
910 trigger_price
911 }
912 _ => request.state.price.unwrap_or(Decimal::ZERO),
913 };
914 let notional = notional_price * request.state.quantity;
915 if notional < Decimal::TEN {
916 warn!(
917 instrument = %request.key.instrument,
918 %notional,
919 "Spot order below $10 minimum notional value"
920 );
921 return make_rejected(format!("Spot order notional ${notional} below $10 minimum"));
922 }
923
924 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
926 warn!(
927 instrument = %request.key.instrument,
928 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
929 );
930 }
931
932 let order_request = ClientOrderRequest {
933 asset: coin,
934 is_buy,
935 reduce_only: request.state.reduce_only,
936 limit_px: wire.limit_px,
937 sz: wire.sz,
938 cloid: Some(cloid),
939 order_type: wire.order_type,
940 };
941
942 let response = match self.exchange_client.order(order_request, None).await {
943 Ok(r) => r,
944 Err(e) => {
945 warn!(%e, "Spot open order failed");
946 return Order {
947 key: OrderKey {
948 exchange: ExchangeId::HyperliquidSpot,
949 instrument: request.key.instrument.clone(),
950 strategy: request.key.strategy.clone(),
951 cid: request.key.cid.clone(),
952 },
953 side: request.state.side,
954 price: request.state.price,
955 quantity: request.state.quantity,
956 kind: request.state.kind,
957 time_in_force: request.state.time_in_force,
958 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
959 };
960 }
961 };
962
963 let state = match response {
964 ExchangeResponseStatus::Ok(exchange_resp) => {
965 let status = exchange_resp
966 .data
967 .and_then(|d| d.statuses.into_iter().next());
968
969 match status {
970 Some(ExchangeDataStatus::Resting(resting)) => {
971 debug!(oid = resting.oid, "Spot order resting");
972 OrderState::active(Open {
973 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
974 time_exchange: Utc::now(),
976 filled_quantity: Decimal::ZERO,
977 })
978 }
979 Some(ExchangeDataStatus::Filled(filled)) => {
980 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
981 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
982 OrderState::fully_filled(Filled::new(
983 OrderId(format_smolstr!("{}", filled.oid)),
984 Utc::now(),
986 parse_decimal(&filled.total_sz, "total_sz")
987 .unwrap_or(request.state.quantity),
988 avg_price,
989 ))
990 }
991 Some(ExchangeDataStatus::Error(msg)) => {
992 warn!(%msg, "Spot order rejected by exchange");
993 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
994 }
995 Some(
996 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
997 ) => {
998 debug!(cid = %request.key.cid, "Spot order waiting under its cloid");
1002 OrderState::active(Open {
1003 id: VenueOrderId::ClientAssigned,
1004 time_exchange: Utc::now(),
1005 filled_quantity: Decimal::ZERO,
1006 })
1007 }
1008 Some(ExchangeDataStatus::Success) | None => {
1009 warn!("Spot order accepted but no order ID returned");
1010 OrderState::inactive(OrderError::Rejected(
1011 crate::error::ApiError::OrderRejected(
1012 "no order ID in response".to_string(),
1013 ),
1014 ))
1015 }
1016 }
1017 }
1018 ExchangeResponseStatus::Err(msg) => {
1019 warn!(%msg, "Spot order rejected");
1020 OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1021 }
1022 };
1023
1024 let order = Order {
1025 key: OrderKey {
1026 exchange: ExchangeId::HyperliquidSpot,
1027 instrument: request.key.instrument.clone(),
1028 strategy: request.key.strategy.clone(),
1029 cid: request.key.cid.clone(),
1030 },
1031 side: request.state.side,
1032 price: request.state.price,
1033 quantity: request.state.quantity,
1034 kind: request.state.kind,
1035 time_in_force: request.state.time_in_force,
1036 state,
1037 };
1038 self.known_live
1039 .lock()
1040 .placed(&order.key, order.quantity, order.kind, &order.state);
1041 order
1042 }
1043
1044 async fn fetch_balances(
1045 &self,
1046 _assets: &[AssetNameExchange],
1047 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1048 let address = self.wallet_h160();
1049
1050 let token_balances = self
1051 .info_client
1052 .user_token_balances(address)
1053 .await
1054 .map_err(map_sdk_error)?;
1055
1056 let now = Utc::now();
1057 Ok(spot_balances(&token_balances.balances, now).collect())
1058 }
1059
1060 async fn fetch_open_orders(
1061 &self,
1062 instruments: &[InstrumentNameExchange],
1063 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1064 let address = self.wallet_h160();
1065
1066 let open_orders = open_orders(&self.info_client, address).await?;
1067 let pairs = self
1068 .spot_coins
1069 .covering(open_orders.iter().map(|order| order.coin.as_str()))
1070 .await;
1071
1072 let orders = spot_open_orders(&open_orders, &pairs, instruments)?;
1073 remember_open(&self.known_live, &orders);
1074 Ok(orders)
1075 }
1076
1077 async fn fetch_trades(
1081 &self,
1082 start: DateTime<Utc>,
1083 end: DateTime<Utc>,
1084 instruments: &[InstrumentNameExchange],
1085 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1086 let Some((start_ms, end_ms)) = span_millis(start, end) else {
1087 return Ok(TradesRead::complete(Vec::new()));
1088 };
1089 let address = self.wallet_h160();
1090
1091 let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1092 let pairs = self
1093 .spot_coins
1094 .covering(fills.iter().map(|fill| fill.coin.as_str()))
1095 .await;
1096
1097 spot_trades(&fills, &pairs, start, end, instruments).map(TradesRead::complete)
1098 }
1099}
1100
1101impl OrderStatusClient for HyperliquidSpotClient {
1110 async fn fetch_ended_orders(
1111 &self,
1112 orders: &[UnindexedOrderKey],
1113 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1114 let address = self.wallet_h160();
1115 fetch_ended_by_key(orders, |key| {
1116 spot_order_lookup(
1117 self.info_client.clone(),
1118 self.spot_coins.clone(),
1119 address,
1120 key,
1121 )
1122 })
1123 .await
1124 }
1125}
1126
1127async fn spot_order_lookup(
1133 info_client: Arc<InfoClient>,
1134 spot_coins: SpotCoins,
1135 address: ethers::types::H160,
1136 key: UnindexedOrderKey,
1137) -> Result<OrderLookup, UnindexedClientError> {
1138 let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1139 return Ok(OrderLookup::Unknown);
1140 };
1141 let coin = record.order.coin.as_str();
1142 let pairs = spot_coins.covering([coin]).await;
1143 let instrument = spot_pair(&pairs, coin)?.map(spot_pair_to_instrument);
1144 Ok(lookup_from_record(key, &record, instrument))
1145}
1146
1147async fn spot_listed_cids(
1155 info_client: Arc<InfoClient>,
1156 spot_coins: SpotCoins,
1157 address: ethers::types::H160,
1158 instruments: Vec<InstrumentNameExchange>,
1159) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1160 let rows = open_orders(&info_client, address).await?;
1161 let pairs = spot_coins
1162 .covering(rows.iter().map(|row| row.coin.as_str()))
1163 .await;
1164 Ok(listed_cids(&rows, &instruments, |coin| {
1165 pairs.get(coin).map(spot_pair_to_instrument)
1166 }))
1167}
1168
1169fn spot_open_orders(
1176 rows: &[OpenOrder],
1177 pairs: &SpotPairs,
1178 instruments: &[InstrumentNameExchange],
1179) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1180 let instrument_filter = InstrumentFilter::new(instruments);
1181
1182 let mut orders = Vec::new();
1183 for order in rows {
1184 let Some(pair) = spot_pair(pairs, &order.coin)? else {
1185 continue;
1186 };
1187 let instrument = spot_pair_to_instrument(pair);
1188 if !instrument_filter.matches(&instrument) {
1189 continue;
1190 }
1191 orders.extend(open_order_to_order(
1192 order,
1193 ExchangeId::HyperliquidSpot,
1194 instrument,
1195 ));
1196 }
1197 Ok(orders)
1198}
1199
1200fn spot_trades(
1207 fills: &[UserFill],
1208 pairs: &SpotPairs,
1209 start: DateTime<Utc>,
1210 end: DateTime<Utc>,
1211 instruments: &[InstrumentNameExchange],
1212) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1213 let instrument_filter = InstrumentFilter::new(instruments);
1214
1215 let mut result = Vec::new();
1216 for fill in fills {
1217 let Some(pair) = spot_pair(pairs, &fill.coin)? else {
1218 continue;
1219 };
1220 let (base_asset, quote_asset) = (pair.base(), pair.quote());
1221 let instrument = spot_pair_to_instrument(pair);
1222
1223 if !instrument_filter.matches(&instrument) {
1224 continue;
1225 }
1226
1227 let Some(side) = parse_side(&fill.side) else {
1228 continue;
1229 };
1230 let Some(price) = parse_decimal(&fill.px, "px") else {
1231 continue;
1232 };
1233 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1234 continue;
1235 };
1236 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1237
1238 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1239 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1240 continue;
1241 };
1242 if !(start..=end).contains(&time_exchange) {
1243 continue;
1244 }
1245
1246 let fee_asset = fill
1251 .fee_token
1252 .as_deref()
1253 .unwrap_or(if matches!(side, Side::Buy) {
1254 base_asset
1255 } else {
1256 quote_asset
1257 });
1258
1259 result.push(Trade {
1260 id: TradeId(format_smolstr!("{}", fill.tid)),
1261 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1262 instrument,
1263 strategy: StrategyId::unknown(),
1264 time_exchange,
1265 side,
1266 price,
1267 quantity,
1268 order_filled_quantity: None,
1271 fees: AssetFees {
1272 asset: AssetNameExchange::from(fee_asset),
1273 fees: fee,
1274 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1281 Some(fee)
1282 } else {
1283 None
1284 },
1285 },
1286 origin: TradeOrigin::Order,
1288 position_side: None,
1289 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1290 });
1291 }
1292
1293 Ok(result)
1294}
1295
1296fn fill_to_account_event(
1298 fill: &hyperliquid_rust_sdk::TradeInfo,
1299 pair: &SpotPair,
1300) -> Option<UnindexedAccountEvent> {
1301 let side = parse_side(&fill.side)?;
1302 let price = parse_decimal(&fill.px, "fill.px")?;
1303 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1304 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1305 let time_exchange = millis_to_datetime(fill.time)?;
1306
1307 let quote_asset = pair.quote();
1308 let instrument = spot_pair_to_instrument(pair);
1309 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1310
1311 let fee_asset = fill.fee_token.as_str();
1314
1315 let trade = Trade {
1316 id: TradeId(format_smolstr!("{}", fill.tid)),
1317 order_id,
1318 instrument,
1319 strategy: StrategyId::unknown(),
1320 time_exchange,
1321 side,
1322 price,
1323 quantity,
1324 order_filled_quantity: None,
1327 fees: AssetFees {
1328 asset: AssetNameExchange::from(fee_asset),
1329 fees: fee,
1330 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1333 Some(fee)
1334 } else {
1335 None
1336 },
1337 },
1338 origin: TradeOrigin::Order,
1341 position_side: None,
1342 client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1343 };
1344
1345 Some(AccountEvent::new(
1346 ExchangeId::HyperliquidSpot,
1347 AccountEventKind::Trade(trade),
1348 ))
1349}
1350
1351fn order_update_to_account_event(
1353 update: &hyperliquid_rust_sdk::OrderUpdate,
1354 pair: &SpotPair,
1355) -> Option<UnindexedAccountEvent> {
1356 super::common::order_update_to_account_event(
1357 update,
1358 ExchangeId::HyperliquidSpot,
1359 spot_pair_to_instrument(pair),
1360 )
1361}
1362
1363#[cfg(test)]
1364#[allow(clippy::unwrap_used)]
1366mod tests {
1367 use super::super::spot_coins::test_spot_pairs;
1368 use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1369
1370 mod order_recovery {
1371 use super::super::super::common::info_tests::info_client_against;
1372 use super::super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1373 use super::super::super::spot_coins::TEST_SPOT_META;
1374 use super::*;
1375 use crate::order::state::InactiveOrderState;
1376 use wiremock::matchers::{body_string_contains, method, path};
1377 use wiremock::{Mock, MockServer, ResponseTemplate};
1378
1379 async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>, SpotCoins) {
1382 let server = MockServer::start().await;
1383 Mock::given(method("POST"))
1384 .and(path("/info"))
1385 .and(body_string_contains("spotMeta"))
1386 .respond_with(
1387 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1388 )
1389 .with_priority(1)
1390 .mount(&server)
1391 .await;
1392 Mock::given(method("POST"))
1393 .and(path("/info"))
1394 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1395 .with_priority(2)
1396 .mount(&server)
1397 .await;
1398 let client = Arc::new(info_client_against(server.uri()).await);
1399 let coins = SpotCoins::fetch(client.clone()).await.unwrap();
1400 (server, client, coins)
1401 }
1402
1403 fn key() -> UnindexedOrderKey {
1404 OrderKey {
1405 exchange: ExchangeId::HyperliquidSpot,
1406 instrument: InstrumentNameExchange::new("HYPE-USDC-SPOT"),
1407 strategy: StrategyId::new("strategy"),
1408 cid: ClientOrderId::new(CID),
1409 }
1410 }
1411
1412 fn order_status(coin: &str) -> serde_json::Value {
1413 serde_json::json!({
1414 "status": "order",
1415 "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1416 })
1417 }
1418
1419 #[tokio::test]
1420 async fn a_spot_lookup_names_the_pair_from_spot_meta() {
1421 let (_server, client, coins) = serve(order_status("@107")).await;
1422
1423 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1424 .await
1425 .unwrap();
1426
1427 let OrderLookup::Ended(order) = lookup else {
1428 panic!("expected an ended order, got {lookup:?}");
1429 };
1430 assert_eq!(order.key, key());
1431 assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1432 }
1433
1434 #[tokio::test]
1435 async fn a_spot_lookup_of_a_perp_order_is_unknown() {
1436 let (_server, client, coins) = serve(order_status("BTC")).await;
1437
1438 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1439 .await
1440 .unwrap();
1441
1442 assert!(matches!(lookup, OrderLookup::Unknown));
1443 }
1444
1445 #[tokio::test]
1446 async fn a_spot_lookup_fails_on_a_coin_missing_from_spot_meta() {
1447 let (_server, client, coins) = serve(order_status("@999")).await;
1448
1449 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key()).await;
1450
1451 assert!(lookup.is_err());
1452 }
1453
1454 #[tokio::test]
1455 async fn a_spot_listing_names_only_the_orders_on_the_pairs_asked_about() {
1456 let rows = serde_json::json!([
1457 row("@107", 1, Some(CLOID)),
1458 row("@207", 2, None),
1459 row("BTC", 3, None),
1460 ]);
1461 let (_server, client, coins) = serve(rows).await;
1462
1463 let listed = spot_listed_cids(
1464 client,
1465 coins,
1466 ethers::types::H160::zero(),
1467 vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1468 )
1469 .await
1470 .unwrap();
1471
1472 assert_eq!(
1473 listed.into_iter().collect::<Vec<_>>(),
1474 [ClientOrderId::new(CID)]
1475 );
1476 }
1477
1478 #[tokio::test]
1479 async fn a_spot_listing_leaves_out_a_coin_missing_from_spot_meta() {
1480 let rows = serde_json::json!([row("@999", 1, None), row("@107", 2, Some(CLOID))]);
1481 let (_server, client, coins) = serve(rows).await;
1482
1483 let listed = spot_listed_cids(
1484 client,
1485 coins,
1486 ethers::types::H160::zero(),
1487 vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1488 )
1489 .await
1490 .unwrap();
1491
1492 assert_eq!(
1493 listed.into_iter().collect::<Vec<_>>(),
1494 [ClientOrderId::new(CID)]
1495 );
1496 }
1497 }
1498
1499 mod open_order {
1500 use super::super::super::common::info_tests::{
1501 exchange_client_against, info_client_against, test_wallet,
1502 };
1503 use super::super::super::config::Network;
1504 use super::super::super::spot_coins::TEST_SPOT_META;
1505 use super::*;
1506 use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1507 use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1508 use std::collections::HashMap;
1509 use wiremock::matchers::{method, path};
1510 use wiremock::{Mock, MockServer, ResponseTemplate};
1511
1512 async fn resting_client() -> (MockServer, HyperliquidSpotClient) {
1514 client_responding(serde_json::json!({
1515 "status": "ok",
1516 "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1517 }))
1518 .await
1519 }
1520
1521 async fn client_responding(body: serde_json::Value) -> (MockServer, HyperliquidSpotClient) {
1524 let server = MockServer::start().await;
1525 Mock::given(method("POST"))
1526 .and(path("/info"))
1527 .respond_with(
1528 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1529 )
1530 .mount(&server)
1531 .await;
1532 Mock::given(method("POST"))
1533 .and(path("/exchange"))
1534 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1535 .mount(&server)
1536 .await;
1537 let info_client = Arc::new(info_client_against(server.uri()).await);
1538 let client = HyperliquidSpotClient {
1539 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1540 spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1541 info_client,
1542 exchange_client: Arc::new(
1543 exchange_client_against(
1544 server.uri(),
1545 HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1546 )
1547 .await,
1548 ),
1549 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1550 };
1551 (server, client)
1552 }
1553
1554 #[tokio::test]
1558 async fn a_spot_order_refused_for_balance_is_balance_insufficient() {
1559 const BALANCE: &str = "Order has insufficient spot balance to trade";
1560 const MINIMUM: &str = "Order must have minimum value of 10 USDC.";
1561 for (body, expected) in [
1562 (
1563 serde_json::json!({
1564 "status": "ok",
1565 "response": {"type": "order", "data": {"statuses": [{"error": BALANCE}]}},
1566 }),
1567 ApiError::BalanceInsufficient(None, BALANCE.to_owned()),
1568 ),
1569 (
1570 serde_json::json!({"status": "err", "response": BALANCE}),
1571 ApiError::BalanceInsufficient(None, BALANCE.to_owned()),
1572 ),
1573 (
1574 serde_json::json!({
1575 "status": "ok",
1576 "response": {"type": "order", "data": {"statuses": [{"error": MINIMUM}]}},
1577 }),
1578 ApiError::OrderRejected(MINIMUM.to_owned()),
1579 ),
1580 ] {
1581 let (_server, client) = client_responding(body.clone()).await;
1582 let order = place_hype(&client, dec!(10), dec!(1)).await;
1583 let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1584 reported,
1585 ))) = &order.state
1586 else {
1587 panic!("{body}: expected a rejection, got {:?}", order.state);
1588 };
1589 assert_eq!(reported, &expected, "{body}");
1590 }
1591 }
1592
1593 async fn place_hype(
1594 client: &HyperliquidSpotClient,
1595 price: Decimal,
1596 quantity: Decimal,
1597 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1598 place(client, "HYPE-USDC-SPOT", price, quantity).await
1599 }
1600
1601 async fn place(
1602 client: &HyperliquidSpotClient,
1603 instrument: &str,
1604 price: Decimal,
1605 quantity: Decimal,
1606 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1607 let instrument = InstrumentNameExchange::new(instrument);
1608 client
1609 .open_order(OrderEvent {
1610 key: OrderKey {
1611 exchange: ExchangeId::HyperliquidSpot,
1612 instrument: &instrument,
1613 strategy: StrategyId::new("strategy"),
1614 cid: ClientOrderId::uuid(),
1615 },
1616 state: RequestOpen {
1617 side: Side::Buy,
1618 price: Some(price),
1619 quantity,
1620 kind: OrderKind::Limit,
1621 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1622 position_id: None,
1623 reduce_only: false,
1624 market: None,
1625 },
1626 })
1627 .await
1628 }
1629
1630 async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
1632 server
1633 .received_requests()
1634 .await
1635 .unwrap_or_default()
1636 .into_iter()
1637 .filter(|request| request.url.path() == "/exchange")
1638 .map(|request| {
1639 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1640 body["action"]["orders"][0].clone()
1641 })
1642 .collect()
1643 }
1644
1645 #[tokio::test]
1646 async fn a_spot_pairs_precision_is_its_base_tokens() {
1647 let (_server, client) = resting_client().await;
1648
1649 assert_eq!(
1650 client
1651 .order_precision(&InstrumentNameExchange::new("HYPE-USDC-SPOT"))
1652 .await,
1653 Some(OrderPrecision::spot(2))
1654 );
1655 assert_eq!(
1656 client
1657 .order_precision(&InstrumentNameExchange::new("PURR-USDC-SPOT"))
1658 .await,
1659 Some(OrderPrecision::spot(0))
1660 );
1661 assert_eq!(
1662 client
1663 .order_precision(&InstrumentNameExchange::new("BTC-USDC-PERP"))
1664 .await,
1665 None
1666 );
1667 }
1668
1669 #[tokio::test]
1670 async fn a_spot_order_spelling_the_pair_differently_is_refused_unsent() {
1671 let (server, client) = resting_client().await;
1672
1673 for instrument in ["hype-usdc-SPOT", "Hype-USDC-SPOT"] {
1675 let order = place(&client, instrument, dec!(25), dec!(1)).await;
1676 assert_eq!(
1677 order.state,
1678 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1679 ApiError::InstrumentInvalid(
1680 InstrumentNameExchange::new(instrument),
1681 "Hyperliquid spells this spot pair HYPE/USDC, and reports its orders \
1682 under HYPE-USDC-SPOT; name the instrument that way"
1683 .to_owned(),
1684 )
1685 )))
1686 );
1687 assert_eq!(
1688 client
1689 .order_precision(&InstrumentNameExchange::new(instrument))
1690 .await,
1691 None
1692 );
1693 }
1694 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1695 }
1696
1697 #[tokio::test]
1698 async fn a_spot_order_on_an_unlisted_pair_is_refused_unsent() {
1699 let (server, client) = resting_client().await;
1700
1701 let order = place(&client, "HYPE-PURR-SPOT", dec!(25), dec!(1)).await;
1702 assert_eq!(
1703 order.state,
1704 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1705 ApiError::InstrumentInvalid(
1706 InstrumentNameExchange::new("HYPE-PURR-SPOT"),
1707 "Hyperliquid's spotMeta lists no spot pair HYPE/PURR".to_owned(),
1708 )
1709 )))
1710 );
1711 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1712 }
1713
1714 #[tokio::test]
1715 async fn a_spot_order_breaking_the_precision_rules_is_refused_unsent() {
1716 let (server, client) = resting_client().await;
1717
1718 for (price, quantity, violation) in [
1720 (
1721 dec!(25),
1722 dec!(1.234),
1723 PrecisionViolation {
1724 field: OrderField::Quantity,
1725 value: dec!(1.234),
1726 limit: PrecisionLimit::DecimalPlaces { max: 2 },
1727 },
1728 ),
1729 (
1730 dec!(25.1234567),
1731 dec!(1),
1732 PrecisionViolation {
1733 field: OrderField::Price,
1734 value: dec!(25.1234567),
1735 limit: PrecisionLimit::DecimalPlaces { max: 6 },
1736 },
1737 ),
1738 ] {
1739 let order = place_hype(&client, price, quantity).await;
1740 assert_eq!(
1741 order.state,
1742 OrderState::Inactive(InactiveOrderState::OpenFailed(
1743 OrderError::InvalidPrecision(violation)
1744 ))
1745 );
1746 }
1747 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1748 }
1749
1750 #[tokio::test]
1751 async fn a_valid_spot_order_is_sent_unchanged() {
1752 let (server, client) = resting_client().await;
1753
1754 let order = place_hype(&client, dec!(25.123), dec!(1.25)).await;
1755 assert!(
1756 matches!(order.state, OrderState::Active(_)),
1757 "{:?}",
1758 order.state
1759 );
1760
1761 let sent = sent_orders(&server).await;
1762 assert_eq!(sent.len(), 1);
1763 assert_eq!(
1764 (&sent[0]["p"], &sent[0]["s"]),
1765 (&serde_json::json!("25.123"), &serde_json::json!("1.25"))
1766 );
1767 }
1768 }
1769
1770 mod cancel {
1771 use super::super::super::common::info_tests::{
1772 CANCEL_NOT_APPLIED, cancel_answer, exchange_client_against, info_client_against,
1773 test_wallet,
1774 };
1775 use super::super::super::config::Network;
1776 use super::super::super::spot_coins::TEST_SPOT_META;
1777 use super::*;
1778 use crate::error::ApiError;
1779 use crate::order::{OrderEvent, request::RequestCancel};
1780 use std::collections::HashMap;
1781 use wiremock::matchers::{method, path};
1782 use wiremock::{Mock, MockServer, ResponseTemplate};
1783
1784 async fn cancel_answered(
1787 answer: serde_json::Value,
1788 ) -> (UnindexedOrderResponseCancel, bool) {
1789 let server = MockServer::start().await;
1790 Mock::given(method("POST"))
1791 .and(path("/info"))
1792 .respond_with(
1793 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1794 )
1795 .mount(&server)
1796 .await;
1797 Mock::given(method("POST"))
1798 .and(path("/exchange"))
1799 .respond_with(ResponseTemplate::new(200).set_body_json(answer))
1800 .mount(&server)
1801 .await;
1802 let info_client = Arc::new(info_client_against(server.uri()).await);
1803 let client = HyperliquidSpotClient {
1804 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1805 spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1806 info_client,
1807 exchange_client: Arc::new(
1808 exchange_client_against(
1809 server.uri(),
1810 HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1811 )
1812 .await,
1813 ),
1814 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1815 };
1816 let instrument = InstrumentNameExchange::new("HYPE-USDC-SPOT");
1817 let key = OrderKey {
1818 exchange: ExchangeId::HyperliquidSpot,
1819 instrument: instrument.clone(),
1820 strategy: StrategyId::new("strategy"),
1821 cid: ClientOrderId::uuid(),
1822 };
1823 let id = VenueOrderId::Assigned(OrderId::new("7"));
1824 let open = Open {
1825 id: id.clone(),
1826 time_exchange: Utc::now(),
1827 filled_quantity: Decimal::ZERO,
1828 };
1829 client
1830 .known_live
1831 .lock()
1832 .live(&key, dec!(1), OrderKind::Limit, &open);
1833
1834 let response = client
1835 .cancel_order(OrderEvent {
1836 key: OrderKey {
1837 exchange: key.exchange,
1838 instrument: &instrument,
1839 strategy: key.strategy.clone(),
1840 cid: key.cid.clone(),
1841 },
1842 state: RequestCancel { id: Some(id) },
1843 })
1844 .await;
1845 let live = client.known_live.lock().contains(&key.cid);
1846 (response, live)
1847 }
1848
1849 #[tokio::test]
1850 async fn a_success_status_cancels_the_spot_order() {
1851 let (response, live) =
1852 cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
1853 assert!(response.state.is_ok(), "{:?}", response.state);
1854 assert!(!live, "a cancelled order is no longer held live");
1855 }
1856
1857 #[tokio::test]
1860 async fn an_error_status_is_a_rejected_spot_cancel_and_the_order_stays_live() {
1861 let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
1862 {"error": CANCEL_NOT_APPLIED}
1863 ])))
1864 .await;
1865 let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
1866 &response.state
1867 else {
1868 panic!("expected a rejected cancel, got {:?}", response.state);
1869 };
1870 assert_eq!(reason, CANCEL_NOT_APPLIED);
1871 assert!(live, "the order is still held live");
1872 }
1873
1874 #[tokio::test]
1875 async fn an_answer_without_a_status_is_a_rejected_spot_cancel() {
1876 let (response, live) = cancel_answered(serde_json::json!({
1877 "status": "ok",
1878 "response": {"type": "cancel"},
1879 }))
1880 .await;
1881 assert!(
1882 matches!(
1883 response.state,
1884 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
1885 ),
1886 "{:?}",
1887 response.state
1888 );
1889 assert!(live, "the order is still held live");
1890 }
1891 }
1892
1893 use super::*;
1894 use rust_decimal_macros::dec;
1895 use rustrade_integration::collection::snapshot::Snapshot;
1896
1897 fn pair(coin: &str) -> SpotPair {
1899 test_spot_pairs().get(coin).unwrap().clone()
1900 }
1901
1902 #[test]
1903 fn test_fill_to_account_event_spot() {
1904 let fill_json = r#"{
1905 "coin": "PURR/USDC",
1906 "side": "B",
1907 "px": "0.05",
1908 "sz": "1000",
1909 "time": 1714100000000,
1910 "hash": "0xspot123",
1911 "startPosition": "0",
1912 "dir": "Open Long",
1913 "closedPnl": "0",
1914 "oid": 12345,
1915 "cloid": null,
1916 "crossed": false,
1917 "fee": "0.025",
1918 "feeToken": "USDC",
1919 "tid": 99999
1920 }"#;
1921
1922 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1923 let event = fill_to_account_event(&fill, &pair("PURR/USDC")).unwrap();
1924
1925 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1926 match event.kind {
1927 AccountEventKind::Trade(trade) => {
1928 assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1929 assert_eq!(
1930 trade.id.0, "99999",
1931 "the id is the fill's tid, not its hash"
1932 );
1933 assert_eq!(trade.side, Side::Buy);
1934 assert_eq!(trade.price, dec!(0.05));
1935 assert_eq!(trade.quantity, dec!(1000));
1936 assert_eq!(trade.fees.fees, dec!(0.025));
1937 assert_eq!(trade.fees.asset.as_ref(), "USDC");
1938 }
1939 _ => panic!("Expected Trade event"),
1940 }
1941 }
1942
1943 fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1945 let json = format!(
1946 r#"{{
1947 "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1948 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1949 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1950 "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1951 }}"#
1952 );
1953 serde_json::from_str(&json).unwrap()
1954 }
1955
1956 #[test]
1957 fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1958 let first =
1959 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1960 let second =
1961 fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1962
1963 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1964 (first.kind, second.kind)
1965 else {
1966 panic!("Expected Trade events");
1967 };
1968
1969 assert_eq!(first.order_id, second.order_id, "one order produced both");
1970 assert_ne!(first.id, second.id, "but they are distinct fills");
1971 }
1972
1973 #[test]
1974 fn a_replayed_spot_fill_is_delivered_once() {
1975 let cache = new_dedup_cache();
1976 let event =
1977 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1978 let replay =
1979 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1980
1981 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1982 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1983 let sibling =
1985 fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1986 assert!(!is_duplicate(
1987 &cache,
1988 dedup_key_from_event(&sibling).unwrap()
1989 ));
1990 }
1991
1992 #[test]
1993 fn test_order_update_to_account_event_spot() {
1994 let update_json = r#"{
1995 "order": {
1996 "coin": "@107",
1997 "side": "A",
1998 "limitPx": "25.5",
1999 "sz": "10",
2000 "oid": 12346,
2001 "timestamp": 1714100000000,
2002 "origSz": "10",
2003 "cloid": null
2004 },
2005 "status": "open",
2006 "statusTimestamp": 1714100000000
2007 }"#;
2008
2009 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2010 let event = order_update_to_account_event(&update, &pair("@107")).unwrap();
2011
2012 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
2013 match event.kind {
2014 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2015 assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
2016 assert_eq!(order.side, Side::Sell);
2017 assert_eq!(order.price, Some(dec!(25.5)));
2018 assert_eq!(order.quantity, dec!(10));
2019 assert!(matches!(
2020 order.state,
2021 crate::order::state::OrderState::Active(_)
2022 ));
2023 }
2024 _ => panic!("Expected OrderSnapshot event"),
2025 }
2026 }
2027 fn stream_fill(coin: &str, fee_token: &str) -> hyperliquid_rust_sdk::TradeInfo {
2029 let json = format!(
2030 r#"{{
2031 "coin": "{coin}", "side": "B", "px": "25.5", "sz": "2",
2032 "time": 1714100000000, "hash": "0xindexed", "startPosition": "0",
2033 "dir": "Buy", "closedPnl": "0", "oid": 4343, "cloid": null,
2034 "crossed": true, "fee": "0.01", "feeToken": "{fee_token}", "tid": 333
2035 }}"#
2036 );
2037 serde_json::from_str(&json).unwrap()
2038 }
2039
2040 #[test]
2041 fn a_fill_on_an_indexed_coin_is_named_from_its_tokens() {
2042 let event = fill_to_account_event(&stream_fill("@107", "HYPE"), &pair("@107")).unwrap();
2043 let AccountEventKind::Trade(trade) = event.kind else {
2044 panic!("Expected Trade event");
2045 };
2046 assert_eq!(trade.instrument.as_ref(), "HYPE-USDC-SPOT");
2047 assert_eq!(trade.fees.asset.as_ref(), "HYPE");
2048 assert_eq!(
2049 trade.fees.fees_quote, None,
2050 "a fee in the base asset is not in quote"
2051 );
2052 }
2053
2054 #[test]
2055 fn a_fill_quoted_in_another_token_takes_its_quote_from_the_pair() {
2056 let event = fill_to_account_event(&stream_fill("@207", "USDT0"), &pair("@207")).unwrap();
2057 let AccountEventKind::Trade(trade) = event.kind else {
2058 panic!("Expected Trade event");
2059 };
2060 assert_eq!(trade.instrument.as_ref(), "HYPE-USDT0-SPOT");
2061 assert_eq!(
2062 trade.fees.fees_quote,
2063 Some(dec!(0.01)),
2064 "USDT0 is this pair's quote, not USDC"
2065 );
2066 }
2067
2068 fn open_order_rows(spot_coin: &str) -> Vec<OpenOrder> {
2071 serde_json::from_value(serde_json::json!([
2072 {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.1", "oid": 1,
2073 "timestamp": 1714100000000u64},
2074 {"coin": spot_coin, "side": "B", "limitPx": "25.5", "sz": "2", "oid": 2,
2075 "timestamp": 1714100000000u64},
2076 {"coin": "PURR/USDC", "side": "A", "limitPx": "0.05", "sz": "100", "oid": 3,
2077 "timestamp": 1714100000000u64},
2078 {"coin": "#12", "side": "B", "limitPx": "0.5", "sz": "10", "oid": 4,
2079 "timestamp": 1714100000000u64}
2080 ]))
2081 .unwrap()
2082 }
2083
2084 #[test]
2085 fn open_orders_on_indexed_coins_are_listed_and_other_markets_left_out() {
2086 let orders = spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &[]).unwrap();
2087
2088 let instruments: Vec<_> = orders.iter().map(|o| o.key.instrument.as_ref()).collect();
2089 assert_eq!(instruments, ["HYPE-USDC-SPOT", "PURR-USDC-SPOT"]);
2090
2091 let hype_only = [InstrumentNameExchange::from("HYPE-USDC-SPOT")];
2092 let orders =
2093 spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &hype_only).unwrap();
2094 assert_eq!(orders.len(), 1);
2095 }
2096
2097 #[test]
2098 fn an_open_order_on_a_spot_coin_missing_from_spot_meta_fails_the_list() {
2099 let result = spot_open_orders(&open_order_rows("@999"), &test_spot_pairs(), &[]);
2100 assert!(
2101 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2102 "a list missing an order must not read as complete: {result:?}"
2103 );
2104 }
2105
2106 fn user_fills(spot_coin: &str) -> Vec<UserFill> {
2109 serde_json::from_value(serde_json::json!([
2110 {"coin": "BTC", "side": "B", "px": "50000", "sz": "0.1", "time": 1714100000000u64,
2111 "oid": 1, "fee": "0.5", "feeToken": "USDC", "tid": 11},
2112 {"coin": spot_coin, "side": "B", "px": "25.5", "sz": "2", "time": 1714100000000u64,
2113 "oid": 2, "fee": "0.01", "feeToken": "HYPE", "tid": 12},
2114 {"coin": "PURR/USDC", "side": "A", "px": "0.05", "sz": "100",
2115 "time": 1714100000000u64, "oid": 3, "fee": "0.002", "tid": 13}
2116 ]))
2117 .unwrap()
2118 }
2119
2120 fn fill_time() -> DateTime<Utc> {
2121 millis_to_datetime(1714100000000).unwrap()
2122 }
2123
2124 #[test]
2125 fn trades_on_indexed_coins_are_read_with_assets_from_spot_meta() {
2126 let trades = spot_trades(
2127 &user_fills("@107"),
2128 &test_spot_pairs(),
2129 fill_time(),
2130 fill_time(),
2131 &[],
2132 )
2133 .unwrap();
2134
2135 let read: Vec<_> = trades
2136 .iter()
2137 .map(|t| (t.instrument.as_ref(), t.fees.asset.as_ref()))
2138 .collect();
2139 assert_eq!(
2140 read,
2141 [("HYPE-USDC-SPOT", "HYPE"), ("PURR-USDC-SPOT", "USDC")],
2142 "the perpetual is left out; PURR/USDC's missing feeToken falls back to the quote, \
2143 since it sold"
2144 );
2145 }
2146
2147 #[test]
2148 fn a_trade_on_a_spot_coin_missing_from_spot_meta_fails_the_read() {
2149 let result = spot_trades(
2150 &user_fills("@999"),
2151 &test_spot_pairs(),
2152 fill_time(),
2153 fill_time(),
2154 &[],
2155 );
2156 assert!(
2157 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2158 "a read missing a fill must not read as complete: {result:?}"
2159 );
2160 }
2161}