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