1pub mod common;
66pub mod config;
67pub mod error;
68pub mod spot;
69
70use crate::{
71 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
72 UnindexedAccountEvent, UnindexedAccountSnapshot,
73 balance::{AssetBalance, Balance},
74 client::ExecutionClient,
75 emit_stream_terminated,
76 error::{
77 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
78 UnindexedOrderError,
79 },
80 order::{
81 Order, OrderKey, OrderKind, TimeInForce,
82 id::{ClientOrderId, OrderId, StrategyId},
83 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
84 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
85 },
86 position::Position,
87 trade::{AssetFees, Trade, TradeId},
88};
89use chrono::{DateTime, Utc};
90use common::{
91 CancelOnDropStream, cid_to_cloid, instrument_to_perp_coin, map_tif, millis_to_datetime,
92 parse_decimal, parse_side, perp_coin_to_instrument, round_to_5_sig_figs,
93};
94pub use config::{HyperliquidConfig, HyperliquidConfigError};
95use error::{map_order_error, map_sdk_error};
96use ethers::signers::Signer;
97use futures::{StreamExt, stream::BoxStream};
98use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
99use rust_decimal::Decimal;
100use rustrade_instrument::{
101 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
102 instrument::name::InstrumentNameExchange,
103};
104use rustrade_integration::collection::snapshot::Snapshot;
105use smol_str::{SmolStr, format_smolstr};
106use std::{
107 collections::HashSet,
108 sync::{
109 Arc,
110 atomic::{AtomicBool, Ordering},
111 },
112};
113use tokio::sync::mpsc;
114use tokio_util::sync::CancellationToken;
115use tracing::{debug, error, info, warn};
116
117const USDC_ASSET: &str = "USDC";
119
120#[derive(Debug, Clone)]
125pub struct HyperliquidClient {
126 config: HyperliquidConfig,
127 info_client: Arc<InfoClient>,
128 exchange_client: Arc<ExchangeClient>,
129}
130
131impl HyperliquidClient {
132 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
137 let base_url = if config.testnet {
138 BaseUrl::Testnet
139 } else {
140 BaseUrl::Mainnet
141 };
142
143 let info_client = InfoClient::new(None, Some(base_url))
144 .await
145 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
146
147 let wallet = config.wallet.clone();
148 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
149 .await
150 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
151
152 info!(
153 testnet = config.testnet,
154 wallet = %config.wallet_address_hex(),
155 "Created HyperliquidClient"
156 );
157
158 Ok(Self {
159 config,
160 info_client: Arc::new(info_client),
161 exchange_client: Arc::new(exchange_client),
162 })
163 }
164
165 fn base_url(&self) -> BaseUrl {
167 if self.config.testnet {
168 BaseUrl::Testnet
169 } else {
170 BaseUrl::Mainnet
171 }
172 }
173
174 pub fn wallet_address(&self) -> String {
176 self.config.wallet_address_hex()
177 }
178
179 fn wallet_h160(&self) -> ethers::types::H160 {
181 self.config.wallet.address()
182 }
183}
184
185impl ExecutionClient for HyperliquidClient {
186 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
187
188 type Config = HyperliquidConfig;
189 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
190
191 fn new(config: Self::Config) -> Self {
204 let base_url = if config.testnet {
205 BaseUrl::Testnet
206 } else {
207 BaseUrl::Mainnet
208 };
209
210 let handle = tokio::runtime::Handle::current();
213
214 let info_client = handle.block_on(async {
215 InfoClient::new(None, Some(base_url))
216 .await
217 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
218 });
219
220 let wallet = config.wallet.clone();
221 let exchange_client = handle.block_on(async {
222 ExchangeClient::new(None, wallet, Some(base_url), None, None)
223 .await
224 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
225 });
226
227 info!(
228 testnet = config.testnet,
229 wallet = %config.wallet_address_hex(),
230 "Created HyperliquidClient"
231 );
232
233 Self {
234 config,
235 info_client: Arc::new(info_client),
236 exchange_client: Arc::new(exchange_client),
237 }
238 }
239
240 async fn account_snapshot(
241 &self,
242 _assets: &[AssetNameExchange],
243 instruments: &[InstrumentNameExchange],
244 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
245 let address = self.wallet_h160();
246
247 let (user_state, open_orders) = tokio::try_join!(
249 async {
250 self.info_client
251 .user_state(address)
252 .await
253 .map_err(map_sdk_error)
254 },
255 async {
256 self.info_client
257 .open_orders(address)
258 .await
259 .map_err(map_sdk_error)
260 }
261 )?;
262
263 let now = Utc::now();
264
265 let account_value =
267 parse_decimal(&user_state.margin_summary.account_value, "account_value")
268 .unwrap_or(Decimal::ZERO);
269 let margin_used = parse_decimal(
270 &user_state.margin_summary.total_margin_used,
271 "total_margin_used",
272 )
273 .unwrap_or(Decimal::ZERO);
274
275 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
278 let balances = vec![AssetBalance::new(
279 AssetNameExchange::from(USDC_ASSET),
280 Balance::new(account_value, free_balance),
281 now,
282 )];
283
284 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
286 None
287 } else {
288 let mut set = HashSet::with_capacity(instruments.len());
289 set.extend(instruments.iter().cloned());
290 Some(set)
291 };
292
293 let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
295 std::collections::HashMap::with_capacity(open_orders.len());
296
297 for order in &open_orders {
298 let instrument = perp_coin_to_instrument(&order.coin);
299 if instrument_filter
300 .as_ref()
301 .is_some_and(|f| !f.contains(&instrument))
302 {
303 continue;
304 }
305
306 let Some(side) = parse_side(&order.side) else {
307 continue;
308 };
309 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
310 continue;
311 };
312 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
313 continue;
314 };
315 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
316 warn!(
317 oid = order.oid,
318 timestamp = order.timestamp,
319 "Invalid order timestamp, skipping"
320 );
321 continue;
322 };
323
324 let order_id = format_smolstr!("{}", order.oid);
325 let order_snapshot = Order {
326 key: OrderKey {
327 exchange: ExchangeId::HyperliquidPerp,
328 instrument: instrument.clone(),
329 strategy: StrategyId::unknown(),
330 cid: ClientOrderId::new(order_id.clone()),
331 },
332 side,
333 price: Some(price),
334 quantity,
335 kind: OrderKind::Limit,
336 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
337 state: crate::order::state::OrderState::active(Open {
338 id: OrderId(order_id),
339 time_exchange,
340 filled_quantity: Decimal::ZERO,
341 }),
342 };
343
344 orders_by_instrument
345 .entry(instrument)
346 .or_default()
347 .push(order_snapshot);
348 }
349
350 let mut instrument_snapshots = Vec::new();
352 for asset_pos in user_state.asset_positions {
353 let pos = &asset_pos.position;
354 let instrument = perp_coin_to_instrument(&pos.coin);
355
356 if instrument_filter
357 .as_ref()
358 .is_some_and(|f| !f.contains(&instrument))
359 {
360 continue;
361 }
362
363 let quantity = parse_decimal(&pos.szi, "szi").unwrap_or(Decimal::ZERO);
364 let entry_price = pos
365 .entry_px
366 .as_ref()
367 .and_then(|p| parse_decimal(p, "entry_px"));
368 let unrealized_pnl = parse_decimal(&pos.unrealized_pnl, "unrealized_pnl");
369 let margin_used = parse_decimal(&pos.margin_used, "margin_used");
370 let liquidation_price = pos
371 .liquidation_px
372 .as_ref()
373 .and_then(|p| parse_decimal(p, "liquidation_px"));
374 let leverage = Some(Decimal::from(pos.leverage.value));
375
376 let position = if quantity.is_zero() {
377 None
378 } else {
379 Some(Position::new(
380 quantity,
381 entry_price,
382 unrealized_pnl,
383 margin_used,
384 liquidation_price,
385 leverage,
386 now,
387 ))
388 };
389
390 let orders = orders_by_instrument.remove(&instrument).unwrap_or_default();
391
392 instrument_snapshots.push(InstrumentAccountSnapshot {
393 instrument,
394 orders,
395 position,
396 isolated: None,
397 });
398 }
399
400 for (instrument, orders) in orders_by_instrument {
402 instrument_snapshots.push(InstrumentAccountSnapshot {
403 instrument,
404 orders,
405 position: None,
406 isolated: None,
407 });
408 }
409
410 Ok(AccountSnapshot {
411 exchange: ExchangeId::HyperliquidPerp,
412 balances,
413 instruments: instrument_snapshots,
414 })
415 }
416
417 async fn account_stream(
432 &self,
433 _assets: &[AssetNameExchange],
434 _instruments: &[InstrumentNameExchange],
435 ) -> Result<Self::AccountStream, UnindexedClientError> {
436 let user = self.wallet_h160();
437 let base_url = self.base_url();
438
439 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
442 .await
443 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
444
445 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
447 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
448
449 ws_client
451 .subscribe(Subscription::UserFills { user }, fills_tx)
452 .await
453 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
454
455 ws_client
457 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
458 .await
459 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
460
461 info!(%user, "Subscribed to Hyperliquid account stream");
462
463 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
465
466 let cancel_token = CancellationToken::new();
468
469 let terminated = Arc::new(AtomicBool::new(false));
474
475 let fills_event_tx = event_tx.clone();
477 let fills_cancel = cancel_token.clone();
478 let fills_terminated = terminated.clone();
479 tokio::spawn(async move {
480 loop {
481 tokio::select! {
482 biased;
483 () = fills_cancel.cancelled() => {
484 debug!("Fills task cancelled");
485 return;
486 }
487 msg = fills_rx.recv() => {
488 let Some(msg) = msg else {
489 debug!("Fills receiver closed");
490 if fills_terminated
493 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
494 .is_ok()
495 {
496 emit_stream_terminated(
497 &fills_event_tx,
498 ExchangeId::HyperliquidPerp,
499 StreamTerminationReason::Error(
500 "hyperliquid account stream closed".to_string(),
501 ),
502 );
503 }
504 return;
505 };
506 match msg {
507 Message::UserFills(fills) => {
508 for fill in fills.data.fills {
509 if let Some(event) = fill_to_account_event(&fill)
510 && fills_event_tx.send(event).is_err()
511 {
512 debug!("Fills event channel closed");
513 return;
514 }
515 }
516 }
517 Message::NoData => {
518 warn!("UserFills WebSocket disconnected");
519 }
520 Message::HyperliquidError(e) => {
521 error!(%e, "UserFills WebSocket error");
524 }
525 _ => {}
526 }
527 }
528 }
529 }
530 });
531
532 let orders_event_tx = event_tx;
537 let orders_cancel = cancel_token.clone();
538 let orders_terminated = terminated;
539 tokio::spawn(async move {
540 let _ws_client = ws_client;
541
542 loop {
543 tokio::select! {
544 biased;
545 () = orders_cancel.cancelled() => {
546 debug!("Orders task cancelled");
547 return;
548 }
549 msg = orders_rx.recv() => {
550 let Some(msg) = msg else {
551 debug!("Orders receiver closed");
552 if orders_terminated
555 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
556 .is_ok()
557 {
558 emit_stream_terminated(
559 &orders_event_tx,
560 ExchangeId::HyperliquidPerp,
561 StreamTerminationReason::Error(
562 "hyperliquid account stream closed".to_string(),
563 ),
564 );
565 }
566 return;
567 };
568 match msg {
569 Message::OrderUpdates(updates) => {
570 for update in updates.data {
571 if let Some(event) = order_update_to_account_event(&update)
572 && orders_event_tx.send(event).is_err()
573 {
574 debug!("Orders event channel closed");
575 return;
576 }
577 }
578 }
579 Message::NoData => {
580 warn!("OrderUpdates WebSocket disconnected");
581 }
582 Message::HyperliquidError(e) => {
583 error!(%e, "OrderUpdates WebSocket error");
586 }
587 _ => {}
588 }
589 }
590 }
591 }
592 });
593
594 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
596 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
597 Ok(guarded_stream.boxed())
598 }
599
600 async fn cancel_order(
601 &self,
602 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
603 ) -> Option<UnindexedOrderResponseCancel> {
604 use crate::order::{request::OrderResponseCancel, state::Cancelled};
605 use hyperliquid_rust_sdk::{
606 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
607 };
608 use uuid::Uuid;
609
610 let coin = instrument_to_perp_coin(request.key.instrument);
611
612 let order_id = match &request.state.id {
614 Some(id) => id,
615 None => {
616 warn!("Cancel request missing order ID");
617 return Some(OrderResponseCancel {
618 key: OrderKey {
619 exchange: ExchangeId::HyperliquidPerp,
620 instrument: request.key.instrument.clone(),
621 strategy: request.key.strategy.clone(),
622 cid: request.key.cid.clone(),
623 },
624 state: Err(UnindexedOrderError::Rejected(
625 crate::error::ApiError::OrderRejected("Missing order ID".to_string()),
626 )),
627 });
628 }
629 };
630
631 enum CancelMethod {
636 ByOid(u64),
637 ByCloid(Uuid),
638 }
639
640 let cancel_method = if let Ok(oid) = order_id.0.parse::<u64>() {
641 CancelMethod::ByOid(oid)
642 } else if let Ok(uuid) = Uuid::parse_str(&order_id.0) {
643 CancelMethod::ByCloid(uuid)
644 } else {
645 warn!(?order_id, "Order ID is neither u64 (OID) nor UUID (cloid)");
646 return Some(OrderResponseCancel {
647 key: OrderKey {
648 exchange: ExchangeId::HyperliquidPerp,
649 instrument: request.key.instrument.clone(),
650 strategy: request.key.strategy.clone(),
651 cid: request.key.cid.clone(),
652 },
653 state: Err(UnindexedOrderError::Rejected(
654 crate::error::ApiError::OrderRejected(
655 "Invalid order ID: must be numeric OID or UUID (for trigger orders)"
656 .to_string(),
657 ),
658 )),
659 });
660 };
661
662 let response = match cancel_method {
663 CancelMethod::ByOid(oid) => {
664 debug!(oid, "Cancelling by OID");
665 let cancel_request = ClientCancelRequest { asset: coin, oid };
666 self.exchange_client.cancel(cancel_request, None).await
667 }
668 CancelMethod::ByCloid(cloid) => {
669 debug!(%cloid, "Cancelling by cloid (trigger order)");
670 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
671 self.exchange_client
672 .cancel_by_cloid(cancel_request, None)
673 .await
674 }
675 };
676
677 let response = match response {
678 Ok(r) => r,
679 Err(e) => {
680 warn!(%e, "Cancel order failed (transport)");
681 return Some(OrderResponseCancel {
682 key: OrderKey {
683 exchange: ExchangeId::HyperliquidPerp,
684 instrument: request.key.instrument.clone(),
685 strategy: request.key.strategy.clone(),
686 cid: request.key.cid.clone(),
687 },
688 state: Err(map_order_error(e, request.key.instrument)),
689 });
690 }
691 };
692
693 match response {
694 ExchangeResponseStatus::Ok(_) => {
695 debug!("Cancel order accepted");
696 Some(OrderResponseCancel {
698 key: OrderKey {
699 exchange: ExchangeId::HyperliquidPerp,
700 instrument: request.key.instrument.clone(),
701 strategy: request.key.strategy.clone(),
702 cid: request.key.cid.clone(),
703 },
704 state: Ok(Cancelled::new(
705 order_id.clone(),
706 Utc::now(),
707 Decimal::ZERO, )),
709 })
710 }
711 ExchangeResponseStatus::Err(msg) => {
712 warn!(%msg, "Cancel rejected by exchange");
713 Some(OrderResponseCancel {
714 key: OrderKey {
715 exchange: ExchangeId::HyperliquidPerp,
716 instrument: request.key.instrument.clone(),
717 strategy: request.key.strategy.clone(),
718 cid: request.key.cid.clone(),
719 },
720 state: Err(UnindexedOrderError::Rejected(
721 crate::error::ApiError::OrderRejected(msg),
722 )),
723 })
724 }
725 }
726 }
727
728 async fn open_order(
729 &self,
730 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
731 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
732 use hyperliquid_rust_sdk::{
733 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
734 ExchangeResponseStatus,
735 };
736
737 let make_rejected =
738 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
739 Order {
740 key: OrderKey {
741 exchange: ExchangeId::HyperliquidPerp,
742 instrument: request.key.instrument.clone(),
743 strategy: request.key.strategy.clone(),
744 cid: request.key.cid.clone(),
745 },
746 side: request.state.side,
747 price: request.state.price,
748 quantity: request.state.quantity,
749 kind: request.state.kind,
750 time_in_force: request.state.time_in_force,
751 state: OrderState::inactive(OrderError::Rejected(
752 crate::error::ApiError::OrderRejected(msg),
753 )),
754 }
755 };
756
757 let make_unsupported =
758 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
759 Order {
760 key: OrderKey {
761 exchange: ExchangeId::HyperliquidPerp,
762 instrument: request.key.instrument.clone(),
763 strategy: request.key.strategy.clone(),
764 cid: request.key.cid.clone(),
765 },
766 side: request.state.side,
767 price: request.state.price,
768 quantity: request.state.quantity,
769 kind: request.state.kind,
770 time_in_force: request.state.time_in_force,
771 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
772 }
773 };
774
775 let coin = instrument_to_perp_coin(request.key.instrument);
776 let is_buy = request.state.side == Side::Buy;
777
778 match request.state.kind {
779 OrderKind::Market => {
780 return Some(make_unsupported(
781 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
782 .to_string(),
783 ));
784 }
785 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
786 return Some(make_unsupported(
787 "Hyperliquid does not support trailing stop orders".to_string(),
788 ));
789 }
790 OrderKind::Limit
791 | OrderKind::Stop { .. }
792 | OrderKind::StopLimit { .. }
793 | OrderKind::TakeProfit { .. }
794 | OrderKind::TakeProfitLimit { .. } => {}
795 }
796
797 let cloid = cid_to_cloid(&request.key.cid);
798 let cloid_is_some = cloid.is_some();
799 let is_trigger_order = matches!(
800 request.state.kind,
801 OrderKind::Stop { .. }
802 | OrderKind::StopLimit { .. }
803 | OrderKind::TakeProfit { .. }
804 | OrderKind::TakeProfitLimit { .. }
805 );
806 if is_trigger_order && cloid.is_none() {
807 return Some(make_rejected(
808 "Trigger orders require UUID-format client order ID (use ClientOrderId::uuid()). \
809 Non-UUID IDs cannot be cancelled via cancel_by_cloid()."
810 .to_string(),
811 ));
812 }
813
814 let limit_px = match request.state.kind {
815 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
817 round_to_5_sig_figs(trigger_price)
818 }
819 _ => match request.state.price {
820 Some(p) => round_to_5_sig_figs(p),
821 None => {
822 return Some(make_rejected(
823 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
824 .to_string(),
825 ));
826 }
827 },
828 };
829
830 let sz = round_to_5_sig_figs(request.state.quantity);
831
832 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
834 warn!(
835 instrument = %request.key.instrument,
836 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
837 );
838 }
839 let tif = map_tif(&request.state.time_in_force).to_string();
840
841 let order_type = match request.state.kind {
843 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
844 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
845 is_market: true,
846 trigger_px: round_to_5_sig_figs(trigger_price),
847 tpsl: "sl".to_string(),
848 }),
849 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
850 is_market: false,
851 trigger_px: round_to_5_sig_figs(trigger_price),
852 tpsl: "sl".to_string(),
853 }),
854 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
855 is_market: true,
856 trigger_px: round_to_5_sig_figs(trigger_price),
857 tpsl: "tp".to_string(),
858 }),
859 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
860 is_market: false,
861 trigger_px: round_to_5_sig_figs(trigger_price),
862 tpsl: "tp".to_string(),
863 }),
864 OrderKind::Market
866 | OrderKind::TrailingStop { .. }
867 | OrderKind::TrailingStopLimit { .. } => {
868 unreachable!("unsupported order kinds rejected earlier")
869 }
870 };
871
872 let order_request = ClientOrderRequest {
873 asset: coin,
874 is_buy,
875 reduce_only: request.state.reduce_only,
876 limit_px,
877 sz,
878 cloid,
879 order_type,
880 };
881
882 let response = match self.exchange_client.order(order_request, None).await {
883 Ok(r) => r,
884 Err(e) => {
885 warn!(%e, "Open order failed");
886 return Some(Order {
887 key: OrderKey {
888 exchange: ExchangeId::HyperliquidPerp,
889 instrument: request.key.instrument.clone(),
890 strategy: request.key.strategy.clone(),
891 cid: request.key.cid.clone(),
892 },
893 side: request.state.side,
894 price: request.state.price,
895 quantity: request.state.quantity,
896 kind: request.state.kind,
897 time_in_force: request.state.time_in_force,
898 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
899 });
900 }
901 };
902
903 let state = match response {
905 ExchangeResponseStatus::Ok(exchange_resp) => {
906 let status = exchange_resp
908 .data
909 .and_then(|d| d.statuses.into_iter().next());
910
911 match status {
912 Some(ExchangeDataStatus::Resting(resting)) => {
913 debug!(oid = resting.oid, "Order resting");
914 OrderState::active(Open {
915 id: OrderId(format_smolstr!("{}", resting.oid)),
916 time_exchange: Utc::now(),
917 filled_quantity: Decimal::ZERO,
918 })
919 }
920 Some(ExchangeDataStatus::Filled(filled)) => {
921 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
922 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
924 OrderState::fully_filled(Filled::new(
925 OrderId(format_smolstr!("{}", filled.oid)),
926 Utc::now(),
927 parse_decimal(&filled.total_sz, "total_sz")
928 .unwrap_or(request.state.quantity),
929 avg_price,
930 ))
931 }
932 Some(ExchangeDataStatus::Error(msg)) => {
933 warn!(%msg, "Order rejected by exchange");
934 OrderState::inactive(OrderError::Rejected(
935 crate::error::ApiError::OrderRejected(msg),
936 ))
937 }
938 Some(
939 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
940 ) => {
941 if cloid_is_some {
947 debug!(cloid = %request.key.cid.0, "Order waiting (cloid trackable)");
948 OrderState::active(Open {
949 id: OrderId(request.key.cid.0.clone()),
950 time_exchange: Utc::now(),
951 filled_quantity: Decimal::ZERO,
952 })
953 } else {
954 warn!("Order accepted but cid is not UUID — untrackable");
955 OrderState::inactive(OrderError::Rejected(
956 crate::error::ApiError::OrderRejected(
957 "order accepted but cid is not UUID; cannot track for cancel"
958 .to_string(),
959 ),
960 ))
961 }
962 }
963 Some(ExchangeDataStatus::Success) | None => {
964 warn!("Order accepted but no order ID returned");
967 OrderState::inactive(OrderError::Rejected(
968 crate::error::ApiError::OrderRejected(
969 "no order ID in response".to_string(),
970 ),
971 ))
972 }
973 }
974 }
975 ExchangeResponseStatus::Err(msg) => {
976 warn!(%msg, "Order rejected");
977 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
978 msg,
979 )))
980 }
981 };
982
983 Some(Order {
984 key: OrderKey {
985 exchange: ExchangeId::HyperliquidPerp,
986 instrument: request.key.instrument.clone(),
987 strategy: request.key.strategy.clone(),
988 cid: request.key.cid.clone(),
989 },
990 side: request.state.side,
991 price: request.state.price,
992 quantity: request.state.quantity,
993 kind: request.state.kind,
994 time_in_force: request.state.time_in_force,
995 state,
996 })
997 }
998
999 async fn fetch_balances(
1000 &self,
1001 _assets: &[AssetNameExchange],
1002 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1003 let address = self.wallet_h160();
1004
1005 let user_state = self
1006 .info_client
1007 .user_state(address)
1008 .await
1009 .map_err(map_sdk_error)?;
1010
1011 let now = Utc::now();
1012
1013 let account_value =
1015 parse_decimal(&user_state.margin_summary.account_value, "account_value")
1016 .unwrap_or(Decimal::ZERO);
1017 let margin_used = parse_decimal(
1018 &user_state.margin_summary.total_margin_used,
1019 "total_margin_used",
1020 )
1021 .unwrap_or(Decimal::ZERO);
1022
1023 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
1025 Ok(vec![AssetBalance::new(
1026 AssetNameExchange::from(USDC_ASSET),
1027 Balance::new(account_value, free_balance),
1028 now,
1029 )])
1030 }
1031
1032 async fn fetch_open_orders(
1033 &self,
1034 instruments: &[InstrumentNameExchange],
1035 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1036 let address = self.wallet_h160();
1037
1038 let open_orders = self
1039 .info_client
1040 .open_orders(address)
1041 .await
1042 .map_err(map_sdk_error)?;
1043
1044 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1045 None
1046 } else {
1047 let mut set = HashSet::with_capacity(instruments.len());
1048 set.extend(instruments.iter().cloned());
1049 Some(set)
1050 };
1051
1052 let mut result = Vec::new();
1053 for order in open_orders {
1054 let instrument = perp_coin_to_instrument(&order.coin);
1055
1056 if instrument_filter
1057 .as_ref()
1058 .is_some_and(|f| !f.contains(&instrument))
1059 {
1060 continue;
1061 }
1062
1063 let Some(side) = parse_side(&order.side) else {
1064 continue;
1065 };
1066 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
1067 continue;
1068 };
1069 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
1070 continue;
1071 };
1072 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
1073 warn!(
1074 oid = order.oid,
1075 timestamp = order.timestamp,
1076 "Invalid order timestamp, skipping"
1077 );
1078 continue;
1079 };
1080
1081 let order_id = format_smolstr!("{}", order.oid);
1082 result.push(Order {
1083 key: OrderKey {
1084 exchange: ExchangeId::HyperliquidPerp,
1085 instrument,
1086 strategy: StrategyId::unknown(),
1087 cid: ClientOrderId::new(order_id.clone()),
1088 },
1089 side,
1090 price: Some(price),
1091 quantity,
1092 kind: OrderKind::Limit,
1093 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1094 state: Open {
1095 id: OrderId(order_id),
1096 time_exchange,
1097 filled_quantity: Decimal::ZERO,
1098 },
1099 });
1100 }
1101
1102 Ok(result)
1103 }
1104
1105 async fn fetch_trades(
1106 &self,
1107 time_since: DateTime<Utc>,
1108 instruments: &[InstrumentNameExchange],
1109 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1110 let address = self.wallet_h160();
1111
1112 let fills = self
1113 .info_client
1114 .user_fills(address)
1115 .await
1116 .map_err(map_sdk_error)?;
1117
1118 #[allow(clippy::cast_sign_loss)] let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1121
1122 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1123 None
1124 } else {
1125 let mut set = HashSet::with_capacity(instruments.len());
1126 set.extend(instruments.iter().cloned());
1127 Some(set)
1128 };
1129
1130 let mut result = Vec::new();
1131 for fill in fills {
1132 if fill.time < time_since_ms {
1134 continue;
1135 }
1136
1137 let instrument = perp_coin_to_instrument(&fill.coin);
1138
1139 if instrument_filter
1140 .as_ref()
1141 .is_some_and(|f| !f.contains(&instrument))
1142 {
1143 continue;
1144 }
1145
1146 let Some(side) = parse_side(&fill.side) else {
1147 continue;
1148 };
1149 let Some(price) = parse_decimal(&fill.px, "px") else {
1150 continue;
1151 };
1152 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1153 continue;
1154 };
1155 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1156
1157 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1158 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1159 continue;
1160 };
1161
1162 result.push(Trade {
1163 id: TradeId(SmolStr::new(&fill.hash)),
1164 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1165 instrument,
1166 strategy: StrategyId::unknown(),
1167 time_exchange,
1168 side,
1169 price,
1170 quantity,
1171 fees: AssetFees {
1172 asset: AssetNameExchange::from("USDC"),
1173 fees: fee,
1174 fees_quote: Some(fee),
1175 },
1176 });
1177 }
1178
1179 Ok(result)
1180 }
1181}
1182
1183fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1185 let side = parse_side(&fill.side)?;
1186 let price = parse_decimal(&fill.px, "fill.px")?;
1187 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1188 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1189 let time_exchange = millis_to_datetime(fill.time)?;
1190 let instrument = perp_coin_to_instrument(&fill.coin);
1191 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1192
1193 let trade = Trade {
1194 id: TradeId(SmolStr::new(&fill.hash)),
1195 order_id,
1196 instrument,
1197 strategy: StrategyId::unknown(),
1198 time_exchange,
1199 side,
1200 price,
1201 quantity,
1202 fees: AssetFees {
1203 asset: AssetNameExchange::from("USDC"),
1204 fees: fee,
1205 fees_quote: Some(fee),
1206 },
1207 };
1208
1209 Some(AccountEvent::new(
1210 ExchangeId::HyperliquidPerp,
1211 AccountEventKind::Trade(trade),
1212 ))
1213}
1214
1215fn order_update_to_account_event(
1217 update: &hyperliquid_rust_sdk::OrderUpdate,
1218) -> Option<UnindexedAccountEvent> {
1219 let order = &update.order;
1220 let side = parse_side(&order.side)?;
1221 let price = parse_decimal(&order.limit_px, "order.limit_px")?;
1222 let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
1223 let time_exchange = millis_to_datetime(update.status_timestamp)?;
1224 let instrument = perp_coin_to_instrument(&order.coin);
1225
1226 let order_id_smol = format_smolstr!("{}", order.oid);
1228 let cid = order
1229 .cloid
1230 .as_deref()
1231 .map(|c| ClientOrderId::new(SmolStr::new(c)))
1232 .unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
1233
1234 let state = match update.status.as_str() {
1236 "open" | "resting" => {
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::active(Open {
1240 id: OrderId(order_id_smol),
1241 time_exchange,
1242 filled_quantity,
1243 })
1244 }
1245 "filled" => crate::order::state::OrderState::fully_filled(Filled::new(
1246 OrderId(order_id_smol),
1247 time_exchange,
1248 orig_sz, None, )),
1251 "canceled" | "cancelled" => {
1252 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1253 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1254 crate::order::state::OrderState::inactive(Cancelled::new(
1255 OrderId(order_id_smol),
1256 time_exchange,
1257 filled_quantity,
1258 ))
1259 }
1260 status => {
1261 warn!(%status, "Unknown order status");
1262 return None;
1263 }
1264 };
1265
1266 let order_snapshot = Order {
1269 key: OrderKey {
1270 exchange: ExchangeId::HyperliquidPerp,
1271 instrument,
1272 strategy: StrategyId::unknown(),
1273 cid,
1274 },
1275 side,
1276 price: Some(price),
1277 quantity: orig_sz,
1278 kind: OrderKind::Limit,
1279 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1280 state,
1281 };
1282
1283 Some(AccountEvent::new(
1284 ExchangeId::HyperliquidPerp,
1285 AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
1286 ))
1287}
1288
1289#[cfg(test)]
1290#[allow(clippy::unwrap_used)]
1292mod tests {
1293 use super::*;
1294 use rust_decimal_macros::dec;
1295
1296 #[test]
1297 fn test_fill_to_account_event() {
1298 let fill_json = r#"{
1299 "coin": "BTC",
1300 "side": "B",
1301 "px": "65000.5",
1302 "sz": "0.1",
1303 "time": 1714100000000,
1304 "hash": "0xabc123",
1305 "startPosition": "0",
1306 "dir": "Open Long",
1307 "closedPnl": "0",
1308 "oid": 12345,
1309 "cloid": null,
1310 "crossed": false,
1311 "fee": "0.65",
1312 "feeToken": "USDC",
1313 "tid": 99999
1314 }"#;
1315
1316 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1317 let event = fill_to_account_event(&fill).unwrap();
1318
1319 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1320 match event.kind {
1321 AccountEventKind::Trade(trade) => {
1322 assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
1323 assert_eq!(trade.side, Side::Buy);
1324 assert_eq!(trade.price, dec!(65000.5));
1325 assert_eq!(trade.quantity, dec!(0.1));
1326 assert_eq!(trade.fees.fees, dec!(0.65));
1327 }
1328 _ => panic!("Expected Trade event"),
1329 }
1330 }
1331
1332 #[test]
1333 fn test_fill_to_account_event_sell() {
1334 let fill_json = r#"{
1335 "coin": "ETH",
1336 "side": "A",
1337 "px": "3200",
1338 "sz": "1.5",
1339 "time": 1714100000000,
1340 "hash": "0xdef456",
1341 "startPosition": "1.5",
1342 "dir": "Close Long",
1343 "closedPnl": "150.0",
1344 "oid": 12346,
1345 "cloid": null,
1346 "crossed": true,
1347 "fee": "4.8",
1348 "feeToken": "USDC",
1349 "tid": 100000
1350 }"#;
1351
1352 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1353 let event = fill_to_account_event(&fill).unwrap();
1354
1355 match event.kind {
1356 AccountEventKind::Trade(trade) => {
1357 assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
1358 assert_eq!(trade.side, Side::Sell);
1359 assert_eq!(trade.price, dec!(3200));
1360 assert_eq!(trade.quantity, dec!(1.5));
1361 }
1362 _ => panic!("Expected Trade event"),
1363 }
1364 }
1365
1366 #[test]
1367 fn test_order_update_to_account_event_open() {
1368 let update_json = r#"{
1369 "order": {
1370 "coin": "BTC",
1371 "side": "B",
1372 "limitPx": "64000",
1373 "sz": "0.5",
1374 "oid": 12345,
1375 "timestamp": 1714100000000,
1376 "origSz": "0.5",
1377 "cloid": null
1378 },
1379 "status": "open",
1380 "statusTimestamp": 1714100000000
1381 }"#;
1382
1383 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1384 let event = order_update_to_account_event(&update).unwrap();
1385
1386 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1387 match event.kind {
1388 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1389 assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
1390 assert_eq!(order.side, Side::Buy);
1391 assert_eq!(order.price, Some(dec!(64000)));
1392 assert_eq!(order.quantity, dec!(0.5));
1393 assert!(matches!(
1394 order.state,
1395 crate::order::state::OrderState::Active(_)
1396 ));
1397 }
1398 _ => panic!("Expected OrderSnapshot event"),
1399 }
1400 }
1401
1402 #[test]
1403 fn test_order_update_to_account_event_filled() {
1404 let update_json = r#"{
1405 "order": {
1406 "coin": "ETH",
1407 "side": "A",
1408 "limitPx": "3250",
1409 "sz": "0",
1410 "oid": 12346,
1411 "timestamp": 1714100000000,
1412 "origSz": "2.0",
1413 "cloid": null
1414 },
1415 "status": "filled",
1416 "statusTimestamp": 1714100001000
1417 }"#;
1418
1419 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1420 let event = order_update_to_account_event(&update).unwrap();
1421
1422 match event.kind {
1423 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1424 assert_eq!(order.side, Side::Sell);
1425 assert!(matches!(
1426 order.state,
1427 crate::order::state::OrderState::Inactive(
1428 crate::order::state::InactiveOrderState::FullyFilled(_)
1429 )
1430 ));
1431 }
1432 _ => panic!("Expected OrderSnapshot event"),
1433 }
1434 }
1435
1436 #[test]
1437 fn test_order_update_to_account_event_cancelled() {
1438 let update_json = r#"{
1439 "order": {
1440 "coin": "SOL",
1441 "side": "B",
1442 "limitPx": "150",
1443 "sz": "10",
1444 "oid": 12347,
1445 "timestamp": 1714100000000,
1446 "origSz": "10",
1447 "cloid": null
1448 },
1449 "status": "canceled",
1450 "statusTimestamp": 1714100002000
1451 }"#;
1452
1453 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1454 let event = order_update_to_account_event(&update).unwrap();
1455
1456 match event.kind {
1457 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1458 assert_eq!(order.key.instrument.as_ref(), "SOL-USD-PERP");
1459 assert!(matches!(
1460 order.state,
1461 crate::order::state::OrderState::Inactive(
1462 crate::order::state::InactiveOrderState::Cancelled(_)
1463 )
1464 ));
1465 }
1466 _ => panic!("Expected OrderSnapshot event"),
1467 }
1468 }
1469
1470 #[test]
1471 fn test_order_update_unknown_status_returns_none() {
1472 let update_json = r#"{
1473 "order": {
1474 "coin": "BTC",
1475 "side": "B",
1476 "limitPx": "64000",
1477 "sz": "0.5",
1478 "oid": 12345,
1479 "timestamp": 1714100000000,
1480 "origSz": "0.5",
1481 "cloid": null
1482 },
1483 "status": "unknown_status",
1484 "statusTimestamp": 1714100000000
1485 }"#;
1486
1487 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1488 let event = order_update_to_account_event(&update);
1489 assert!(event.is_none());
1490 }
1491}