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