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