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