1use super::common::{
79 CLOID_REQUIRED, CancelOnDropStream, OpenOrder, OpenOrderListing, UserFill, cancel_outcome,
80 cid_to_cloid, instrument_to_spot_coin, map_tif, millis_to_datetime, open_order_to_order,
81 open_orders, parse_decimal, parse_side, span_millis, spot_balances, spot_base_quote,
82 spot_pair_to_instrument, user_fills_by_time,
83};
84use super::config::HyperliquidConfig;
85use super::error::{map_order_error, map_sdk_error};
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, 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 { .. } | OrderKind::TrailingStopLimit { .. } => {
832 return make_unsupported(
833 "Hyperliquid does not support trailing stop orders".to_string(),
834 );
835 }
836 OrderKind::Limit
837 | OrderKind::Stop { .. }
838 | OrderKind::StopLimit { .. }
839 | OrderKind::TakeProfit { .. }
840 | OrderKind::TakeProfitLimit { .. } => {}
841 }
842
843 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
846 return make_rejected(CLOID_REQUIRED.to_string());
847 };
848
849 let tif = map_tif(&request.state.time_in_force).to_string();
850
851 let pair = match self.traded_pair(base, quote).await {
852 Ok(pair) => pair,
853 Err(untraded) => {
854 let reason = match untraded {
855 UntradedPair::Unlisted => {
856 format!("Hyperliquid's spotMeta lists no spot pair {base}/{quote}")
857 }
858 UntradedPair::Misspelled(pair) => format!(
859 "Hyperliquid spells this spot pair {}/{}, and reports its orders under \
860 {}; name the instrument that way",
861 pair.base(),
862 pair.quote(),
863 spot_pair_to_instrument(&pair)
864 ),
865 };
866 warn!(instrument = %request.key.instrument, %reason, "Spot order refused unsent");
867 return make_inactive(OrderError::Rejected(
868 crate::error::ApiError::InstrumentInvalid(
869 request.key.instrument.clone(),
870 reason,
871 ),
872 ));
873 }
874 };
875 let precision = OrderPrecision::spot(pair.base_sz_decimals());
876 let coin = format!("{}/{}", pair.base(), pair.quote());
879 let wire = match wire_order(
880 &precision,
881 request.state.kind,
882 tif,
883 request.state.price,
884 request.state.quantity,
885 ) {
886 Ok(wire) => wire,
887 Err(WireOrderError::Precision(violation)) => {
888 return make_inactive(OrderError::InvalidPrecision(violation));
889 }
890 Err(WireOrderError::MissingPrice) => {
891 return make_rejected(
892 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
893 .to_string(),
894 );
895 }
896 Err(WireOrderError::UnsupportedKind) => {
897 return make_unsupported(format!(
898 "Hyperliquid does not support {} orders",
899 request.state.kind
900 ));
901 }
902 };
903
904 let notional_price = match request.state.kind {
907 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
908 trigger_price
909 }
910 _ => request.state.price.unwrap_or(Decimal::ZERO),
911 };
912 let notional = notional_price * request.state.quantity;
913 if notional < Decimal::TEN {
914 warn!(
915 instrument = %request.key.instrument,
916 %notional,
917 "Spot order below $10 minimum notional value"
918 );
919 return make_rejected(format!("Spot order notional ${notional} below $10 minimum"));
920 }
921
922 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
924 warn!(
925 instrument = %request.key.instrument,
926 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
927 );
928 }
929
930 let order_request = ClientOrderRequest {
931 asset: coin,
932 is_buy,
933 reduce_only: request.state.reduce_only,
934 limit_px: wire.limit_px,
935 sz: wire.sz,
936 cloid: Some(cloid),
937 order_type: wire.order_type,
938 };
939
940 let response = match self.exchange_client.order(order_request, None).await {
941 Ok(r) => r,
942 Err(e) => {
943 warn!(%e, "Spot open order failed");
944 return Order {
945 key: OrderKey {
946 exchange: ExchangeId::HyperliquidSpot,
947 instrument: request.key.instrument.clone(),
948 strategy: request.key.strategy.clone(),
949 cid: request.key.cid.clone(),
950 },
951 side: request.state.side,
952 price: request.state.price,
953 quantity: request.state.quantity,
954 kind: request.state.kind,
955 time_in_force: request.state.time_in_force,
956 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
957 };
958 }
959 };
960
961 let state = match response {
962 ExchangeResponseStatus::Ok(exchange_resp) => {
963 let status = exchange_resp
964 .data
965 .and_then(|d| d.statuses.into_iter().next());
966
967 match status {
968 Some(ExchangeDataStatus::Resting(resting)) => {
969 debug!(oid = resting.oid, "Spot order resting");
970 OrderState::active(Open {
971 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
972 time_exchange: Utc::now(),
974 filled_quantity: Decimal::ZERO,
975 })
976 }
977 Some(ExchangeDataStatus::Filled(filled)) => {
978 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
979 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
980 OrderState::fully_filled(Filled::new(
981 OrderId(format_smolstr!("{}", filled.oid)),
982 Utc::now(),
984 parse_decimal(&filled.total_sz, "total_sz")
985 .unwrap_or(request.state.quantity),
986 avg_price,
987 ))
988 }
989 Some(ExchangeDataStatus::Error(msg)) => {
990 warn!(%msg, "Spot order rejected by exchange");
991 OrderState::inactive(OrderError::Rejected(
992 crate::error::ApiError::OrderRejected(msg),
993 ))
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(crate::error::ApiError::OrderRejected(
1021 msg,
1022 )))
1023 }
1024 };
1025
1026 let order = Order {
1027 key: OrderKey {
1028 exchange: ExchangeId::HyperliquidSpot,
1029 instrument: request.key.instrument.clone(),
1030 strategy: request.key.strategy.clone(),
1031 cid: request.key.cid.clone(),
1032 },
1033 side: request.state.side,
1034 price: request.state.price,
1035 quantity: request.state.quantity,
1036 kind: request.state.kind,
1037 time_in_force: request.state.time_in_force,
1038 state,
1039 };
1040 self.known_live
1041 .lock()
1042 .placed(&order.key, order.quantity, order.kind, &order.state);
1043 order
1044 }
1045
1046 async fn fetch_balances(
1047 &self,
1048 _assets: &[AssetNameExchange],
1049 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1050 let address = self.wallet_h160();
1051
1052 let token_balances = self
1053 .info_client
1054 .user_token_balances(address)
1055 .await
1056 .map_err(map_sdk_error)?;
1057
1058 let now = Utc::now();
1059 Ok(spot_balances(&token_balances.balances, now).collect())
1060 }
1061
1062 async fn fetch_open_orders(
1063 &self,
1064 instruments: &[InstrumentNameExchange],
1065 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1066 let address = self.wallet_h160();
1067
1068 let open_orders = open_orders(&self.info_client, address).await?;
1069 let pairs = self
1070 .spot_coins
1071 .covering(open_orders.iter().map(|order| order.coin.as_str()))
1072 .await;
1073
1074 let orders = spot_open_orders(&open_orders, &pairs, instruments)?;
1075 remember_open(&self.known_live, &orders);
1076 Ok(orders)
1077 }
1078
1079 async fn fetch_trades(
1083 &self,
1084 start: DateTime<Utc>,
1085 end: DateTime<Utc>,
1086 instruments: &[InstrumentNameExchange],
1087 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1088 let Some((start_ms, end_ms)) = span_millis(start, end) else {
1089 return Ok(TradesRead::complete(Vec::new()));
1090 };
1091 let address = self.wallet_h160();
1092
1093 let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1094 let pairs = self
1095 .spot_coins
1096 .covering(fills.iter().map(|fill| fill.coin.as_str()))
1097 .await;
1098
1099 spot_trades(&fills, &pairs, start, end, instruments).map(TradesRead::complete)
1100 }
1101}
1102
1103impl OrderStatusClient for HyperliquidSpotClient {
1112 async fn fetch_ended_orders(
1113 &self,
1114 orders: &[UnindexedOrderKey],
1115 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1116 let address = self.wallet_h160();
1117 fetch_ended_by_key(orders, |key| {
1118 spot_order_lookup(
1119 self.info_client.clone(),
1120 self.spot_coins.clone(),
1121 address,
1122 key,
1123 )
1124 })
1125 .await
1126 }
1127}
1128
1129async fn spot_order_lookup(
1135 info_client: Arc<InfoClient>,
1136 spot_coins: SpotCoins,
1137 address: ethers::types::H160,
1138 key: UnindexedOrderKey,
1139) -> Result<OrderLookup, UnindexedClientError> {
1140 let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1141 return Ok(OrderLookup::Unknown);
1142 };
1143 let coin = record.order.coin.as_str();
1144 let pairs = spot_coins.covering([coin]).await;
1145 let instrument = spot_pair(&pairs, coin)?.map(spot_pair_to_instrument);
1146 Ok(lookup_from_record(key, &record, instrument))
1147}
1148
1149async fn spot_listed_cids(
1157 info_client: Arc<InfoClient>,
1158 spot_coins: SpotCoins,
1159 address: ethers::types::H160,
1160 instruments: Vec<InstrumentNameExchange>,
1161) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1162 let rows = open_orders(&info_client, address).await?;
1163 let pairs = spot_coins
1164 .covering(rows.iter().map(|row| row.coin.as_str()))
1165 .await;
1166 Ok(listed_cids(&rows, &instruments, |coin| {
1167 pairs.get(coin).map(spot_pair_to_instrument)
1168 }))
1169}
1170
1171fn spot_open_orders(
1178 rows: &[OpenOrder],
1179 pairs: &SpotPairs,
1180 instruments: &[InstrumentNameExchange],
1181) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1182 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1183 None
1184 } else {
1185 let mut set = HashSet::with_capacity(instruments.len());
1186 set.extend(instruments.iter().cloned());
1187 Some(set)
1188 };
1189
1190 let mut orders = Vec::new();
1191 for order in rows {
1192 let Some(pair) = spot_pair(pairs, &order.coin)? else {
1193 continue;
1194 };
1195 let instrument = spot_pair_to_instrument(pair);
1196 if instrument_filter
1197 .as_ref()
1198 .is_some_and(|f| !f.contains(&instrument))
1199 {
1200 continue;
1201 }
1202 orders.extend(open_order_to_order(
1203 order,
1204 ExchangeId::HyperliquidSpot,
1205 instrument,
1206 ));
1207 }
1208 Ok(orders)
1209}
1210
1211fn spot_trades(
1218 fills: &[UserFill],
1219 pairs: &SpotPairs,
1220 start: DateTime<Utc>,
1221 end: DateTime<Utc>,
1222 instruments: &[InstrumentNameExchange],
1223) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1224 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1225 None
1226 } else {
1227 let mut set = HashSet::with_capacity(instruments.len());
1228 set.extend(instruments.iter().cloned());
1229 Some(set)
1230 };
1231
1232 let mut result = Vec::new();
1233 for fill in fills {
1234 let Some(pair) = spot_pair(pairs, &fill.coin)? else {
1235 continue;
1236 };
1237 let (base_asset, quote_asset) = (pair.base(), pair.quote());
1238 let instrument = spot_pair_to_instrument(pair);
1239
1240 if instrument_filter
1241 .as_ref()
1242 .is_some_and(|f| !f.contains(&instrument))
1243 {
1244 continue;
1245 }
1246
1247 let Some(side) = parse_side(&fill.side) else {
1248 continue;
1249 };
1250 let Some(price) = parse_decimal(&fill.px, "px") else {
1251 continue;
1252 };
1253 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1254 continue;
1255 };
1256 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1257
1258 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1259 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1260 continue;
1261 };
1262 if !(start..=end).contains(&time_exchange) {
1263 continue;
1264 }
1265
1266 let fee_asset = fill
1271 .fee_token
1272 .as_deref()
1273 .unwrap_or(if matches!(side, Side::Buy) {
1274 base_asset
1275 } else {
1276 quote_asset
1277 });
1278
1279 result.push(Trade {
1280 id: TradeId(format_smolstr!("{}", fill.tid)),
1281 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1282 instrument,
1283 strategy: StrategyId::unknown(),
1284 time_exchange,
1285 side,
1286 price,
1287 quantity,
1288 order_filled_quantity: None,
1291 fees: AssetFees {
1292 asset: AssetNameExchange::from(fee_asset),
1293 fees: fee,
1294 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1301 Some(fee)
1302 } else {
1303 None
1304 },
1305 },
1306 });
1307 }
1308
1309 Ok(result)
1310}
1311
1312fn fill_to_account_event(
1314 fill: &hyperliquid_rust_sdk::TradeInfo,
1315 pair: &SpotPair,
1316) -> Option<UnindexedAccountEvent> {
1317 let side = parse_side(&fill.side)?;
1318 let price = parse_decimal(&fill.px, "fill.px")?;
1319 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1320 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1321 let time_exchange = millis_to_datetime(fill.time)?;
1322
1323 let quote_asset = pair.quote();
1324 let instrument = spot_pair_to_instrument(pair);
1325 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1326
1327 let fee_asset = fill.fee_token.as_str();
1330
1331 let trade = Trade {
1332 id: TradeId(format_smolstr!("{}", fill.tid)),
1333 order_id,
1334 instrument,
1335 strategy: StrategyId::unknown(),
1336 time_exchange,
1337 side,
1338 price,
1339 quantity,
1340 order_filled_quantity: None,
1343 fees: AssetFees {
1344 asset: AssetNameExchange::from(fee_asset),
1345 fees: fee,
1346 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1349 Some(fee)
1350 } else {
1351 None
1352 },
1353 },
1354 };
1355
1356 Some(AccountEvent::new(
1357 ExchangeId::HyperliquidSpot,
1358 AccountEventKind::Trade(trade),
1359 ))
1360}
1361
1362fn order_update_to_account_event(
1364 update: &hyperliquid_rust_sdk::OrderUpdate,
1365 pair: &SpotPair,
1366) -> Option<UnindexedAccountEvent> {
1367 super::common::order_update_to_account_event(
1368 update,
1369 ExchangeId::HyperliquidSpot,
1370 spot_pair_to_instrument(pair),
1371 )
1372}
1373
1374#[cfg(test)]
1375#[allow(clippy::unwrap_used)]
1377mod tests {
1378 use super::super::spot_coins::test_spot_pairs;
1379 use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1380
1381 mod order_recovery {
1382 use super::super::super::common::info_tests::info_client_against;
1383 use super::super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1384 use super::super::super::spot_coins::TEST_SPOT_META;
1385 use super::*;
1386 use crate::order::state::InactiveOrderState;
1387 use wiremock::matchers::{body_string_contains, method, path};
1388 use wiremock::{Mock, MockServer, ResponseTemplate};
1389
1390 async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>, SpotCoins) {
1393 let server = MockServer::start().await;
1394 Mock::given(method("POST"))
1395 .and(path("/info"))
1396 .and(body_string_contains("spotMeta"))
1397 .respond_with(
1398 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1399 )
1400 .with_priority(1)
1401 .mount(&server)
1402 .await;
1403 Mock::given(method("POST"))
1404 .and(path("/info"))
1405 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1406 .with_priority(2)
1407 .mount(&server)
1408 .await;
1409 let client = Arc::new(info_client_against(server.uri()).await);
1410 let coins = SpotCoins::fetch(client.clone()).await.unwrap();
1411 (server, client, coins)
1412 }
1413
1414 fn key() -> UnindexedOrderKey {
1415 OrderKey {
1416 exchange: ExchangeId::HyperliquidSpot,
1417 instrument: InstrumentNameExchange::new("HYPE-USDC-SPOT"),
1418 strategy: StrategyId::new("strategy"),
1419 cid: ClientOrderId::new(CID),
1420 }
1421 }
1422
1423 fn order_status(coin: &str) -> serde_json::Value {
1424 serde_json::json!({
1425 "status": "order",
1426 "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1427 })
1428 }
1429
1430 #[tokio::test]
1431 async fn a_spot_lookup_names_the_pair_from_spot_meta() {
1432 let (_server, client, coins) = serve(order_status("@107")).await;
1433
1434 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1435 .await
1436 .unwrap();
1437
1438 let OrderLookup::Ended(order) = lookup else {
1439 panic!("expected an ended order, got {lookup:?}");
1440 };
1441 assert_eq!(order.key, key());
1442 assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1443 }
1444
1445 #[tokio::test]
1446 async fn a_spot_lookup_of_a_perp_order_is_unknown() {
1447 let (_server, client, coins) = serve(order_status("BTC")).await;
1448
1449 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1450 .await
1451 .unwrap();
1452
1453 assert!(matches!(lookup, OrderLookup::Unknown));
1454 }
1455
1456 #[tokio::test]
1457 async fn a_spot_lookup_fails_on_a_coin_missing_from_spot_meta() {
1458 let (_server, client, coins) = serve(order_status("@999")).await;
1459
1460 let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key()).await;
1461
1462 assert!(lookup.is_err());
1463 }
1464
1465 #[tokio::test]
1466 async fn a_spot_listing_names_only_the_orders_on_the_pairs_asked_about() {
1467 let rows = serde_json::json!([
1468 row("@107", 1, Some(CLOID)),
1469 row("@207", 2, None),
1470 row("BTC", 3, None),
1471 ]);
1472 let (_server, client, coins) = serve(rows).await;
1473
1474 let listed = spot_listed_cids(
1475 client,
1476 coins,
1477 ethers::types::H160::zero(),
1478 vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1479 )
1480 .await
1481 .unwrap();
1482
1483 assert_eq!(
1484 listed.into_iter().collect::<Vec<_>>(),
1485 [ClientOrderId::new(CID)]
1486 );
1487 }
1488
1489 #[tokio::test]
1490 async fn a_spot_listing_leaves_out_a_coin_missing_from_spot_meta() {
1491 let rows = serde_json::json!([row("@999", 1, None), row("@107", 2, Some(CLOID))]);
1492 let (_server, client, coins) = serve(rows).await;
1493
1494 let listed = spot_listed_cids(
1495 client,
1496 coins,
1497 ethers::types::H160::zero(),
1498 vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1499 )
1500 .await
1501 .unwrap();
1502
1503 assert_eq!(
1504 listed.into_iter().collect::<Vec<_>>(),
1505 [ClientOrderId::new(CID)]
1506 );
1507 }
1508 }
1509
1510 mod precision {
1511 use super::super::super::common::info_tests::{
1512 exchange_client_against, info_client_against, test_wallet,
1513 };
1514 use super::super::super::config::Network;
1515 use super::super::super::spot_coins::TEST_SPOT_META;
1516 use super::*;
1517 use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1518 use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1519 use std::collections::HashMap;
1520 use wiremock::matchers::{method, path};
1521 use wiremock::{Mock, MockServer, ResponseTemplate};
1522
1523 async fn resting_client() -> (MockServer, HyperliquidSpotClient) {
1525 let server = MockServer::start().await;
1526 Mock::given(method("POST"))
1527 .and(path("/info"))
1528 .respond_with(
1529 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1530 )
1531 .mount(&server)
1532 .await;
1533 Mock::given(method("POST"))
1534 .and(path("/exchange"))
1535 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1536 "status": "ok",
1537 "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1538 })))
1539 .mount(&server)
1540 .await;
1541 let info_client = Arc::new(info_client_against(server.uri()).await);
1542 let client = HyperliquidSpotClient {
1543 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1544 spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1545 info_client,
1546 exchange_client: Arc::new(
1547 exchange_client_against(
1548 server.uri(),
1549 HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1550 )
1551 .await,
1552 ),
1553 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1554 };
1555 (server, client)
1556 }
1557
1558 async fn place_hype(
1559 client: &HyperliquidSpotClient,
1560 price: Decimal,
1561 quantity: Decimal,
1562 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1563 place(client, "HYPE-USDC-SPOT", price, quantity).await
1564 }
1565
1566 async fn place(
1567 client: &HyperliquidSpotClient,
1568 instrument: &str,
1569 price: Decimal,
1570 quantity: Decimal,
1571 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1572 let instrument = InstrumentNameExchange::new(instrument);
1573 client
1574 .open_order(OrderEvent {
1575 key: OrderKey {
1576 exchange: ExchangeId::HyperliquidSpot,
1577 instrument: &instrument,
1578 strategy: StrategyId::new("strategy"),
1579 cid: ClientOrderId::uuid(),
1580 },
1581 state: RequestOpen {
1582 side: Side::Buy,
1583 price: Some(price),
1584 quantity,
1585 kind: OrderKind::Limit,
1586 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1587 position_id: None,
1588 reduce_only: false,
1589 market: None,
1590 },
1591 })
1592 .await
1593 }
1594
1595 async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
1597 server
1598 .received_requests()
1599 .await
1600 .unwrap_or_default()
1601 .into_iter()
1602 .filter(|request| request.url.path() == "/exchange")
1603 .map(|request| {
1604 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1605 body["action"]["orders"][0].clone()
1606 })
1607 .collect()
1608 }
1609
1610 #[tokio::test]
1611 async fn a_spot_pairs_precision_is_its_base_tokens() {
1612 let (_server, client) = resting_client().await;
1613
1614 assert_eq!(
1615 client
1616 .order_precision(&InstrumentNameExchange::new("HYPE-USDC-SPOT"))
1617 .await,
1618 Some(OrderPrecision::spot(2))
1619 );
1620 assert_eq!(
1621 client
1622 .order_precision(&InstrumentNameExchange::new("PURR-USDC-SPOT"))
1623 .await,
1624 Some(OrderPrecision::spot(0))
1625 );
1626 assert_eq!(
1627 client
1628 .order_precision(&InstrumentNameExchange::new("BTC-USDC-PERP"))
1629 .await,
1630 None
1631 );
1632 }
1633
1634 #[tokio::test]
1635 async fn a_spot_order_spelling_the_pair_differently_is_refused_unsent() {
1636 let (server, client) = resting_client().await;
1637
1638 for instrument in ["hype-usdc-SPOT", "Hype-USDC-SPOT"] {
1640 let order = place(&client, instrument, dec!(25), dec!(1)).await;
1641 assert_eq!(
1642 order.state,
1643 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1644 ApiError::InstrumentInvalid(
1645 InstrumentNameExchange::new(instrument),
1646 "Hyperliquid spells this spot pair HYPE/USDC, and reports its orders \
1647 under HYPE-USDC-SPOT; name the instrument that way"
1648 .to_owned(),
1649 )
1650 )))
1651 );
1652 assert_eq!(
1653 client
1654 .order_precision(&InstrumentNameExchange::new(instrument))
1655 .await,
1656 None
1657 );
1658 }
1659 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1660 }
1661
1662 #[tokio::test]
1663 async fn a_spot_order_on_an_unlisted_pair_is_refused_unsent() {
1664 let (server, client) = resting_client().await;
1665
1666 let order = place(&client, "HYPE-PURR-SPOT", dec!(25), dec!(1)).await;
1667 assert_eq!(
1668 order.state,
1669 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1670 ApiError::InstrumentInvalid(
1671 InstrumentNameExchange::new("HYPE-PURR-SPOT"),
1672 "Hyperliquid's spotMeta lists no spot pair HYPE/PURR".to_owned(),
1673 )
1674 )))
1675 );
1676 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1677 }
1678
1679 #[tokio::test]
1680 async fn a_spot_order_breaking_the_precision_rules_is_refused_unsent() {
1681 let (server, client) = resting_client().await;
1682
1683 for (price, quantity, violation) in [
1685 (
1686 dec!(25),
1687 dec!(1.234),
1688 PrecisionViolation {
1689 field: OrderField::Quantity,
1690 value: dec!(1.234),
1691 limit: PrecisionLimit::DecimalPlaces { max: 2 },
1692 },
1693 ),
1694 (
1695 dec!(25.1234567),
1696 dec!(1),
1697 PrecisionViolation {
1698 field: OrderField::Price,
1699 value: dec!(25.1234567),
1700 limit: PrecisionLimit::DecimalPlaces { max: 6 },
1701 },
1702 ),
1703 ] {
1704 let order = place_hype(&client, price, quantity).await;
1705 assert_eq!(
1706 order.state,
1707 OrderState::Inactive(InactiveOrderState::OpenFailed(
1708 OrderError::InvalidPrecision(violation)
1709 ))
1710 );
1711 }
1712 assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1713 }
1714
1715 #[tokio::test]
1716 async fn a_valid_spot_order_is_sent_unchanged() {
1717 let (server, client) = resting_client().await;
1718
1719 let order = place_hype(&client, dec!(25.123), dec!(1.25)).await;
1720 assert!(
1721 matches!(order.state, OrderState::Active(_)),
1722 "{:?}",
1723 order.state
1724 );
1725
1726 let sent = sent_orders(&server).await;
1727 assert_eq!(sent.len(), 1);
1728 assert_eq!(
1729 (&sent[0]["p"], &sent[0]["s"]),
1730 (&serde_json::json!("25.123"), &serde_json::json!("1.25"))
1731 );
1732 }
1733 }
1734
1735 mod cancel {
1736 use super::super::super::common::info_tests::{
1737 CANCEL_NOT_APPLIED, cancel_answer, exchange_client_against, info_client_against,
1738 test_wallet,
1739 };
1740 use super::super::super::config::Network;
1741 use super::super::super::spot_coins::TEST_SPOT_META;
1742 use super::*;
1743 use crate::error::ApiError;
1744 use crate::order::{OrderEvent, request::RequestCancel};
1745 use std::collections::HashMap;
1746 use wiremock::matchers::{method, path};
1747 use wiremock::{Mock, MockServer, ResponseTemplate};
1748
1749 async fn cancel_answered(
1752 answer: serde_json::Value,
1753 ) -> (UnindexedOrderResponseCancel, bool) {
1754 let server = MockServer::start().await;
1755 Mock::given(method("POST"))
1756 .and(path("/info"))
1757 .respond_with(
1758 ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1759 )
1760 .mount(&server)
1761 .await;
1762 Mock::given(method("POST"))
1763 .and(path("/exchange"))
1764 .respond_with(ResponseTemplate::new(200).set_body_json(answer))
1765 .mount(&server)
1766 .await;
1767 let info_client = Arc::new(info_client_against(server.uri()).await);
1768 let client = HyperliquidSpotClient {
1769 config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1770 spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1771 info_client,
1772 exchange_client: Arc::new(
1773 exchange_client_against(
1774 server.uri(),
1775 HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1776 )
1777 .await,
1778 ),
1779 known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1780 };
1781 let instrument = InstrumentNameExchange::new("HYPE-USDC-SPOT");
1782 let key = OrderKey {
1783 exchange: ExchangeId::HyperliquidSpot,
1784 instrument: instrument.clone(),
1785 strategy: StrategyId::new("strategy"),
1786 cid: ClientOrderId::uuid(),
1787 };
1788 let id = VenueOrderId::Assigned(OrderId::new("7"));
1789 let open = Open {
1790 id: id.clone(),
1791 time_exchange: Utc::now(),
1792 filled_quantity: Decimal::ZERO,
1793 };
1794 client
1795 .known_live
1796 .lock()
1797 .live(&key, dec!(1), OrderKind::Limit, &open);
1798
1799 let response = client
1800 .cancel_order(OrderEvent {
1801 key: OrderKey {
1802 exchange: key.exchange,
1803 instrument: &instrument,
1804 strategy: key.strategy.clone(),
1805 cid: key.cid.clone(),
1806 },
1807 state: RequestCancel { id: Some(id) },
1808 })
1809 .await;
1810 let live = client.known_live.lock().contains(&key.cid);
1811 (response, live)
1812 }
1813
1814 #[tokio::test]
1815 async fn a_success_status_cancels_the_spot_order() {
1816 let (response, live) =
1817 cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
1818 assert!(response.state.is_ok(), "{:?}", response.state);
1819 assert!(!live, "a cancelled order is no longer held live");
1820 }
1821
1822 #[tokio::test]
1825 async fn an_error_status_is_a_rejected_spot_cancel_and_the_order_stays_live() {
1826 let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
1827 {"error": CANCEL_NOT_APPLIED}
1828 ])))
1829 .await;
1830 let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
1831 &response.state
1832 else {
1833 panic!("expected a rejected cancel, got {:?}", response.state);
1834 };
1835 assert_eq!(reason, CANCEL_NOT_APPLIED);
1836 assert!(live, "the order is still held live");
1837 }
1838
1839 #[tokio::test]
1840 async fn an_answer_without_a_status_is_a_rejected_spot_cancel() {
1841 let (response, live) = cancel_answered(serde_json::json!({
1842 "status": "ok",
1843 "response": {"type": "cancel"},
1844 }))
1845 .await;
1846 assert!(
1847 matches!(
1848 response.state,
1849 Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
1850 ),
1851 "{:?}",
1852 response.state
1853 );
1854 assert!(live, "the order is still held live");
1855 }
1856 }
1857
1858 use super::*;
1859 use rust_decimal_macros::dec;
1860 use rustrade_integration::collection::snapshot::Snapshot;
1861
1862 fn pair(coin: &str) -> SpotPair {
1864 test_spot_pairs().get(coin).unwrap().clone()
1865 }
1866
1867 #[test]
1868 fn test_fill_to_account_event_spot() {
1869 let fill_json = r#"{
1870 "coin": "PURR/USDC",
1871 "side": "B",
1872 "px": "0.05",
1873 "sz": "1000",
1874 "time": 1714100000000,
1875 "hash": "0xspot123",
1876 "startPosition": "0",
1877 "dir": "Open Long",
1878 "closedPnl": "0",
1879 "oid": 12345,
1880 "cloid": null,
1881 "crossed": false,
1882 "fee": "0.025",
1883 "feeToken": "USDC",
1884 "tid": 99999
1885 }"#;
1886
1887 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1888 let event = fill_to_account_event(&fill, &pair("PURR/USDC")).unwrap();
1889
1890 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1891 match event.kind {
1892 AccountEventKind::Trade(trade) => {
1893 assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1894 assert_eq!(
1895 trade.id.0, "99999",
1896 "the id is the fill's tid, not its hash"
1897 );
1898 assert_eq!(trade.side, Side::Buy);
1899 assert_eq!(trade.price, dec!(0.05));
1900 assert_eq!(trade.quantity, dec!(1000));
1901 assert_eq!(trade.fees.fees, dec!(0.025));
1902 assert_eq!(trade.fees.asset.as_ref(), "USDC");
1903 }
1904 _ => panic!("Expected Trade event"),
1905 }
1906 }
1907
1908 fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1910 let json = format!(
1911 r#"{{
1912 "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1913 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1914 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1915 "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1916 }}"#
1917 );
1918 serde_json::from_str(&json).unwrap()
1919 }
1920
1921 #[test]
1922 fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1923 let first =
1924 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1925 let second =
1926 fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1927
1928 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1929 (first.kind, second.kind)
1930 else {
1931 panic!("Expected Trade events");
1932 };
1933
1934 assert_eq!(first.order_id, second.order_id, "one order produced both");
1935 assert_ne!(first.id, second.id, "but they are distinct fills");
1936 }
1937
1938 #[test]
1939 fn a_replayed_spot_fill_is_delivered_once() {
1940 let cache = new_dedup_cache();
1941 let event =
1942 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1943 let replay =
1944 fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1945
1946 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1947 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1948 let sibling =
1950 fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1951 assert!(!is_duplicate(
1952 &cache,
1953 dedup_key_from_event(&sibling).unwrap()
1954 ));
1955 }
1956
1957 #[test]
1958 fn test_order_update_to_account_event_spot() {
1959 let update_json = r#"{
1960 "order": {
1961 "coin": "@107",
1962 "side": "A",
1963 "limitPx": "25.5",
1964 "sz": "10",
1965 "oid": 12346,
1966 "timestamp": 1714100000000,
1967 "origSz": "10",
1968 "cloid": null
1969 },
1970 "status": "open",
1971 "statusTimestamp": 1714100000000
1972 }"#;
1973
1974 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1975 let event = order_update_to_account_event(&update, &pair("@107")).unwrap();
1976
1977 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1978 match event.kind {
1979 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1980 assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
1981 assert_eq!(order.side, Side::Sell);
1982 assert_eq!(order.price, Some(dec!(25.5)));
1983 assert_eq!(order.quantity, dec!(10));
1984 assert!(matches!(
1985 order.state,
1986 crate::order::state::OrderState::Active(_)
1987 ));
1988 }
1989 _ => panic!("Expected OrderSnapshot event"),
1990 }
1991 }
1992 fn stream_fill(coin: &str, fee_token: &str) -> hyperliquid_rust_sdk::TradeInfo {
1994 let json = format!(
1995 r#"{{
1996 "coin": "{coin}", "side": "B", "px": "25.5", "sz": "2",
1997 "time": 1714100000000, "hash": "0xindexed", "startPosition": "0",
1998 "dir": "Buy", "closedPnl": "0", "oid": 4343, "cloid": null,
1999 "crossed": true, "fee": "0.01", "feeToken": "{fee_token}", "tid": 333
2000 }}"#
2001 );
2002 serde_json::from_str(&json).unwrap()
2003 }
2004
2005 #[test]
2006 fn a_fill_on_an_indexed_coin_is_named_from_its_tokens() {
2007 let event = fill_to_account_event(&stream_fill("@107", "HYPE"), &pair("@107")).unwrap();
2008 let AccountEventKind::Trade(trade) = event.kind else {
2009 panic!("Expected Trade event");
2010 };
2011 assert_eq!(trade.instrument.as_ref(), "HYPE-USDC-SPOT");
2012 assert_eq!(trade.fees.asset.as_ref(), "HYPE");
2013 assert_eq!(
2014 trade.fees.fees_quote, None,
2015 "a fee in the base asset is not in quote"
2016 );
2017 }
2018
2019 #[test]
2020 fn a_fill_quoted_in_another_token_takes_its_quote_from_the_pair() {
2021 let event = fill_to_account_event(&stream_fill("@207", "USDT0"), &pair("@207")).unwrap();
2022 let AccountEventKind::Trade(trade) = event.kind else {
2023 panic!("Expected Trade event");
2024 };
2025 assert_eq!(trade.instrument.as_ref(), "HYPE-USDT0-SPOT");
2026 assert_eq!(
2027 trade.fees.fees_quote,
2028 Some(dec!(0.01)),
2029 "USDT0 is this pair's quote, not USDC"
2030 );
2031 }
2032
2033 fn open_order_rows(spot_coin: &str) -> Vec<OpenOrder> {
2036 serde_json::from_value(serde_json::json!([
2037 {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.1", "oid": 1,
2038 "timestamp": 1714100000000u64},
2039 {"coin": spot_coin, "side": "B", "limitPx": "25.5", "sz": "2", "oid": 2,
2040 "timestamp": 1714100000000u64},
2041 {"coin": "PURR/USDC", "side": "A", "limitPx": "0.05", "sz": "100", "oid": 3,
2042 "timestamp": 1714100000000u64},
2043 {"coin": "#12", "side": "B", "limitPx": "0.5", "sz": "10", "oid": 4,
2044 "timestamp": 1714100000000u64}
2045 ]))
2046 .unwrap()
2047 }
2048
2049 #[test]
2050 fn open_orders_on_indexed_coins_are_listed_and_other_markets_left_out() {
2051 let orders = spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &[]).unwrap();
2052
2053 let instruments: Vec<_> = orders.iter().map(|o| o.key.instrument.as_ref()).collect();
2054 assert_eq!(instruments, ["HYPE-USDC-SPOT", "PURR-USDC-SPOT"]);
2055
2056 let hype_only = [InstrumentNameExchange::from("HYPE-USDC-SPOT")];
2057 let orders =
2058 spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &hype_only).unwrap();
2059 assert_eq!(orders.len(), 1);
2060 }
2061
2062 #[test]
2063 fn an_open_order_on_a_spot_coin_missing_from_spot_meta_fails_the_list() {
2064 let result = spot_open_orders(&open_order_rows("@999"), &test_spot_pairs(), &[]);
2065 assert!(
2066 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2067 "a list missing an order must not read as complete: {result:?}"
2068 );
2069 }
2070
2071 fn user_fills(spot_coin: &str) -> Vec<UserFill> {
2074 serde_json::from_value(serde_json::json!([
2075 {"coin": "BTC", "side": "B", "px": "50000", "sz": "0.1", "time": 1714100000000u64,
2076 "oid": 1, "fee": "0.5", "feeToken": "USDC", "tid": 11},
2077 {"coin": spot_coin, "side": "B", "px": "25.5", "sz": "2", "time": 1714100000000u64,
2078 "oid": 2, "fee": "0.01", "feeToken": "HYPE", "tid": 12},
2079 {"coin": "PURR/USDC", "side": "A", "px": "0.05", "sz": "100",
2080 "time": 1714100000000u64, "oid": 3, "fee": "0.002", "tid": 13}
2081 ]))
2082 .unwrap()
2083 }
2084
2085 fn fill_time() -> DateTime<Utc> {
2086 millis_to_datetime(1714100000000).unwrap()
2087 }
2088
2089 #[test]
2090 fn trades_on_indexed_coins_are_read_with_assets_from_spot_meta() {
2091 let trades = spot_trades(
2092 &user_fills("@107"),
2093 &test_spot_pairs(),
2094 fill_time(),
2095 fill_time(),
2096 &[],
2097 )
2098 .unwrap();
2099
2100 let read: Vec<_> = trades
2101 .iter()
2102 .map(|t| (t.instrument.as_ref(), t.fees.asset.as_ref()))
2103 .collect();
2104 assert_eq!(
2105 read,
2106 [("HYPE-USDC-SPOT", "HYPE"), ("PURR-USDC-SPOT", "USDC")],
2107 "the perpetual is left out; PURR/USDC's missing feeToken falls back to the quote, \
2108 since it sold"
2109 );
2110 }
2111
2112 #[test]
2113 fn a_trade_on_a_spot_coin_missing_from_spot_meta_fails_the_read() {
2114 let result = spot_trades(
2115 &user_fills("@999"),
2116 &test_spot_pairs(),
2117 fill_time(),
2118 fill_time(),
2119 &[],
2120 );
2121 assert!(
2122 matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2123 "a read missing a fill must not read as complete: {result:?}"
2124 );
2125 }
2126}