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 error::{ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
51 order::{
52 Order, OrderKey, OrderKind, TimeInForce,
53 id::{ClientOrderId, OrderId, StrategyId},
54 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
55 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
56 },
57 trade::{AssetFees, Trade, TradeId},
58};
59use chrono::{DateTime, Utc};
60use ethers::signers::Signer;
61use futures::{StreamExt, stream::BoxStream};
62use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
63use rust_decimal::Decimal;
64use rustrade_instrument::{
65 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
66 instrument::name::InstrumentNameExchange,
67};
68use rustrade_integration::collection::snapshot::Snapshot;
69use smol_str::{SmolStr, format_smolstr};
70use std::{collections::HashSet, sync::Arc};
71use tokio::sync::mpsc;
72use tokio_util::sync::CancellationToken;
73use tracing::{debug, error, info, warn};
74
75#[derive(Debug, Clone)]
80pub struct HyperliquidSpotClient {
81 config: HyperliquidConfig,
82 info_client: Arc<InfoClient>,
83 exchange_client: Arc<ExchangeClient>,
84}
85
86impl HyperliquidSpotClient {
87 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
92 let base_url = if config.testnet {
93 BaseUrl::Testnet
94 } else {
95 BaseUrl::Mainnet
96 };
97
98 let info_client = InfoClient::new(None, Some(base_url))
99 .await
100 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
101
102 let wallet = config.wallet.clone();
103 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
104 .await
105 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
106
107 info!(
108 testnet = config.testnet,
109 wallet = %config.wallet_address_hex(),
110 "Created HyperliquidSpotClient"
111 );
112
113 Ok(Self {
114 config,
115 info_client: Arc::new(info_client),
116 exchange_client: Arc::new(exchange_client),
117 })
118 }
119
120 fn base_url(&self) -> BaseUrl {
122 if self.config.testnet {
123 BaseUrl::Testnet
124 } else {
125 BaseUrl::Mainnet
126 }
127 }
128
129 pub fn wallet_address(&self) -> String {
131 self.config.wallet_address_hex()
132 }
133
134 fn wallet_h160(&self) -> ethers::types::H160 {
136 self.config.wallet.address()
137 }
138}
139
140impl ExecutionClient for HyperliquidSpotClient {
141 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
142
143 type Config = HyperliquidConfig;
144 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
145
146 fn new(config: Self::Config) -> Self {
159 let base_url = if config.testnet {
160 BaseUrl::Testnet
161 } else {
162 BaseUrl::Mainnet
163 };
164
165 let handle = tokio::runtime::Handle::current();
166
167 let info_client = handle.block_on(async {
168 InfoClient::new(None, Some(base_url))
169 .await
170 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
171 });
172
173 let wallet = config.wallet.clone();
174 let exchange_client = handle.block_on(async {
175 ExchangeClient::new(None, wallet, Some(base_url), None, None)
176 .await
177 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
178 });
179
180 info!(
181 testnet = config.testnet,
182 wallet = %config.wallet_address_hex(),
183 "Created HyperliquidSpotClient"
184 );
185
186 Self {
187 config,
188 info_client: Arc::new(info_client),
189 exchange_client: Arc::new(exchange_client),
190 }
191 }
192
193 async fn account_snapshot(
194 &self,
195 _assets: &[AssetNameExchange],
196 instruments: &[InstrumentNameExchange],
197 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
198 let address = self.wallet_h160();
199
200 let (token_balances, open_orders) = tokio::try_join!(
202 async {
203 self.info_client
204 .user_token_balances(address)
205 .await
206 .map_err(map_sdk_error)
207 },
208 async {
209 self.info_client
210 .open_orders(address)
211 .await
212 .map_err(map_sdk_error)
213 }
214 )?;
215
216 let now = Utc::now();
217 let balances = parse_token_balances(&token_balances.balances, now);
218
219 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
221 None
222 } else {
223 let mut set = HashSet::with_capacity(instruments.len());
224 set.extend(instruments.iter().cloned());
225 Some(set)
226 };
227
228 let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
230 std::collections::HashMap::new();
231
232 for order in &open_orders {
233 if !is_spot_coin(&order.coin) {
235 continue;
236 }
237
238 let instrument = spot_coin_to_instrument(&order.coin);
239 if instrument_filter
240 .as_ref()
241 .is_some_and(|f| !f.contains(&instrument))
242 {
243 continue;
244 }
245
246 let Some(side) = parse_side(&order.side) else {
247 continue;
248 };
249 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
250 continue;
251 };
252 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
253 continue;
254 };
255 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
256 warn!(
257 oid = order.oid,
258 timestamp = order.timestamp,
259 "Invalid order timestamp, skipping"
260 );
261 continue;
262 };
263
264 let order_id = format_smolstr!("{}", order.oid);
265 let order_snapshot = Order {
266 key: OrderKey {
267 exchange: ExchangeId::HyperliquidSpot,
268 instrument: instrument.clone(),
269 strategy: StrategyId::unknown(),
270 cid: ClientOrderId::new(order_id.clone()),
271 },
272 side,
273 price: Some(price),
274 quantity,
275 kind: OrderKind::Limit,
276 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
277 state: crate::order::state::OrderState::active(Open {
278 id: OrderId(order_id),
279 time_exchange,
280 filled_quantity: Decimal::ZERO,
281 }),
282 };
283
284 orders_by_instrument
285 .entry(instrument)
286 .or_default()
287 .push(order_snapshot);
288 }
289
290 let instrument_snapshots: Vec<_> = orders_by_instrument
292 .into_iter()
293 .map(|(instrument, orders)| InstrumentAccountSnapshot {
294 instrument,
295 orders,
296 position: None, isolated: None,
298 })
299 .collect();
300
301 Ok(AccountSnapshot {
302 exchange: ExchangeId::HyperliquidSpot,
303 balances,
304 instruments: instrument_snapshots,
305 })
306 }
307
308 async fn account_stream(
321 &self,
322 _assets: &[AssetNameExchange],
323 _instruments: &[InstrumentNameExchange],
324 ) -> Result<Self::AccountStream, UnindexedClientError> {
325 let user = self.wallet_h160();
326 let base_url = self.base_url();
327
328 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
329 .await
330 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
331
332 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
333 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
334
335 ws_client
336 .subscribe(Subscription::UserFills { user }, fills_tx)
337 .await
338 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
339
340 ws_client
341 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
342 .await
343 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
344
345 info!(%user, "Subscribed to Hyperliquid spot account stream");
346
347 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
348 let cancel_token = CancellationToken::new();
349
350 let fills_event_tx = event_tx.clone();
352 let fills_cancel = cancel_token.clone();
353 tokio::spawn(async move {
354 loop {
355 tokio::select! {
356 biased;
357 () = fills_cancel.cancelled() => {
358 debug!("Spot fills task cancelled");
359 return;
360 }
361 msg = fills_rx.recv() => {
362 let Some(msg) = msg else {
363 debug!("Spot fills receiver closed");
364 return;
365 };
366 match msg {
367 Message::UserFills(fills) => {
368 for fill in fills.data.fills {
369 if !is_spot_coin(&fill.coin) {
371 continue;
372 }
373 if let Some(event) = fill_to_account_event(&fill)
374 && fills_event_tx.send(event).is_err()
375 {
376 debug!("Spot fills event channel closed");
377 return;
378 }
379 }
380 }
381 Message::NoData => {
382 warn!("Spot UserFills WebSocket disconnected");
383 }
384 Message::HyperliquidError(e) => {
385 error!(%e, "Spot UserFills WebSocket error");
386 let _ = fills_event_tx.send(AccountEvent::new(
387 ExchangeId::HyperliquidSpot,
388 AccountEventKind::StreamError(e),
389 ));
390 }
391 _ => {}
392 }
393 }
394 }
395 }
396 });
397
398 let orders_event_tx = event_tx;
403 let orders_cancel = cancel_token.clone();
404 tokio::spawn(async move {
405 let _ws_client = ws_client;
406
407 loop {
408 tokio::select! {
409 biased;
410 () = orders_cancel.cancelled() => {
411 debug!("Spot orders task cancelled");
412 return;
413 }
414 msg = orders_rx.recv() => {
415 let Some(msg) = msg else {
416 debug!("Spot orders receiver closed");
417 return;
418 };
419 match msg {
420 Message::OrderUpdates(updates) => {
421 for update in updates.data {
422 if !is_spot_coin(&update.order.coin) {
424 continue;
425 }
426 if let Some(event) = order_update_to_account_event(&update)
427 && orders_event_tx.send(event).is_err()
428 {
429 debug!("Spot orders event channel closed");
430 return;
431 }
432 }
433 }
434 Message::NoData => {
435 warn!("Spot OrderUpdates WebSocket disconnected");
436 }
437 Message::HyperliquidError(e) => {
438 error!(%e, "Spot OrderUpdates WebSocket error");
439 let _ = orders_event_tx.send(AccountEvent::new(
440 ExchangeId::HyperliquidSpot,
441 AccountEventKind::StreamError(e),
442 ));
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 let order_id = match &request.state.id {
491 Some(id) => id,
492 None => {
493 warn!("Cancel request missing order ID");
494 return Some(OrderResponseCancel {
495 key: OrderKey {
496 exchange: ExchangeId::HyperliquidSpot,
497 instrument: request.key.instrument.clone(),
498 strategy: request.key.strategy.clone(),
499 cid: request.key.cid.clone(),
500 },
501 state: Err(UnindexedOrderError::Rejected(
502 crate::error::ApiError::OrderRejected("Missing order ID".to_string()),
503 )),
504 });
505 }
506 };
507
508 enum CancelMethod {
513 ByOid(u64),
514 ByCloid(Uuid),
515 }
516
517 let cancel_method = if let Ok(oid) = order_id.0.parse::<u64>() {
518 CancelMethod::ByOid(oid)
519 } else if let Ok(uuid) = Uuid::parse_str(&order_id.0) {
520 CancelMethod::ByCloid(uuid)
521 } else {
522 warn!(?order_id, "Order ID is neither u64 (OID) nor UUID (cloid)");
523 return Some(OrderResponseCancel {
524 key: OrderKey {
525 exchange: ExchangeId::HyperliquidSpot,
526 instrument: request.key.instrument.clone(),
527 strategy: request.key.strategy.clone(),
528 cid: request.key.cid.clone(),
529 },
530 state: Err(UnindexedOrderError::Rejected(
531 crate::error::ApiError::OrderRejected(
532 "Invalid order ID: must be numeric OID or UUID (for trigger orders)"
533 .to_string(),
534 ),
535 )),
536 });
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 (trigger order)");
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 order_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 cloid = cid_to_cloid(&request.key.cid);
687 let cloid_is_some = cloid.is_some();
688 let is_trigger_order = matches!(
689 request.state.kind,
690 OrderKind::Stop { .. }
691 | OrderKind::StopLimit { .. }
692 | OrderKind::TakeProfit { .. }
693 | OrderKind::TakeProfitLimit { .. }
694 );
695 if is_trigger_order && cloid.is_none() {
696 return Some(make_rejected(
697 "Trigger orders require UUID-format client order ID (use ClientOrderId::uuid()). \
698 Non-UUID IDs cannot be cancelled via cancel_by_cloid()."
699 .to_string(),
700 ));
701 }
702
703 let limit_px = match request.state.kind {
704 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
706 round_to_5_sig_figs(trigger_price)
707 }
708 _ => match request.state.price {
709 Some(p) => round_to_5_sig_figs(p),
710 None => {
711 return Some(make_rejected(
712 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
713 .to_string(),
714 ));
715 }
716 },
717 };
718
719 let sz = round_to_5_sig_figs(request.state.quantity);
720
721 let notional_price = match request.state.kind {
724 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
725 trigger_price
726 }
727 _ => request.state.price.unwrap_or(Decimal::ZERO),
728 };
729 let notional = notional_price * request.state.quantity;
730 if notional < Decimal::TEN {
731 warn!(
732 instrument = %request.key.instrument,
733 %notional,
734 "Spot order below $10 minimum notional value"
735 );
736 return Some(make_rejected(format!(
737 "Spot order notional ${notional} below $10 minimum"
738 )));
739 }
740
741 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
742 warn!(
743 instrument = %request.key.instrument,
744 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
745 );
746 }
747 let tif = map_tif(&request.state.time_in_force).to_string();
748
749 let order_type = match request.state.kind {
751 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
752 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
753 is_market: true,
754 trigger_px: round_to_5_sig_figs(trigger_price),
755 tpsl: "sl".to_string(),
756 }),
757 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
758 is_market: false,
759 trigger_px: round_to_5_sig_figs(trigger_price),
760 tpsl: "sl".to_string(),
761 }),
762 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
763 is_market: true,
764 trigger_px: round_to_5_sig_figs(trigger_price),
765 tpsl: "tp".to_string(),
766 }),
767 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
768 is_market: false,
769 trigger_px: round_to_5_sig_figs(trigger_price),
770 tpsl: "tp".to_string(),
771 }),
772 OrderKind::Market
774 | OrderKind::TrailingStop { .. }
775 | OrderKind::TrailingStopLimit { .. } => {
776 unreachable!("unsupported order kinds rejected earlier")
777 }
778 };
779
780 let order_request = ClientOrderRequest {
781 asset: coin,
782 is_buy,
783 reduce_only: request.state.reduce_only,
784 limit_px,
785 sz,
786 cloid,
787 order_type,
788 };
789
790 let response = match self.exchange_client.order(order_request, None).await {
791 Ok(r) => r,
792 Err(e) => {
793 warn!(%e, "Spot open order failed");
794 return Some(Order {
795 key: OrderKey {
796 exchange: ExchangeId::HyperliquidSpot,
797 instrument: request.key.instrument.clone(),
798 strategy: request.key.strategy.clone(),
799 cid: request.key.cid.clone(),
800 },
801 side: request.state.side,
802 price: request.state.price,
803 quantity: request.state.quantity,
804 kind: request.state.kind,
805 time_in_force: request.state.time_in_force,
806 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
807 });
808 }
809 };
810
811 let state = match response {
812 ExchangeResponseStatus::Ok(exchange_resp) => {
813 let status = exchange_resp
814 .data
815 .and_then(|d| d.statuses.into_iter().next());
816
817 match status {
818 Some(ExchangeDataStatus::Resting(resting)) => {
819 debug!(oid = resting.oid, "Spot order resting");
820 OrderState::active(Open {
821 id: OrderId(format_smolstr!("{}", resting.oid)),
822 time_exchange: Utc::now(),
824 filled_quantity: Decimal::ZERO,
825 })
826 }
827 Some(ExchangeDataStatus::Filled(filled)) => {
828 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
829 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
830 OrderState::fully_filled(Filled::new(
831 OrderId(format_smolstr!("{}", filled.oid)),
832 Utc::now(),
834 parse_decimal(&filled.total_sz, "total_sz")
835 .unwrap_or(request.state.quantity),
836 avg_price,
837 ))
838 }
839 Some(ExchangeDataStatus::Error(msg)) => {
840 warn!(%msg, "Spot order rejected by exchange");
841 OrderState::inactive(OrderError::Rejected(
842 crate::error::ApiError::OrderRejected(msg),
843 ))
844 }
845 Some(
846 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
847 ) => {
848 if cloid_is_some {
854 debug!(cloid = %request.key.cid.0, "Spot order waiting (cloid trackable)");
855 OrderState::active(Open {
856 id: OrderId(request.key.cid.0.clone()),
857 time_exchange: Utc::now(),
858 filled_quantity: Decimal::ZERO,
859 })
860 } else {
861 warn!("Spot order accepted but cid is not UUID — untrackable");
862 OrderState::inactive(OrderError::Rejected(
863 crate::error::ApiError::OrderRejected(
864 "order accepted but cid is not UUID; cannot track for cancel"
865 .to_string(),
866 ),
867 ))
868 }
869 }
870 Some(ExchangeDataStatus::Success) | None => {
871 warn!("Spot order accepted but no order ID returned");
872 OrderState::inactive(OrderError::Rejected(
873 crate::error::ApiError::OrderRejected(
874 "no order ID in response".to_string(),
875 ),
876 ))
877 }
878 }
879 }
880 ExchangeResponseStatus::Err(msg) => {
881 warn!(%msg, "Spot order rejected");
882 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
883 msg,
884 )))
885 }
886 };
887
888 Some(Order {
889 key: OrderKey {
890 exchange: ExchangeId::HyperliquidSpot,
891 instrument: request.key.instrument.clone(),
892 strategy: request.key.strategy.clone(),
893 cid: request.key.cid.clone(),
894 },
895 side: request.state.side,
896 price: request.state.price,
897 quantity: request.state.quantity,
898 kind: request.state.kind,
899 time_in_force: request.state.time_in_force,
900 state,
901 })
902 }
903
904 async fn fetch_balances(
905 &self,
906 _assets: &[AssetNameExchange],
907 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
908 let address = self.wallet_h160();
909
910 let token_balances = self
911 .info_client
912 .user_token_balances(address)
913 .await
914 .map_err(map_sdk_error)?;
915
916 let now = Utc::now();
917 Ok(parse_token_balances(&token_balances.balances, now))
918 }
919
920 async fn fetch_open_orders(
921 &self,
922 instruments: &[InstrumentNameExchange],
923 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
924 let address = self.wallet_h160();
925
926 let open_orders = self
927 .info_client
928 .open_orders(address)
929 .await
930 .map_err(map_sdk_error)?;
931
932 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
933 None
934 } else {
935 let mut set = HashSet::with_capacity(instruments.len());
936 set.extend(instruments.iter().cloned());
937 Some(set)
938 };
939
940 let mut result = Vec::new();
941 for order in open_orders {
942 if !is_spot_coin(&order.coin) {
944 continue;
945 }
946
947 let instrument = spot_coin_to_instrument(&order.coin);
948
949 if instrument_filter
950 .as_ref()
951 .is_some_and(|f| !f.contains(&instrument))
952 {
953 continue;
954 }
955
956 let Some(side) = parse_side(&order.side) else {
957 continue;
958 };
959 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
960 continue;
961 };
962 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
963 continue;
964 };
965 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
966 warn!(
967 oid = order.oid,
968 timestamp = order.timestamp,
969 "Invalid order timestamp, skipping"
970 );
971 continue;
972 };
973
974 let order_id = format_smolstr!("{}", order.oid);
975 result.push(Order {
976 key: OrderKey {
977 exchange: ExchangeId::HyperliquidSpot,
978 instrument,
979 strategy: StrategyId::unknown(),
980 cid: ClientOrderId::new(order_id.clone()),
981 },
982 side,
983 price: Some(price),
984 quantity,
985 kind: OrderKind::Limit,
986 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
987 state: Open {
988 id: OrderId(order_id),
989 time_exchange,
990 filled_quantity: Decimal::ZERO,
991 },
992 });
993 }
994
995 Ok(result)
996 }
997
998 async fn fetch_trades(
999 &self,
1000 time_since: DateTime<Utc>,
1001 instruments: &[InstrumentNameExchange],
1002 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1003 let address = self.wallet_h160();
1004
1005 let fills = self
1006 .info_client
1007 .user_fills(address)
1008 .await
1009 .map_err(map_sdk_error)?;
1010
1011 #[allow(clippy::cast_sign_loss)]
1013 let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1014
1015 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1016 None
1017 } else {
1018 let mut set = HashSet::with_capacity(instruments.len());
1019 set.extend(instruments.iter().cloned());
1020 Some(set)
1021 };
1022
1023 let mut result = Vec::new();
1024 for fill in fills {
1025 if fill.time < time_since_ms {
1027 continue;
1028 }
1029
1030 let Some((base_asset, quote_asset)) = fill.coin.split_once('/') else {
1032 continue;
1033 };
1034
1035 let instrument = spot_coin_to_instrument(&fill.coin);
1036
1037 if instrument_filter
1038 .as_ref()
1039 .is_some_and(|f| !f.contains(&instrument))
1040 {
1041 continue;
1042 }
1043
1044 let Some(side) = parse_side(&fill.side) else {
1045 continue;
1046 };
1047 let Some(price) = parse_decimal(&fill.px, "px") else {
1048 continue;
1049 };
1050 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1051 continue;
1052 };
1053 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1054
1055 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1056 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1057 continue;
1058 };
1059
1060 let fee_asset = if matches!(side, Side::Buy) {
1063 base_asset
1064 } else {
1065 quote_asset
1066 };
1067
1068 result.push(Trade {
1069 id: TradeId(SmolStr::new(&fill.hash)),
1070 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1071 instrument,
1072 strategy: StrategyId::unknown(),
1073 time_exchange,
1074 side,
1075 price,
1076 quantity,
1077 fees: AssetFees {
1078 asset: AssetNameExchange::from(fee_asset),
1079 fees: fee,
1080 fees_quote: if fee_asset == quote_asset {
1083 Some(fee)
1084 } else {
1085 None
1086 },
1087 },
1088 });
1089 }
1090
1091 Ok(result)
1092 }
1093}
1094
1095fn parse_token_balances(
1097 balances: &[hyperliquid_rust_sdk::UserTokenBalance],
1098 now: DateTime<Utc>,
1099) -> Vec<AssetBalance<AssetNameExchange>> {
1100 balances
1101 .iter()
1102 .map(|bal| {
1103 let total = parse_decimal(&bal.total, "total").unwrap_or(Decimal::ZERO);
1104 let hold = parse_decimal(&bal.hold, "hold").unwrap_or(Decimal::ZERO);
1105 let free = (total - hold).max(Decimal::ZERO);
1106
1107 AssetBalance::new(
1108 AssetNameExchange::from(bal.coin.as_str()),
1109 Balance::new(total, free),
1110 now,
1111 )
1112 })
1113 .collect()
1114}
1115
1116fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1118 let side = parse_side(&fill.side)?;
1119 let price = parse_decimal(&fill.px, "fill.px")?;
1120 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1121 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1122 let time_exchange = millis_to_datetime(fill.time)?;
1123
1124 let (_base, quote_asset) = fill.coin.split_once('/').unwrap_or(("", "USDC"));
1126 let instrument = spot_coin_to_instrument(&fill.coin);
1127 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1128
1129 let fee_asset = fill.fee_token.as_str();
1132
1133 let trade = Trade {
1134 id: TradeId(SmolStr::new(&fill.hash)),
1135 order_id,
1136 instrument,
1137 strategy: StrategyId::unknown(),
1138 time_exchange,
1139 side,
1140 price,
1141 quantity,
1142 fees: AssetFees {
1143 asset: AssetNameExchange::from(fee_asset),
1144 fees: fee,
1145 fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1148 Some(fee)
1149 } else {
1150 None
1151 },
1152 },
1153 };
1154
1155 Some(AccountEvent::new(
1156 ExchangeId::HyperliquidSpot,
1157 AccountEventKind::Trade(trade),
1158 ))
1159}
1160
1161fn order_update_to_account_event(
1163 update: &hyperliquid_rust_sdk::OrderUpdate,
1164) -> Option<UnindexedAccountEvent> {
1165 let order = &update.order;
1166 let side = parse_side(&order.side)?;
1167 let price = parse_decimal(&order.limit_px, "order.limit_px")?;
1168 let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
1169 let time_exchange = millis_to_datetime(update.status_timestamp)?;
1170 let instrument = spot_coin_to_instrument(&order.coin);
1171
1172 let order_id_smol = format_smolstr!("{}", order.oid);
1173 let cid = order
1174 .cloid
1175 .as_deref()
1176 .map(|c| ClientOrderId::new(SmolStr::new(c)))
1177 .unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
1178
1179 let state = match update.status.as_str() {
1180 "open" | "resting" => {
1181 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1182 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1183 crate::order::state::OrderState::active(Open {
1184 id: OrderId(order_id_smol),
1185 time_exchange,
1186 filled_quantity,
1187 })
1188 }
1189 "filled" => crate::order::state::OrderState::fully_filled(Filled::new(
1190 OrderId(order_id_smol),
1191 time_exchange,
1192 orig_sz,
1193 None,
1194 )),
1195 "canceled" | "cancelled" => {
1196 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1197 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1198 crate::order::state::OrderState::inactive(Cancelled::new(
1199 OrderId(order_id_smol),
1200 time_exchange,
1201 filled_quantity,
1202 ))
1203 }
1204 status => {
1205 warn!(%status, "Unknown order status");
1206 return None;
1207 }
1208 };
1209
1210 let order_snapshot = Order {
1211 key: OrderKey {
1212 exchange: ExchangeId::HyperliquidSpot,
1213 instrument,
1214 strategy: StrategyId::unknown(),
1215 cid,
1216 },
1217 side,
1218 price: Some(price),
1219 quantity: orig_sz,
1220 kind: OrderKind::Limit,
1221 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1222 state,
1223 };
1224
1225 Some(AccountEvent::new(
1226 ExchangeId::HyperliquidSpot,
1227 AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
1228 ))
1229}
1230
1231#[cfg(test)]
1232#[allow(clippy::unwrap_used)]
1234mod tests {
1235 use super::*;
1236 use rust_decimal_macros::dec;
1237
1238 #[test]
1239 fn test_fill_to_account_event_spot() {
1240 let fill_json = r#"{
1241 "coin": "PURR/USDC",
1242 "side": "B",
1243 "px": "0.05",
1244 "sz": "1000",
1245 "time": 1714100000000,
1246 "hash": "0xspot123",
1247 "startPosition": "0",
1248 "dir": "Open Long",
1249 "closedPnl": "0",
1250 "oid": 12345,
1251 "cloid": null,
1252 "crossed": false,
1253 "fee": "0.025",
1254 "feeToken": "USDC",
1255 "tid": 99999
1256 }"#;
1257
1258 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1259 let event = fill_to_account_event(&fill).unwrap();
1260
1261 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1262 match event.kind {
1263 AccountEventKind::Trade(trade) => {
1264 assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1265 assert_eq!(trade.side, Side::Buy);
1266 assert_eq!(trade.price, dec!(0.05));
1267 assert_eq!(trade.quantity, dec!(1000));
1268 assert_eq!(trade.fees.fees, dec!(0.025));
1269 assert_eq!(trade.fees.asset.as_ref(), "USDC");
1270 }
1271 _ => panic!("Expected Trade event"),
1272 }
1273 }
1274
1275 #[test]
1276 fn test_order_update_to_account_event_spot() {
1277 let update_json = r#"{
1278 "order": {
1279 "coin": "HYPE/USDC",
1280 "side": "A",
1281 "limitPx": "25.5",
1282 "sz": "10",
1283 "oid": 12346,
1284 "timestamp": 1714100000000,
1285 "origSz": "10",
1286 "cloid": null
1287 },
1288 "status": "open",
1289 "statusTimestamp": 1714100000000
1290 }"#;
1291
1292 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1293 let event = order_update_to_account_event(&update).unwrap();
1294
1295 assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1296 match event.kind {
1297 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1298 assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
1299 assert_eq!(order.side, Side::Sell);
1300 assert_eq!(order.price, Some(dec!(25.5)));
1301 assert_eq!(order.quantity, dec!(10));
1302 assert!(matches!(
1303 order.state,
1304 crate::order::state::OrderState::Active(_)
1305 ));
1306 }
1307 _ => panic!("Expected OrderSnapshot event"),
1308 }
1309 }
1310}