1use super::common::{
45 CLOID_REQUIRED, CancelOnDropStream, OpenOrderListing, cid_to_cloid, instrument_to_spot_coin,
46 is_spot_coin, map_tif, millis_to_datetime, open_order_to_order, open_orders, parse_decimal,
47 parse_side, round_to_5_sig_figs, spot_coin_to_instrument, user_fills,
48};
49use super::config::HyperliquidConfig;
50use super::error::{map_order_error, map_sdk_error};
51use crate::client::dedup::{dedup_key_from_event, is_duplicate, new_dedup_cache};
52use crate::{
53 AccountEvent, AccountEventKind, AccountSnapshot, UnindexedAccountEvent,
54 UnindexedAccountSnapshot,
55 balance::{AssetBalance, Balance},
56 client::ExecutionClient,
57 emit_stream_terminated,
58 error::{
59 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
60 UnindexedOrderError,
61 },
62 order::{
63 Order, OrderKey, OrderKind, TimeInForce,
64 id::{OrderId, StrategyId, VenueOrderId},
65 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
66 state::{Filled, Open, OrderState, UnindexedOrderState},
67 },
68 trade::{AssetFees, Trade, TradeId},
69};
70use chrono::{DateTime, Utc};
71use ethers::signers::Signer;
72use futures::{StreamExt, stream::BoxStream};
73use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
74use rust_decimal::Decimal;
75use rustrade_instrument::{
76 Side,
77 asset::name::AssetNameExchange,
78 exchange::ExchangeId,
79 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
80};
81use smol_str::format_smolstr;
82use std::{
83 collections::HashSet,
84 sync::{
85 Arc,
86 atomic::{AtomicBool, Ordering},
87 },
88};
89use tokio::sync::mpsc;
90use tokio_util::sync::CancellationToken;
91use tracing::{debug, error, info, trace, warn};
92
93#[derive(Debug, Clone)]
98pub struct HyperliquidSpotClient {
99 config: HyperliquidConfig,
100 info_client: Arc<InfoClient>,
101 exchange_client: Arc<ExchangeClient>,
102}
103
104impl HyperliquidSpotClient {
105 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
110 let base_url = if config.testnet {
111 BaseUrl::Testnet
112 } else {
113 BaseUrl::Mainnet
114 };
115
116 let info_client = InfoClient::new(None, Some(base_url))
117 .await
118 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
119
120 let wallet = config.wallet.clone();
121 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
122 .await
123 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
124
125 info!(
126 testnet = config.testnet,
127 wallet = %config.wallet_address_hex(),
128 "Created HyperliquidSpotClient"
129 );
130
131 Ok(Self {
132 config,
133 info_client: Arc::new(info_client),
134 exchange_client: Arc::new(exchange_client),
135 })
136 }
137
138 fn base_url(&self) -> BaseUrl {
140 if self.config.testnet {
141 BaseUrl::Testnet
142 } else {
143 BaseUrl::Mainnet
144 }
145 }
146
147 pub fn wallet_address(&self) -> String {
149 self.config.wallet_address_hex()
150 }
151
152 fn wallet_h160(&self) -> ethers::types::H160 {
154 self.config.wallet.address()
155 }
156}
157
158impl ExecutionClient for HyperliquidSpotClient {
159 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
160
161 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
163 &[InstrumentKindDiscriminant::Spot];
164
165 type Config = HyperliquidConfig;
166 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
167
168 fn new(config: Self::Config) -> Self {
181 let base_url = if config.testnet {
182 BaseUrl::Testnet
183 } else {
184 BaseUrl::Mainnet
185 };
186
187 let handle = tokio::runtime::Handle::current();
188
189 let info_client = handle.block_on(async {
190 InfoClient::new(None, Some(base_url))
191 .await
192 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
193 });
194
195 let wallet = config.wallet.clone();
196 let exchange_client = handle.block_on(async {
197 ExchangeClient::new(None, wallet, Some(base_url), None, None)
198 .await
199 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
200 });
201
202 info!(
203 testnet = config.testnet,
204 wallet = %config.wallet_address_hex(),
205 "Created HyperliquidSpotClient"
206 );
207
208 Self {
209 config,
210 info_client: Arc::new(info_client),
211 exchange_client: Arc::new(exchange_client),
212 }
213 }
214
215 async fn account_snapshot(
216 &self,
217 _assets: &[AssetNameExchange],
218 instruments: &[InstrumentNameExchange],
219 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
220 let address = self.wallet_h160();
221
222 let (token_balances, open_orders) = tokio::try_join!(
224 async {
225 self.info_client
226 .user_token_balances(address)
227 .await
228 .map_err(map_sdk_error)
229 },
230 open_orders(&self.info_client, address)
231 )?;
232
233 let now = Utc::now();
234 let balances = parse_token_balances(&token_balances.balances, now);
235
236 let instrument_snapshots = OpenOrderListing::new(
238 &open_orders,
239 ExchangeId::HyperliquidSpot,
240 instruments,
241 |coin| is_spot_coin(coin).then(|| spot_coin_to_instrument(coin)),
242 )
243 .into_snapshots()
244 .collect();
245
246 Ok(AccountSnapshot {
247 exchange: ExchangeId::HyperliquidSpot,
248 balances,
249 instruments: instrument_snapshots,
250 })
251 }
252
253 async fn account_stream(
266 &self,
267 _assets: &[AssetNameExchange],
268 _instruments: &[InstrumentNameExchange],
269 ) -> Result<Self::AccountStream, UnindexedClientError> {
270 let user = self.wallet_h160();
271 let base_url = self.base_url();
272
273 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
274 .await
275 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
276
277 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
278 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
279
280 ws_client
281 .subscribe(Subscription::UserFills { user }, fills_tx)
282 .await
283 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
284
285 ws_client
286 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
287 .await
288 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
289
290 info!(%user, "Subscribed to Hyperliquid spot account stream");
291
292 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
293 let cancel_token = CancellationToken::new();
294
295 let terminated = Arc::new(AtomicBool::new(false));
299
300 let fills_dedup = new_dedup_cache();
304
305 let fills_event_tx = event_tx.clone();
307 let fills_cancel = cancel_token.clone();
308 let fills_terminated = terminated.clone();
309 tokio::spawn(async move {
310 loop {
311 tokio::select! {
312 biased;
313 () = fills_cancel.cancelled() => {
314 debug!("Spot fills task cancelled");
315 return;
316 }
317 msg = fills_rx.recv() => {
318 let Some(msg) = msg else {
319 debug!("Spot fills receiver closed");
320 if fills_terminated
323 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
324 .is_ok()
325 {
326 emit_stream_terminated(
327 &fills_event_tx,
328 ExchangeId::HyperliquidSpot,
329 StreamTerminationReason::Error(
330 "hyperliquid spot account stream closed".to_string(),
331 ),
332 );
333 }
334 return;
335 };
336 match msg {
337 Message::UserFills(fills) => {
338 for fill in fills.data.fills {
348 if !is_spot_coin(&fill.coin) {
350 continue;
351 }
352 let Some(event) = fill_to_account_event(&fill) else {
353 continue;
354 };
355 if let Some(key) = dedup_key_from_event(&event)
356 && is_duplicate(&fills_dedup, key)
357 {
358 trace!(
359 tid = fill.tid,
360 "Hyperliquid spot dedup: skipping fill already delivered"
361 );
362 continue;
363 }
364 if fills_event_tx.send(event).is_err() {
365 debug!("Spot fills event channel closed");
366 return;
367 }
368 }
369 }
370 Message::NoData => {
371 warn!("Spot UserFills WebSocket disconnected");
372 }
373 Message::HyperliquidError(e) => {
374 error!(%e, "Spot UserFills WebSocket error");
377 }
378 _ => {}
379 }
380 }
381 }
382 }
383 });
384
385 let orders_event_tx = event_tx;
390 let orders_cancel = cancel_token.clone();
391 let orders_terminated = terminated;
392 tokio::spawn(async move {
393 let _ws_client = ws_client;
394
395 loop {
396 tokio::select! {
397 biased;
398 () = orders_cancel.cancelled() => {
399 debug!("Spot orders task cancelled");
400 return;
401 }
402 msg = orders_rx.recv() => {
403 let Some(msg) = msg else {
404 debug!("Spot orders receiver closed");
405 if orders_terminated
408 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
409 .is_ok()
410 {
411 emit_stream_terminated(
412 &orders_event_tx,
413 ExchangeId::HyperliquidSpot,
414 StreamTerminationReason::Error(
415 "hyperliquid spot account stream closed".to_string(),
416 ),
417 );
418 }
419 return;
420 };
421 match msg {
422 Message::OrderUpdates(updates) => {
423 for update in updates.data {
424 if !is_spot_coin(&update.order.coin) {
426 continue;
427 }
428 if let Some(event) = order_update_to_account_event(&update)
429 && orders_event_tx.send(event).is_err()
430 {
431 debug!("Spot orders event channel closed");
432 return;
433 }
434 }
435 }
436 Message::NoData => {
437 warn!("Spot OrderUpdates WebSocket disconnected");
438 }
439 Message::HyperliquidError(e) => {
440 error!(%e, "Spot OrderUpdates WebSocket error");
443 }
444 _ => {}
445 }
446 }
447 }
448 }
449 });
450
451 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
452 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
453 Ok(guarded_stream.boxed())
454 }
455
456 async fn cancel_order(
457 &self,
458 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
459 ) -> Option<UnindexedOrderResponseCancel> {
460 use crate::order::{request::OrderResponseCancel, state::Cancelled};
461 use hyperliquid_rust_sdk::{
462 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
463 };
464 use uuid::Uuid;
465
466 let coin = match instrument_to_spot_coin(request.key.instrument) {
467 Some(c) => c,
468 None => {
469 warn!(
470 instrument = %request.key.instrument,
471 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
472 );
473 return Some(OrderResponseCancel {
474 key: OrderKey {
475 exchange: ExchangeId::HyperliquidSpot,
476 instrument: request.key.instrument.clone(),
477 strategy: request.key.strategy.clone(),
478 cid: request.key.cid.clone(),
479 },
480 state: Err(UnindexedOrderError::Rejected(
481 crate::error::ApiError::OrderRejected(format!(
482 "Invalid instrument format: {}",
483 request.key.instrument
484 )),
485 )),
486 });
487 }
488 };
489
490 enum CancelMethod {
491 ByOid(u64),
492 ByCloid(Uuid),
493 }
494
495 let cancel_method = match &request.state.id {
500 Some(VenueOrderId::Assigned(id)) => {
501 id.0.parse::<u64>()
502 .map(CancelMethod::ByOid)
503 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
504 }
505 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
509 .map(CancelMethod::ByCloid)
510 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
511 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
512 };
513
514 let cancel_method = match cancel_method {
515 Ok(method) => method,
516 Err(reason) => {
517 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
518 return Some(OrderResponseCancel {
519 key: OrderKey {
520 exchange: ExchangeId::HyperliquidSpot,
521 instrument: request.key.instrument.clone(),
522 strategy: request.key.strategy.clone(),
523 cid: request.key.cid.clone(),
524 },
525 state: Err(UnindexedOrderError::Rejected(
526 crate::error::ApiError::OrderRejected(reason.to_string()),
527 )),
528 });
529 }
530 };
531
532 let cancelled_id = match &request.state.id {
535 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
536 None => OrderId(request.key.cid.0.clone()),
537 };
538
539 let response = match cancel_method {
540 CancelMethod::ByOid(oid) => {
541 debug!(oid, "Cancelling spot order by OID");
542 let cancel_request = ClientCancelRequest { asset: coin, oid };
543 self.exchange_client.cancel(cancel_request, None).await
544 }
545 CancelMethod::ByCloid(cloid) => {
546 debug!(%cloid, "Cancelling spot order by cloid (no oid assigned)");
547 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
548 self.exchange_client
549 .cancel_by_cloid(cancel_request, None)
550 .await
551 }
552 };
553
554 let response = match response {
555 Ok(r) => r,
556 Err(e) => {
557 warn!(%e, "Spot cancel order failed (transport)");
558 return Some(OrderResponseCancel {
559 key: OrderKey {
560 exchange: ExchangeId::HyperliquidSpot,
561 instrument: request.key.instrument.clone(),
562 strategy: request.key.strategy.clone(),
563 cid: request.key.cid.clone(),
564 },
565 state: Err(map_order_error(e, request.key.instrument)),
566 });
567 }
568 };
569
570 match response {
571 ExchangeResponseStatus::Ok(_) => {
572 debug!("Spot cancel order accepted");
573 Some(OrderResponseCancel {
574 key: OrderKey {
575 exchange: ExchangeId::HyperliquidSpot,
576 instrument: request.key.instrument.clone(),
577 strategy: request.key.strategy.clone(),
578 cid: request.key.cid.clone(),
579 },
580 state: Ok(Cancelled::new(
581 cancelled_id.clone(),
582 Utc::now(),
584 Decimal::ZERO,
585 )),
586 })
587 }
588 ExchangeResponseStatus::Err(msg) => {
589 warn!(%msg, "Spot cancel rejected by exchange");
590 Some(OrderResponseCancel {
591 key: OrderKey {
592 exchange: ExchangeId::HyperliquidSpot,
593 instrument: request.key.instrument.clone(),
594 strategy: request.key.strategy.clone(),
595 cid: request.key.cid.clone(),
596 },
597 state: Err(UnindexedOrderError::Rejected(
598 crate::error::ApiError::OrderRejected(msg),
599 )),
600 })
601 }
602 }
603 }
604
605 async fn open_order(
606 &self,
607 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
608 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
609 use hyperliquid_rust_sdk::{
610 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
611 ExchangeResponseStatus,
612 };
613
614 let make_rejected =
615 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
616 Order {
617 key: OrderKey {
618 exchange: ExchangeId::HyperliquidSpot,
619 instrument: request.key.instrument.clone(),
620 strategy: request.key.strategy.clone(),
621 cid: request.key.cid.clone(),
622 },
623 side: request.state.side,
624 price: request.state.price,
625 quantity: request.state.quantity,
626 kind: request.state.kind,
627 time_in_force: request.state.time_in_force,
628 state: OrderState::inactive(OrderError::Rejected(
629 crate::error::ApiError::OrderRejected(msg),
630 )),
631 }
632 };
633
634 let make_unsupported =
635 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
636 Order {
637 key: OrderKey {
638 exchange: ExchangeId::HyperliquidSpot,
639 instrument: request.key.instrument.clone(),
640 strategy: request.key.strategy.clone(),
641 cid: request.key.cid.clone(),
642 },
643 side: request.state.side,
644 price: request.state.price,
645 quantity: request.state.quantity,
646 kind: request.state.kind,
647 time_in_force: request.state.time_in_force,
648 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
649 }
650 };
651
652 let coin = match instrument_to_spot_coin(request.key.instrument) {
653 Some(c) => c,
654 None => {
655 warn!(
656 instrument = %request.key.instrument,
657 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
658 );
659 return Some(make_rejected(format!(
660 "Invalid instrument format: {}",
661 request.key.instrument
662 )));
663 }
664 };
665 let is_buy = request.state.side == Side::Buy;
666
667 match request.state.kind {
668 OrderKind::Market => {
669 return Some(make_unsupported(
670 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
671 .to_string(),
672 ));
673 }
674 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
675 return Some(make_unsupported(
676 "Hyperliquid does not support trailing stop orders".to_string(),
677 ));
678 }
679 OrderKind::Limit
680 | OrderKind::Stop { .. }
681 | OrderKind::StopLimit { .. }
682 | OrderKind::TakeProfit { .. }
683 | OrderKind::TakeProfitLimit { .. } => {}
684 }
685
686 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
689 return Some(make_rejected(CLOID_REQUIRED.to_string()));
690 };
691
692 let limit_px = match request.state.kind {
693 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
695 round_to_5_sig_figs(trigger_price)
696 }
697 _ => match request.state.price {
698 Some(p) => round_to_5_sig_figs(p),
699 None => {
700 return Some(make_rejected(
701 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
702 .to_string(),
703 ));
704 }
705 },
706 };
707
708 let sz = round_to_5_sig_figs(request.state.quantity);
709
710 let notional_price = match request.state.kind {
713 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
714 trigger_price
715 }
716 _ => request.state.price.unwrap_or(Decimal::ZERO),
717 };
718 let notional = notional_price * request.state.quantity;
719 if notional < Decimal::TEN {
720 warn!(
721 instrument = %request.key.instrument,
722 %notional,
723 "Spot order below $10 minimum notional value"
724 );
725 return Some(make_rejected(format!(
726 "Spot order notional ${notional} below $10 minimum"
727 )));
728 }
729
730 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
731 warn!(
732 instrument = %request.key.instrument,
733 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
734 );
735 }
736 let tif = map_tif(&request.state.time_in_force).to_string();
737
738 let order_type = match request.state.kind {
740 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
741 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
742 is_market: true,
743 trigger_px: round_to_5_sig_figs(trigger_price),
744 tpsl: "sl".to_string(),
745 }),
746 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
747 is_market: false,
748 trigger_px: round_to_5_sig_figs(trigger_price),
749 tpsl: "sl".to_string(),
750 }),
751 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
752 is_market: true,
753 trigger_px: round_to_5_sig_figs(trigger_price),
754 tpsl: "tp".to_string(),
755 }),
756 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
757 is_market: false,
758 trigger_px: round_to_5_sig_figs(trigger_price),
759 tpsl: "tp".to_string(),
760 }),
761 OrderKind::Market
763 | OrderKind::TrailingStop { .. }
764 | OrderKind::TrailingStopLimit { .. } => {
765 unreachable!("unsupported order kinds rejected earlier")
766 }
767 };
768
769 let order_request = ClientOrderRequest {
770 asset: coin,
771 is_buy,
772 reduce_only: request.state.reduce_only,
773 limit_px,
774 sz,
775 cloid: Some(cloid),
776 order_type,
777 };
778
779 let response = match self.exchange_client.order(order_request, None).await {
780 Ok(r) => r,
781 Err(e) => {
782 warn!(%e, "Spot open order failed");
783 return Some(Order {
784 key: OrderKey {
785 exchange: ExchangeId::HyperliquidSpot,
786 instrument: request.key.instrument.clone(),
787 strategy: request.key.strategy.clone(),
788 cid: request.key.cid.clone(),
789 },
790 side: request.state.side,
791 price: request.state.price,
792 quantity: request.state.quantity,
793 kind: request.state.kind,
794 time_in_force: request.state.time_in_force,
795 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
796 });
797 }
798 };
799
800 let state = match response {
801 ExchangeResponseStatus::Ok(exchange_resp) => {
802 let status = exchange_resp
803 .data
804 .and_then(|d| d.statuses.into_iter().next());
805
806 match status {
807 Some(ExchangeDataStatus::Resting(resting)) => {
808 debug!(oid = resting.oid, "Spot order resting");
809 OrderState::active(Open {
810 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
811 time_exchange: Utc::now(),
813 filled_quantity: Decimal::ZERO,
814 })
815 }
816 Some(ExchangeDataStatus::Filled(filled)) => {
817 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
818 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
819 OrderState::fully_filled(Filled::new(
820 OrderId(format_smolstr!("{}", filled.oid)),
821 Utc::now(),
823 parse_decimal(&filled.total_sz, "total_sz")
824 .unwrap_or(request.state.quantity),
825 avg_price,
826 ))
827 }
828 Some(ExchangeDataStatus::Error(msg)) => {
829 warn!(%msg, "Spot order rejected by exchange");
830 OrderState::inactive(OrderError::Rejected(
831 crate::error::ApiError::OrderRejected(msg),
832 ))
833 }
834 Some(
835 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
836 ) => {
837 debug!(cid = %request.key.cid, "Spot order waiting under its cloid");
841 OrderState::active(Open {
842 id: VenueOrderId::ClientAssigned,
843 time_exchange: Utc::now(),
844 filled_quantity: Decimal::ZERO,
845 })
846 }
847 Some(ExchangeDataStatus::Success) | None => {
848 warn!("Spot order accepted but no order ID returned");
849 OrderState::inactive(OrderError::Rejected(
850 crate::error::ApiError::OrderRejected(
851 "no order ID in response".to_string(),
852 ),
853 ))
854 }
855 }
856 }
857 ExchangeResponseStatus::Err(msg) => {
858 warn!(%msg, "Spot order rejected");
859 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
860 msg,
861 )))
862 }
863 };
864
865 Some(Order {
866 key: OrderKey {
867 exchange: ExchangeId::HyperliquidSpot,
868 instrument: request.key.instrument.clone(),
869 strategy: request.key.strategy.clone(),
870 cid: request.key.cid.clone(),
871 },
872 side: request.state.side,
873 price: request.state.price,
874 quantity: request.state.quantity,
875 kind: request.state.kind,
876 time_in_force: request.state.time_in_force,
877 state,
878 })
879 }
880
881 async fn fetch_balances(
882 &self,
883 _assets: &[AssetNameExchange],
884 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
885 let address = self.wallet_h160();
886
887 let token_balances = self
888 .info_client
889 .user_token_balances(address)
890 .await
891 .map_err(map_sdk_error)?;
892
893 let now = Utc::now();
894 Ok(parse_token_balances(&token_balances.balances, now))
895 }
896
897 async fn fetch_open_orders(
898 &self,
899 instruments: &[InstrumentNameExchange],
900 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
901 let address = self.wallet_h160();
902
903 let open_orders = open_orders(&self.info_client, address).await?;
904
905 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
906 None
907 } else {
908 let mut set = HashSet::with_capacity(instruments.len());
909 set.extend(instruments.iter().cloned());
910 Some(set)
911 };
912
913 Ok(open_orders
914 .iter()
915 .filter_map(|order| {
916 if !is_spot_coin(&order.coin) {
917 return None;
918 }
919 let instrument = spot_coin_to_instrument(&order.coin);
920 if instrument_filter
921 .as_ref()
922 .is_some_and(|f| !f.contains(&instrument))
923 {
924 return None;
925 }
926 open_order_to_order(order, ExchangeId::HyperliquidSpot, instrument)
927 })
928 .collect())
929 }
930
931 async fn fetch_trades(
932 &self,
933 time_since: DateTime<Utc>,
934 instruments: &[InstrumentNameExchange],
935 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
936 let address = self.wallet_h160();
937
938 let fills = user_fills(&self.info_client, address).await?;
939
940 #[allow(clippy::cast_sign_loss)]
942 let time_since_ms = time_since.timestamp_millis().max(0) as u64;
943
944 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
945 None
946 } else {
947 let mut set = HashSet::with_capacity(instruments.len());
948 set.extend(instruments.iter().cloned());
949 Some(set)
950 };
951
952 let mut result = Vec::new();
953 for fill in fills {
954 if fill.time < time_since_ms {
956 continue;
957 }
958
959 let Some((base_asset, quote_asset)) = fill.coin.split_once('/') else {
961 continue;
962 };
963
964 let instrument = spot_coin_to_instrument(&fill.coin);
965
966 if instrument_filter
967 .as_ref()
968 .is_some_and(|f| !f.contains(&instrument))
969 {
970 continue;
971 }
972
973 let Some(side) = parse_side(&fill.side) else {
974 continue;
975 };
976 let Some(price) = parse_decimal(&fill.px, "px") else {
977 continue;
978 };
979 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
980 continue;
981 };
982 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
983
984 let Some(time_exchange) = millis_to_datetime(fill.time) else {
985 warn!(time = fill.time, "Invalid fill timestamp, skipping");
986 continue;
987 };
988
989 let fee_asset = fill
994 .fee_token
995 .as_deref()
996 .unwrap_or(if matches!(side, Side::Buy) {
997 base_asset
998 } else {
999 quote_asset
1000 });
1001
1002 result.push(Trade {
1003 id: TradeId(format_smolstr!("{}", fill.tid)),
1004 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1005 instrument,
1006 strategy: StrategyId::unknown(),
1007 time_exchange,
1008 side,
1009 price,
1010 quantity,
1011 order_filled_quantity: None,
1014 fees: AssetFees {
1015 asset: AssetNameExchange::from(fee_asset),
1016 fees: fee,
1017 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1024 Some(fee)
1025 } else {
1026 None
1027 },
1028 },
1029 });
1030 }
1031
1032 Ok(result)
1033 }
1034}
1035
1036fn parse_token_balances(
1038 balances: &[hyperliquid_rust_sdk::UserTokenBalance],
1039 now: DateTime<Utc>,
1040) -> Vec<AssetBalance<AssetNameExchange>> {
1041 balances
1042 .iter()
1043 .map(|bal| {
1044 let total = parse_decimal(&bal.total, "total").unwrap_or(Decimal::ZERO);
1045 let hold = parse_decimal(&bal.hold, "hold").unwrap_or(Decimal::ZERO);
1046 let free = (total - hold).max(Decimal::ZERO);
1047
1048 AssetBalance::new(
1049 AssetNameExchange::from(bal.coin.as_str()),
1050 Balance::new(total, free),
1051 now,
1052 )
1053 })
1054 .collect()
1055}
1056
1057fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1059 let side = parse_side(&fill.side)?;
1060 let price = parse_decimal(&fill.px, "fill.px")?;
1061 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1062 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1063 let time_exchange = millis_to_datetime(fill.time)?;
1064
1065 let (_base, quote_asset) = fill.coin.split_once('/').unwrap_or(("", "USDC"));
1067 let instrument = spot_coin_to_instrument(&fill.coin);
1068 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1069
1070 let fee_asset = fill.fee_token.as_str();
1073
1074 let trade = Trade {
1075 id: TradeId(format_smolstr!("{}", fill.tid)),
1076 order_id,
1077 instrument,
1078 strategy: StrategyId::unknown(),
1079 time_exchange,
1080 side,
1081 price,
1082 quantity,
1083 order_filled_quantity: None,
1086 fees: AssetFees {
1087 asset: AssetNameExchange::from(fee_asset),
1088 fees: fee,
1089 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1092 Some(fee)
1093 } else {
1094 None
1095 },
1096 },
1097 };
1098
1099 Some(AccountEvent::new(
1100 ExchangeId::HyperliquidSpot,
1101 AccountEventKind::Trade(trade),
1102 ))
1103}
1104
1105fn order_update_to_account_event(
1107 update: &hyperliquid_rust_sdk::OrderUpdate,
1108) -> Option<UnindexedAccountEvent> {
1109 super::common::order_update_to_account_event(
1110 update,
1111 ExchangeId::HyperliquidSpot,
1112 spot_coin_to_instrument(&update.order.coin),
1113 )
1114}
1115
1116#[cfg(test)]
1117#[allow(clippy::unwrap_used)]
1119mod tests {
1120 use super::*;
1121 use rust_decimal_macros::dec;
1122 use rustrade_integration::collection::snapshot::Snapshot;
1123
1124 #[test]
1125 fn test_fill_to_account_event_spot() {
1126 let fill_json = r#"{
1127 "coin": "PURR/USDC",
1128 "side": "B",
1129 "px": "0.05",
1130 "sz": "1000",
1131 "time": 1714100000000,
1132 "hash": "0xspot123",
1133 "startPosition": "0",
1134 "dir": "Open Long",
1135 "closedPnl": "0",
1136 "oid": 12345,
1137 "cloid": null,
1138 "crossed": false,
1139 "fee": "0.025",
1140 "feeToken": "USDC",
1141 "tid": 99999
1142 }"#;
1143
1144 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1145 let event = fill_to_account_event(&fill).unwrap();
1146
1147 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1148 match event.kind {
1149 AccountEventKind::Trade(trade) => {
1150 assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1151 assert_eq!(
1152 trade.id.0, "99999",
1153 "the id is the fill's tid, not its hash"
1154 );
1155 assert_eq!(trade.side, Side::Buy);
1156 assert_eq!(trade.price, dec!(0.05));
1157 assert_eq!(trade.quantity, dec!(1000));
1158 assert_eq!(trade.fees.fees, dec!(0.025));
1159 assert_eq!(trade.fees.asset.as_ref(), "USDC");
1160 }
1161 _ => panic!("Expected Trade event"),
1162 }
1163 }
1164
1165 fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1167 let json = format!(
1168 r#"{{
1169 "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1170 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1171 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1172 "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1173 }}"#
1174 );
1175 serde_json::from_str(&json).unwrap()
1176 }
1177
1178 #[test]
1179 fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1180 let first = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1181 let second = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1182
1183 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1184 (first.kind, second.kind)
1185 else {
1186 panic!("Expected Trade events");
1187 };
1188
1189 assert_eq!(first.order_id, second.order_id, "one order produced both");
1190 assert_ne!(first.id, second.id, "but they are distinct fills");
1191 }
1192
1193 #[test]
1194 fn a_replayed_spot_fill_is_delivered_once() {
1195 let cache = new_dedup_cache();
1196 let event = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1197 let replay = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1198
1199 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1200 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1201 let sibling = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1203 assert!(!is_duplicate(
1204 &cache,
1205 dedup_key_from_event(&sibling).unwrap()
1206 ));
1207 }
1208
1209 #[test]
1210 fn test_order_update_to_account_event_spot() {
1211 let update_json = r#"{
1212 "order": {
1213 "coin": "HYPE/USDC",
1214 "side": "A",
1215 "limitPx": "25.5",
1216 "sz": "10",
1217 "oid": 12346,
1218 "timestamp": 1714100000000,
1219 "origSz": "10",
1220 "cloid": null
1221 },
1222 "status": "open",
1223 "statusTimestamp": 1714100000000
1224 }"#;
1225
1226 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1227 let event = order_update_to_account_event(&update).unwrap();
1228
1229 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1230 match event.kind {
1231 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1232 assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
1233 assert_eq!(order.side, Side::Sell);
1234 assert_eq!(order.price, Some(dec!(25.5)));
1235 assert_eq!(order.quantity, dec!(10));
1236 assert!(matches!(
1237 order.state,
1238 crate::order::state::OrderState::Active(_)
1239 ));
1240 }
1241 _ => panic!("Expected OrderSnapshot event"),
1242 }
1243 }
1244}