1use super::common::{
40 CancelOnDropStream, cid_to_cloid, instrument_to_spot_coin, is_spot_coin, map_tif,
41 millis_to_datetime, parse_decimal, parse_side, round_to_5_sig_figs, spot_coin_to_instrument,
42 user_fills,
43};
44use super::config::HyperliquidConfig;
45use super::error::{map_order_error, map_sdk_error};
46use crate::client::dedup::{dedup_key_from_event, is_duplicate, new_dedup_cache};
47use crate::{
48 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
49 UnindexedAccountEvent, UnindexedAccountSnapshot,
50 balance::{AssetBalance, Balance},
51 client::ExecutionClient,
52 emit_stream_terminated,
53 error::{
54 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
55 UnindexedOrderError,
56 },
57 order::{
58 Order, OrderKey, OrderKind, TimeInForce,
59 id::{ClientOrderId, OrderId, StrategyId},
60 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
61 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
62 },
63 trade::{AssetFees, Trade, TradeId},
64};
65use chrono::{DateTime, Utc};
66use ethers::signers::Signer;
67use futures::{StreamExt, stream::BoxStream};
68use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
69use rust_decimal::Decimal;
70use rustrade_instrument::{
71 Side,
72 asset::name::AssetNameExchange,
73 exchange::ExchangeId,
74 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
75};
76use rustrade_integration::collection::snapshot::Snapshot;
77use smol_str::{SmolStr, format_smolstr};
78use std::{
79 collections::HashSet,
80 sync::{
81 Arc,
82 atomic::{AtomicBool, Ordering},
83 },
84};
85use tokio::sync::mpsc;
86use tokio_util::sync::CancellationToken;
87use tracing::{debug, error, info, trace, warn};
88
89#[derive(Debug, Clone)]
94pub struct HyperliquidSpotClient {
95 config: HyperliquidConfig,
96 info_client: Arc<InfoClient>,
97 exchange_client: Arc<ExchangeClient>,
98}
99
100impl HyperliquidSpotClient {
101 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
106 let base_url = if config.testnet {
107 BaseUrl::Testnet
108 } else {
109 BaseUrl::Mainnet
110 };
111
112 let info_client = InfoClient::new(None, Some(base_url))
113 .await
114 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
115
116 let wallet = config.wallet.clone();
117 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
118 .await
119 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
120
121 info!(
122 testnet = config.testnet,
123 wallet = %config.wallet_address_hex(),
124 "Created HyperliquidSpotClient"
125 );
126
127 Ok(Self {
128 config,
129 info_client: Arc::new(info_client),
130 exchange_client: Arc::new(exchange_client),
131 })
132 }
133
134 fn base_url(&self) -> BaseUrl {
136 if self.config.testnet {
137 BaseUrl::Testnet
138 } else {
139 BaseUrl::Mainnet
140 }
141 }
142
143 pub fn wallet_address(&self) -> String {
145 self.config.wallet_address_hex()
146 }
147
148 fn wallet_h160(&self) -> ethers::types::H160 {
150 self.config.wallet.address()
151 }
152}
153
154impl ExecutionClient for HyperliquidSpotClient {
155 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
156
157 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
159 &[InstrumentKindDiscriminant::Spot];
160
161 type Config = HyperliquidConfig;
162 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
163
164 fn new(config: Self::Config) -> Self {
177 let base_url = if config.testnet {
178 BaseUrl::Testnet
179 } else {
180 BaseUrl::Mainnet
181 };
182
183 let handle = tokio::runtime::Handle::current();
184
185 let info_client = handle.block_on(async {
186 InfoClient::new(None, Some(base_url))
187 .await
188 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
189 });
190
191 let wallet = config.wallet.clone();
192 let exchange_client = handle.block_on(async {
193 ExchangeClient::new(None, wallet, Some(base_url), None, None)
194 .await
195 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
196 });
197
198 info!(
199 testnet = config.testnet,
200 wallet = %config.wallet_address_hex(),
201 "Created HyperliquidSpotClient"
202 );
203
204 Self {
205 config,
206 info_client: Arc::new(info_client),
207 exchange_client: Arc::new(exchange_client),
208 }
209 }
210
211 async fn account_snapshot(
212 &self,
213 _assets: &[AssetNameExchange],
214 instruments: &[InstrumentNameExchange],
215 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
216 let address = self.wallet_h160();
217
218 let (token_balances, open_orders) = tokio::try_join!(
220 async {
221 self.info_client
222 .user_token_balances(address)
223 .await
224 .map_err(map_sdk_error)
225 },
226 async {
227 self.info_client
228 .open_orders(address)
229 .await
230 .map_err(map_sdk_error)
231 }
232 )?;
233
234 let now = Utc::now();
235 let balances = parse_token_balances(&token_balances.balances, now);
236
237 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
239 None
240 } else {
241 let mut set = HashSet::with_capacity(instruments.len());
242 set.extend(instruments.iter().cloned());
243 Some(set)
244 };
245
246 let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
248 std::collections::HashMap::new();
249
250 for order in &open_orders {
251 if !is_spot_coin(&order.coin) {
253 continue;
254 }
255
256 let instrument = spot_coin_to_instrument(&order.coin);
257 if instrument_filter
258 .as_ref()
259 .is_some_and(|f| !f.contains(&instrument))
260 {
261 continue;
262 }
263
264 let Some(side) = parse_side(&order.side) else {
265 continue;
266 };
267 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
268 continue;
269 };
270 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
271 continue;
272 };
273 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
274 warn!(
275 oid = order.oid,
276 timestamp = order.timestamp,
277 "Invalid order timestamp, skipping"
278 );
279 continue;
280 };
281
282 let order_id = format_smolstr!("{}", order.oid);
283 let order_snapshot = Order {
284 key: OrderKey {
285 exchange: ExchangeId::HyperliquidSpot,
286 instrument: instrument.clone(),
287 strategy: StrategyId::unknown(),
288 cid: ClientOrderId::new(order_id.clone()),
289 },
290 side,
291 price: Some(price),
292 quantity,
293 kind: OrderKind::Limit,
294 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
295 state: crate::order::state::OrderState::active(Open {
296 id: OrderId(order_id),
297 time_exchange,
298 filled_quantity: Decimal::ZERO,
299 }),
300 };
301
302 orders_by_instrument
303 .entry(instrument)
304 .or_default()
305 .push(order_snapshot);
306 }
307
308 let instrument_snapshots: Vec<_> = orders_by_instrument
310 .into_iter()
311 .map(|(instrument, orders)| InstrumentAccountSnapshot {
312 instrument,
313 orders,
314 position: None, isolated: None,
316 })
317 .collect();
318
319 Ok(AccountSnapshot {
320 exchange: ExchangeId::HyperliquidSpot,
321 balances,
322 instruments: instrument_snapshots,
323 })
324 }
325
326 async fn account_stream(
339 &self,
340 _assets: &[AssetNameExchange],
341 _instruments: &[InstrumentNameExchange],
342 ) -> Result<Self::AccountStream, UnindexedClientError> {
343 let user = self.wallet_h160();
344 let base_url = self.base_url();
345
346 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
347 .await
348 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
349
350 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
351 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
352
353 ws_client
354 .subscribe(Subscription::UserFills { user }, fills_tx)
355 .await
356 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
357
358 ws_client
359 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
360 .await
361 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
362
363 info!(%user, "Subscribed to Hyperliquid spot account stream");
364
365 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
366 let cancel_token = CancellationToken::new();
367
368 let terminated = Arc::new(AtomicBool::new(false));
372
373 let fills_dedup = new_dedup_cache();
377
378 let fills_event_tx = event_tx.clone();
380 let fills_cancel = cancel_token.clone();
381 let fills_terminated = terminated.clone();
382 tokio::spawn(async move {
383 loop {
384 tokio::select! {
385 biased;
386 () = fills_cancel.cancelled() => {
387 debug!("Spot fills task cancelled");
388 return;
389 }
390 msg = fills_rx.recv() => {
391 let Some(msg) = msg else {
392 debug!("Spot fills receiver closed");
393 if fills_terminated
396 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
397 .is_ok()
398 {
399 emit_stream_terminated(
400 &fills_event_tx,
401 ExchangeId::HyperliquidSpot,
402 StreamTerminationReason::Error(
403 "hyperliquid spot account stream closed".to_string(),
404 ),
405 );
406 }
407 return;
408 };
409 match msg {
410 Message::UserFills(fills) => {
411 for fill in fills.data.fills {
421 if !is_spot_coin(&fill.coin) {
423 continue;
424 }
425 let Some(event) = fill_to_account_event(&fill) else {
426 continue;
427 };
428 if let Some(key) = dedup_key_from_event(&event)
429 && is_duplicate(&fills_dedup, key)
430 {
431 trace!(
432 tid = fill.tid,
433 "Hyperliquid spot dedup: skipping fill already delivered"
434 );
435 continue;
436 }
437 if fills_event_tx.send(event).is_err() {
438 debug!("Spot fills event channel closed");
439 return;
440 }
441 }
442 }
443 Message::NoData => {
444 warn!("Spot UserFills WebSocket disconnected");
445 }
446 Message::HyperliquidError(e) => {
447 error!(%e, "Spot UserFills WebSocket error");
450 }
451 _ => {}
452 }
453 }
454 }
455 }
456 });
457
458 let orders_event_tx = event_tx;
463 let orders_cancel = cancel_token.clone();
464 let orders_terminated = terminated;
465 tokio::spawn(async move {
466 let _ws_client = ws_client;
467
468 loop {
469 tokio::select! {
470 biased;
471 () = orders_cancel.cancelled() => {
472 debug!("Spot orders task cancelled");
473 return;
474 }
475 msg = orders_rx.recv() => {
476 let Some(msg) = msg else {
477 debug!("Spot orders receiver closed");
478 if orders_terminated
481 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
482 .is_ok()
483 {
484 emit_stream_terminated(
485 &orders_event_tx,
486 ExchangeId::HyperliquidSpot,
487 StreamTerminationReason::Error(
488 "hyperliquid spot account stream closed".to_string(),
489 ),
490 );
491 }
492 return;
493 };
494 match msg {
495 Message::OrderUpdates(updates) => {
496 for update in updates.data {
497 if !is_spot_coin(&update.order.coin) {
499 continue;
500 }
501 if let Some(event) = order_update_to_account_event(&update)
502 && orders_event_tx.send(event).is_err()
503 {
504 debug!("Spot orders event channel closed");
505 return;
506 }
507 }
508 }
509 Message::NoData => {
510 warn!("Spot OrderUpdates WebSocket disconnected");
511 }
512 Message::HyperliquidError(e) => {
513 error!(%e, "Spot OrderUpdates WebSocket error");
516 }
517 _ => {}
518 }
519 }
520 }
521 }
522 });
523
524 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
525 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
526 Ok(guarded_stream.boxed())
527 }
528
529 async fn cancel_order(
530 &self,
531 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
532 ) -> Option<UnindexedOrderResponseCancel> {
533 use crate::order::{request::OrderResponseCancel, state::Cancelled};
534 use hyperliquid_rust_sdk::{
535 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
536 };
537 use uuid::Uuid;
538
539 let coin = match instrument_to_spot_coin(request.key.instrument) {
540 Some(c) => c,
541 None => {
542 warn!(
543 instrument = %request.key.instrument,
544 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
545 );
546 return Some(OrderResponseCancel {
547 key: OrderKey {
548 exchange: ExchangeId::HyperliquidSpot,
549 instrument: request.key.instrument.clone(),
550 strategy: request.key.strategy.clone(),
551 cid: request.key.cid.clone(),
552 },
553 state: Err(UnindexedOrderError::Rejected(
554 crate::error::ApiError::OrderRejected(format!(
555 "Invalid instrument format: {}",
556 request.key.instrument
557 )),
558 )),
559 });
560 }
561 };
562
563 let order_id = match &request.state.id {
564 Some(id) => id,
565 None => {
566 warn!("Cancel request missing order ID");
567 return Some(OrderResponseCancel {
568 key: OrderKey {
569 exchange: ExchangeId::HyperliquidSpot,
570 instrument: request.key.instrument.clone(),
571 strategy: request.key.strategy.clone(),
572 cid: request.key.cid.clone(),
573 },
574 state: Err(UnindexedOrderError::Rejected(
575 crate::error::ApiError::OrderRejected("Missing order ID".to_string()),
576 )),
577 });
578 }
579 };
580
581 enum CancelMethod {
586 ByOid(u64),
587 ByCloid(Uuid),
588 }
589
590 let cancel_method = if let Ok(oid) = order_id.0.parse::<u64>() {
591 CancelMethod::ByOid(oid)
592 } else if let Ok(uuid) = Uuid::parse_str(&order_id.0) {
593 CancelMethod::ByCloid(uuid)
594 } else {
595 warn!(?order_id, "Order ID is neither u64 (OID) nor UUID (cloid)");
596 return Some(OrderResponseCancel {
597 key: OrderKey {
598 exchange: ExchangeId::HyperliquidSpot,
599 instrument: request.key.instrument.clone(),
600 strategy: request.key.strategy.clone(),
601 cid: request.key.cid.clone(),
602 },
603 state: Err(UnindexedOrderError::Rejected(
604 crate::error::ApiError::OrderRejected(
605 "Invalid order ID: must be numeric OID or UUID (for trigger orders)"
606 .to_string(),
607 ),
608 )),
609 });
610 };
611
612 let response = match cancel_method {
613 CancelMethod::ByOid(oid) => {
614 debug!(oid, "Cancelling spot order by OID");
615 let cancel_request = ClientCancelRequest { asset: coin, oid };
616 self.exchange_client.cancel(cancel_request, None).await
617 }
618 CancelMethod::ByCloid(cloid) => {
619 debug!(%cloid, "Cancelling spot order by cloid (trigger order)");
620 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
621 self.exchange_client
622 .cancel_by_cloid(cancel_request, None)
623 .await
624 }
625 };
626
627 let response = match response {
628 Ok(r) => r,
629 Err(e) => {
630 warn!(%e, "Spot cancel order failed (transport)");
631 return Some(OrderResponseCancel {
632 key: OrderKey {
633 exchange: ExchangeId::HyperliquidSpot,
634 instrument: request.key.instrument.clone(),
635 strategy: request.key.strategy.clone(),
636 cid: request.key.cid.clone(),
637 },
638 state: Err(map_order_error(e, request.key.instrument)),
639 });
640 }
641 };
642
643 match response {
644 ExchangeResponseStatus::Ok(_) => {
645 debug!("Spot cancel order accepted");
646 Some(OrderResponseCancel {
647 key: OrderKey {
648 exchange: ExchangeId::HyperliquidSpot,
649 instrument: request.key.instrument.clone(),
650 strategy: request.key.strategy.clone(),
651 cid: request.key.cid.clone(),
652 },
653 state: Ok(Cancelled::new(
654 order_id.clone(),
655 Utc::now(),
657 Decimal::ZERO,
658 )),
659 })
660 }
661 ExchangeResponseStatus::Err(msg) => {
662 warn!(%msg, "Spot cancel rejected by exchange");
663 Some(OrderResponseCancel {
664 key: OrderKey {
665 exchange: ExchangeId::HyperliquidSpot,
666 instrument: request.key.instrument.clone(),
667 strategy: request.key.strategy.clone(),
668 cid: request.key.cid.clone(),
669 },
670 state: Err(UnindexedOrderError::Rejected(
671 crate::error::ApiError::OrderRejected(msg),
672 )),
673 })
674 }
675 }
676 }
677
678 async fn open_order(
679 &self,
680 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
681 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
682 use hyperliquid_rust_sdk::{
683 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
684 ExchangeResponseStatus,
685 };
686
687 let make_rejected =
688 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
689 Order {
690 key: OrderKey {
691 exchange: ExchangeId::HyperliquidSpot,
692 instrument: request.key.instrument.clone(),
693 strategy: request.key.strategy.clone(),
694 cid: request.key.cid.clone(),
695 },
696 side: request.state.side,
697 price: request.state.price,
698 quantity: request.state.quantity,
699 kind: request.state.kind,
700 time_in_force: request.state.time_in_force,
701 state: OrderState::inactive(OrderError::Rejected(
702 crate::error::ApiError::OrderRejected(msg),
703 )),
704 }
705 };
706
707 let make_unsupported =
708 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
709 Order {
710 key: OrderKey {
711 exchange: ExchangeId::HyperliquidSpot,
712 instrument: request.key.instrument.clone(),
713 strategy: request.key.strategy.clone(),
714 cid: request.key.cid.clone(),
715 },
716 side: request.state.side,
717 price: request.state.price,
718 quantity: request.state.quantity,
719 kind: request.state.kind,
720 time_in_force: request.state.time_in_force,
721 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
722 }
723 };
724
725 let coin = match instrument_to_spot_coin(request.key.instrument) {
726 Some(c) => c,
727 None => {
728 warn!(
729 instrument = %request.key.instrument,
730 "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
731 );
732 return Some(make_rejected(format!(
733 "Invalid instrument format: {}",
734 request.key.instrument
735 )));
736 }
737 };
738 let is_buy = request.state.side == Side::Buy;
739
740 match request.state.kind {
741 OrderKind::Market => {
742 return Some(make_unsupported(
743 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
744 .to_string(),
745 ));
746 }
747 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
748 return Some(make_unsupported(
749 "Hyperliquid does not support trailing stop orders".to_string(),
750 ));
751 }
752 OrderKind::Limit
753 | OrderKind::Stop { .. }
754 | OrderKind::StopLimit { .. }
755 | OrderKind::TakeProfit { .. }
756 | OrderKind::TakeProfitLimit { .. } => {}
757 }
758
759 let cloid = cid_to_cloid(&request.key.cid);
760 let cloid_is_some = cloid.is_some();
761 let is_trigger_order = matches!(
762 request.state.kind,
763 OrderKind::Stop { .. }
764 | OrderKind::StopLimit { .. }
765 | OrderKind::TakeProfit { .. }
766 | OrderKind::TakeProfitLimit { .. }
767 );
768 if is_trigger_order && cloid.is_none() {
769 return Some(make_rejected(
770 "Trigger orders require UUID-format client order ID (use ClientOrderId::uuid()). \
771 Non-UUID IDs cannot be cancelled via cancel_by_cloid()."
772 .to_string(),
773 ));
774 }
775
776 let limit_px = match request.state.kind {
777 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
779 round_to_5_sig_figs(trigger_price)
780 }
781 _ => match request.state.price {
782 Some(p) => round_to_5_sig_figs(p),
783 None => {
784 return Some(make_rejected(
785 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
786 .to_string(),
787 ));
788 }
789 },
790 };
791
792 let sz = round_to_5_sig_figs(request.state.quantity);
793
794 let notional_price = match request.state.kind {
797 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
798 trigger_price
799 }
800 _ => request.state.price.unwrap_or(Decimal::ZERO),
801 };
802 let notional = notional_price * request.state.quantity;
803 if notional < Decimal::TEN {
804 warn!(
805 instrument = %request.key.instrument,
806 %notional,
807 "Spot order below $10 minimum notional value"
808 );
809 return Some(make_rejected(format!(
810 "Spot order notional ${notional} below $10 minimum"
811 )));
812 }
813
814 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
815 warn!(
816 instrument = %request.key.instrument,
817 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
818 );
819 }
820 let tif = map_tif(&request.state.time_in_force).to_string();
821
822 let order_type = match request.state.kind {
824 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
825 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
826 is_market: true,
827 trigger_px: round_to_5_sig_figs(trigger_price),
828 tpsl: "sl".to_string(),
829 }),
830 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
831 is_market: false,
832 trigger_px: round_to_5_sig_figs(trigger_price),
833 tpsl: "sl".to_string(),
834 }),
835 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
836 is_market: true,
837 trigger_px: round_to_5_sig_figs(trigger_price),
838 tpsl: "tp".to_string(),
839 }),
840 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
841 is_market: false,
842 trigger_px: round_to_5_sig_figs(trigger_price),
843 tpsl: "tp".to_string(),
844 }),
845 OrderKind::Market
847 | OrderKind::TrailingStop { .. }
848 | OrderKind::TrailingStopLimit { .. } => {
849 unreachable!("unsupported order kinds rejected earlier")
850 }
851 };
852
853 let order_request = ClientOrderRequest {
854 asset: coin,
855 is_buy,
856 reduce_only: request.state.reduce_only,
857 limit_px,
858 sz,
859 cloid,
860 order_type,
861 };
862
863 let response = match self.exchange_client.order(order_request, None).await {
864 Ok(r) => r,
865 Err(e) => {
866 warn!(%e, "Spot open order failed");
867 return Some(Order {
868 key: OrderKey {
869 exchange: ExchangeId::HyperliquidSpot,
870 instrument: request.key.instrument.clone(),
871 strategy: request.key.strategy.clone(),
872 cid: request.key.cid.clone(),
873 },
874 side: request.state.side,
875 price: request.state.price,
876 quantity: request.state.quantity,
877 kind: request.state.kind,
878 time_in_force: request.state.time_in_force,
879 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
880 });
881 }
882 };
883
884 let state = match response {
885 ExchangeResponseStatus::Ok(exchange_resp) => {
886 let status = exchange_resp
887 .data
888 .and_then(|d| d.statuses.into_iter().next());
889
890 match status {
891 Some(ExchangeDataStatus::Resting(resting)) => {
892 debug!(oid = resting.oid, "Spot order resting");
893 OrderState::active(Open {
894 id: OrderId(format_smolstr!("{}", resting.oid)),
895 time_exchange: Utc::now(),
897 filled_quantity: Decimal::ZERO,
898 })
899 }
900 Some(ExchangeDataStatus::Filled(filled)) => {
901 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
902 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
903 OrderState::fully_filled(Filled::new(
904 OrderId(format_smolstr!("{}", filled.oid)),
905 Utc::now(),
907 parse_decimal(&filled.total_sz, "total_sz")
908 .unwrap_or(request.state.quantity),
909 avg_price,
910 ))
911 }
912 Some(ExchangeDataStatus::Error(msg)) => {
913 warn!(%msg, "Spot order rejected by exchange");
914 OrderState::inactive(OrderError::Rejected(
915 crate::error::ApiError::OrderRejected(msg),
916 ))
917 }
918 Some(
919 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
920 ) => {
921 if cloid_is_some {
927 debug!(cloid = %request.key.cid.0, "Spot order waiting (cloid trackable)");
928 OrderState::active(Open {
929 id: OrderId(request.key.cid.0.clone()),
930 time_exchange: Utc::now(),
931 filled_quantity: Decimal::ZERO,
932 })
933 } else {
934 warn!("Spot order accepted but cid is not UUID — untrackable");
935 OrderState::inactive(OrderError::Rejected(
936 crate::error::ApiError::OrderRejected(
937 "order accepted but cid is not UUID; cannot track for cancel"
938 .to_string(),
939 ),
940 ))
941 }
942 }
943 Some(ExchangeDataStatus::Success) | None => {
944 warn!("Spot order accepted but no order ID returned");
945 OrderState::inactive(OrderError::Rejected(
946 crate::error::ApiError::OrderRejected(
947 "no order ID in response".to_string(),
948 ),
949 ))
950 }
951 }
952 }
953 ExchangeResponseStatus::Err(msg) => {
954 warn!(%msg, "Spot order rejected");
955 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
956 msg,
957 )))
958 }
959 };
960
961 Some(Order {
962 key: OrderKey {
963 exchange: ExchangeId::HyperliquidSpot,
964 instrument: request.key.instrument.clone(),
965 strategy: request.key.strategy.clone(),
966 cid: request.key.cid.clone(),
967 },
968 side: request.state.side,
969 price: request.state.price,
970 quantity: request.state.quantity,
971 kind: request.state.kind,
972 time_in_force: request.state.time_in_force,
973 state,
974 })
975 }
976
977 async fn fetch_balances(
978 &self,
979 _assets: &[AssetNameExchange],
980 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
981 let address = self.wallet_h160();
982
983 let token_balances = self
984 .info_client
985 .user_token_balances(address)
986 .await
987 .map_err(map_sdk_error)?;
988
989 let now = Utc::now();
990 Ok(parse_token_balances(&token_balances.balances, now))
991 }
992
993 async fn fetch_open_orders(
994 &self,
995 instruments: &[InstrumentNameExchange],
996 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
997 let address = self.wallet_h160();
998
999 let open_orders = self
1000 .info_client
1001 .open_orders(address)
1002 .await
1003 .map_err(map_sdk_error)?;
1004
1005 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1006 None
1007 } else {
1008 let mut set = HashSet::with_capacity(instruments.len());
1009 set.extend(instruments.iter().cloned());
1010 Some(set)
1011 };
1012
1013 let mut result = Vec::new();
1014 for order in open_orders {
1015 if !is_spot_coin(&order.coin) {
1017 continue;
1018 }
1019
1020 let instrument = spot_coin_to_instrument(&order.coin);
1021
1022 if instrument_filter
1023 .as_ref()
1024 .is_some_and(|f| !f.contains(&instrument))
1025 {
1026 continue;
1027 }
1028
1029 let Some(side) = parse_side(&order.side) else {
1030 continue;
1031 };
1032 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
1033 continue;
1034 };
1035 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
1036 continue;
1037 };
1038 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
1039 warn!(
1040 oid = order.oid,
1041 timestamp = order.timestamp,
1042 "Invalid order timestamp, skipping"
1043 );
1044 continue;
1045 };
1046
1047 let order_id = format_smolstr!("{}", order.oid);
1048 result.push(Order {
1049 key: OrderKey {
1050 exchange: ExchangeId::HyperliquidSpot,
1051 instrument,
1052 strategy: StrategyId::unknown(),
1053 cid: ClientOrderId::new(order_id.clone()),
1054 },
1055 side,
1056 price: Some(price),
1057 quantity,
1058 kind: OrderKind::Limit,
1059 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1060 state: Open {
1061 id: OrderId(order_id),
1062 time_exchange,
1063 filled_quantity: Decimal::ZERO,
1064 },
1065 });
1066 }
1067
1068 Ok(result)
1069 }
1070
1071 async fn fetch_trades(
1072 &self,
1073 time_since: DateTime<Utc>,
1074 instruments: &[InstrumentNameExchange],
1075 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1076 let address = self.wallet_h160();
1077
1078 let fills = user_fills(&self.info_client, address).await?;
1079
1080 #[allow(clippy::cast_sign_loss)]
1082 let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1083
1084 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1085 None
1086 } else {
1087 let mut set = HashSet::with_capacity(instruments.len());
1088 set.extend(instruments.iter().cloned());
1089 Some(set)
1090 };
1091
1092 let mut result = Vec::new();
1093 for fill in fills {
1094 if fill.time < time_since_ms {
1096 continue;
1097 }
1098
1099 let Some((base_asset, quote_asset)) = fill.coin.split_once('/') else {
1101 continue;
1102 };
1103
1104 let instrument = spot_coin_to_instrument(&fill.coin);
1105
1106 if instrument_filter
1107 .as_ref()
1108 .is_some_and(|f| !f.contains(&instrument))
1109 {
1110 continue;
1111 }
1112
1113 let Some(side) = parse_side(&fill.side) else {
1114 continue;
1115 };
1116 let Some(price) = parse_decimal(&fill.px, "px") else {
1117 continue;
1118 };
1119 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1120 continue;
1121 };
1122 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1123
1124 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1125 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1126 continue;
1127 };
1128
1129 let fee_asset = fill
1134 .fee_token
1135 .as_deref()
1136 .unwrap_or(if matches!(side, Side::Buy) {
1137 base_asset
1138 } else {
1139 quote_asset
1140 });
1141
1142 result.push(Trade {
1143 id: TradeId(format_smolstr!("{}", fill.tid)),
1144 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1145 instrument,
1146 strategy: StrategyId::unknown(),
1147 time_exchange,
1148 side,
1149 price,
1150 quantity,
1151 order_filled_quantity: None,
1154 fees: AssetFees {
1155 asset: AssetNameExchange::from(fee_asset),
1156 fees: fee,
1157 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1164 Some(fee)
1165 } else {
1166 None
1167 },
1168 },
1169 });
1170 }
1171
1172 Ok(result)
1173 }
1174}
1175
1176fn parse_token_balances(
1178 balances: &[hyperliquid_rust_sdk::UserTokenBalance],
1179 now: DateTime<Utc>,
1180) -> Vec<AssetBalance<AssetNameExchange>> {
1181 balances
1182 .iter()
1183 .map(|bal| {
1184 let total = parse_decimal(&bal.total, "total").unwrap_or(Decimal::ZERO);
1185 let hold = parse_decimal(&bal.hold, "hold").unwrap_or(Decimal::ZERO);
1186 let free = (total - hold).max(Decimal::ZERO);
1187
1188 AssetBalance::new(
1189 AssetNameExchange::from(bal.coin.as_str()),
1190 Balance::new(total, free),
1191 now,
1192 )
1193 })
1194 .collect()
1195}
1196
1197fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1199 let side = parse_side(&fill.side)?;
1200 let price = parse_decimal(&fill.px, "fill.px")?;
1201 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1202 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1203 let time_exchange = millis_to_datetime(fill.time)?;
1204
1205 let (_base, quote_asset) = fill.coin.split_once('/').unwrap_or(("", "USDC"));
1207 let instrument = spot_coin_to_instrument(&fill.coin);
1208 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1209
1210 let fee_asset = fill.fee_token.as_str();
1213
1214 let trade = Trade {
1215 id: TradeId(format_smolstr!("{}", fill.tid)),
1216 order_id,
1217 instrument,
1218 strategy: StrategyId::unknown(),
1219 time_exchange,
1220 side,
1221 price,
1222 quantity,
1223 order_filled_quantity: None,
1226 fees: AssetFees {
1227 asset: AssetNameExchange::from(fee_asset),
1228 fees: fee,
1229 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1232 Some(fee)
1233 } else {
1234 None
1235 },
1236 },
1237 };
1238
1239 Some(AccountEvent::new(
1240 ExchangeId::HyperliquidSpot,
1241 AccountEventKind::Trade(trade),
1242 ))
1243}
1244
1245fn order_update_to_account_event(
1247 update: &hyperliquid_rust_sdk::OrderUpdate,
1248) -> Option<UnindexedAccountEvent> {
1249 let order = &update.order;
1250 let side = parse_side(&order.side)?;
1251 let price = parse_decimal(&order.limit_px, "order.limit_px")?;
1252 let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
1253 let time_exchange = millis_to_datetime(update.status_timestamp)?;
1254 let instrument = spot_coin_to_instrument(&order.coin);
1255
1256 let order_id_smol = format_smolstr!("{}", order.oid);
1257 let cid = order
1258 .cloid
1259 .as_deref()
1260 .map(|c| ClientOrderId::new(SmolStr::new(c)))
1261 .unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
1262
1263 let state = match update.status.as_str() {
1264 "open" | "resting" => {
1265 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1266 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1267 crate::order::state::OrderState::active(Open {
1268 id: OrderId(order_id_smol),
1269 time_exchange,
1270 filled_quantity,
1271 })
1272 }
1273 "filled" => crate::order::state::OrderState::fully_filled(Filled::new(
1274 OrderId(order_id_smol),
1275 time_exchange,
1276 orig_sz,
1277 None,
1278 )),
1279 "canceled" | "cancelled" => {
1280 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1281 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1282 crate::order::state::OrderState::inactive(Cancelled::new(
1283 OrderId(order_id_smol),
1284 time_exchange,
1285 filled_quantity,
1286 ))
1287 }
1288 status => {
1289 warn!(%status, "Unknown order status");
1290 return None;
1291 }
1292 };
1293
1294 let order_snapshot = Order {
1295 key: OrderKey {
1296 exchange: ExchangeId::HyperliquidSpot,
1297 instrument,
1298 strategy: StrategyId::unknown(),
1299 cid,
1300 },
1301 side,
1302 price: Some(price),
1303 quantity: orig_sz,
1304 kind: OrderKind::Limit,
1305 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1306 state,
1307 };
1308
1309 Some(AccountEvent::new(
1310 ExchangeId::HyperliquidSpot,
1311 AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
1312 ))
1313}
1314
1315#[cfg(test)]
1316#[allow(clippy::unwrap_used)]
1318mod tests {
1319 use super::*;
1320 use rust_decimal_macros::dec;
1321
1322 #[test]
1323 fn test_fill_to_account_event_spot() {
1324 let fill_json = r#"{
1325 "coin": "PURR/USDC",
1326 "side": "B",
1327 "px": "0.05",
1328 "sz": "1000",
1329 "time": 1714100000000,
1330 "hash": "0xspot123",
1331 "startPosition": "0",
1332 "dir": "Open Long",
1333 "closedPnl": "0",
1334 "oid": 12345,
1335 "cloid": null,
1336 "crossed": false,
1337 "fee": "0.025",
1338 "feeToken": "USDC",
1339 "tid": 99999
1340 }"#;
1341
1342 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1343 let event = fill_to_account_event(&fill).unwrap();
1344
1345 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1346 match event.kind {
1347 AccountEventKind::Trade(trade) => {
1348 assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1349 assert_eq!(
1350 trade.id.0, "99999",
1351 "the id is the fill's tid, not its hash"
1352 );
1353 assert_eq!(trade.side, Side::Buy);
1354 assert_eq!(trade.price, dec!(0.05));
1355 assert_eq!(trade.quantity, dec!(1000));
1356 assert_eq!(trade.fees.fees, dec!(0.025));
1357 assert_eq!(trade.fees.asset.as_ref(), "USDC");
1358 }
1359 _ => panic!("Expected Trade event"),
1360 }
1361 }
1362
1363 fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1365 let json = format!(
1366 r#"{{
1367 "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1368 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1369 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1370 "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1371 }}"#
1372 );
1373 serde_json::from_str(&json).unwrap()
1374 }
1375
1376 #[test]
1377 fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1378 let first = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1379 let second = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1380
1381 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1382 (first.kind, second.kind)
1383 else {
1384 panic!("Expected Trade events");
1385 };
1386
1387 assert_eq!(first.order_id, second.order_id, "one order produced both");
1388 assert_ne!(first.id, second.id, "but they are distinct fills");
1389 }
1390
1391 #[test]
1392 fn a_replayed_spot_fill_is_delivered_once() {
1393 let cache = new_dedup_cache();
1394 let event = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1395 let replay = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1396
1397 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1398 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1399 let sibling = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1401 assert!(!is_duplicate(
1402 &cache,
1403 dedup_key_from_event(&sibling).unwrap()
1404 ));
1405 }
1406
1407 #[test]
1408 fn test_order_update_to_account_event_spot() {
1409 let update_json = r#"{
1410 "order": {
1411 "coin": "HYPE/USDC",
1412 "side": "A",
1413 "limitPx": "25.5",
1414 "sz": "10",
1415 "oid": 12346,
1416 "timestamp": 1714100000000,
1417 "origSz": "10",
1418 "cloid": null
1419 },
1420 "status": "open",
1421 "statusTimestamp": 1714100000000
1422 }"#;
1423
1424 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1425 let event = order_update_to_account_event(&update).unwrap();
1426
1427 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1428 match event.kind {
1429 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1430 assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
1431 assert_eq!(order.side, Side::Sell);
1432 assert_eq!(order.price, Some(dec!(25.5)));
1433 assert_eq!(order.quantity, dec!(10));
1434 assert!(matches!(
1435 order.state,
1436 crate::order::state::OrderState::Active(_)
1437 ));
1438 }
1439 _ => panic!("Expected OrderSnapshot event"),
1440 }
1441 }
1442}