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