1pub mod common;
100pub mod config;
101pub mod error;
102pub mod spot;
103
104use crate::client::dedup::{dedup_key_from_event, is_duplicate, new_dedup_cache};
105use crate::{
106 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
107 UnindexedAccountEvent, UnindexedAccountSnapshot,
108 balance::{AssetBalance, Balance},
109 client::ExecutionClient,
110 emit_stream_terminated,
111 error::{
112 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
113 UnindexedOrderError,
114 },
115 order::{
116 Order, OrderKey, OrderKind, TimeInForce,
117 id::{OrderId, StrategyId, VenueOrderId},
118 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
119 state::{Filled, Open, OrderState, UnindexedOrderState},
120 },
121 position::{Position, PositionReport},
122 trade::{AssetFees, Trade, TradeId},
123};
124use chrono::{DateTime, Utc};
125use common::{
126 CLOID_REQUIRED, CancelOnDropStream, OpenOrderListing, cid_to_cloid, instrument_to_perp_coin,
127 map_tif, millis_to_datetime, open_order_to_order, open_orders, parse_decimal, parse_side,
128 perp_coin_to_instrument, round_to_5_sig_figs, user_fills,
129};
130pub use config::{HyperliquidConfig, HyperliquidConfigError};
131use error::{map_order_error, map_sdk_error};
132use ethers::signers::Signer;
133use futures::{StreamExt, stream::BoxStream};
134use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
135use rust_decimal::Decimal;
136use rustrade_instrument::{
137 Side,
138 asset::name::AssetNameExchange,
139 exchange::ExchangeId,
140 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
141};
142use smol_str::format_smolstr;
143use std::{
144 collections::HashSet,
145 sync::{
146 Arc,
147 atomic::{AtomicBool, Ordering},
148 },
149};
150use tokio::sync::mpsc;
151use tokio_util::sync::CancellationToken;
152use tracing::{debug, error, info, trace, warn};
153
154const USDC_ASSET: &str = "USDC";
156
157#[derive(Debug, Clone)]
162pub struct HyperliquidClient {
163 config: HyperliquidConfig,
164 info_client: Arc<InfoClient>,
165 exchange_client: Arc<ExchangeClient>,
166}
167
168impl HyperliquidClient {
169 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
174 let base_url = if config.testnet {
175 BaseUrl::Testnet
176 } else {
177 BaseUrl::Mainnet
178 };
179
180 let info_client = InfoClient::new(None, Some(base_url))
181 .await
182 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
183
184 let wallet = config.wallet.clone();
185 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
186 .await
187 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
188
189 info!(
190 testnet = config.testnet,
191 wallet = %config.wallet_address_hex(),
192 "Created HyperliquidClient"
193 );
194
195 Ok(Self {
196 config,
197 info_client: Arc::new(info_client),
198 exchange_client: Arc::new(exchange_client),
199 })
200 }
201
202 fn base_url(&self) -> BaseUrl {
204 if self.config.testnet {
205 BaseUrl::Testnet
206 } else {
207 BaseUrl::Mainnet
208 }
209 }
210
211 pub fn wallet_address(&self) -> String {
213 self.config.wallet_address_hex()
214 }
215
216 fn wallet_h160(&self) -> ethers::types::H160 {
218 self.config.wallet.address()
219 }
220}
221
222impl ExecutionClient for HyperliquidClient {
223 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
224
225 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
228 &[InstrumentKindDiscriminant::Perpetual];
229
230 type Config = HyperliquidConfig;
231 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
232
233 fn new(config: Self::Config) -> Self {
246 let base_url = if config.testnet {
247 BaseUrl::Testnet
248 } else {
249 BaseUrl::Mainnet
250 };
251
252 let handle = tokio::runtime::Handle::current();
255
256 let info_client = handle.block_on(async {
257 InfoClient::new(None, Some(base_url))
258 .await
259 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
260 });
261
262 let wallet = config.wallet.clone();
263 let exchange_client = handle.block_on(async {
264 ExchangeClient::new(None, wallet, Some(base_url), None, None)
265 .await
266 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
267 });
268
269 info!(
270 testnet = config.testnet,
271 wallet = %config.wallet_address_hex(),
272 "Created HyperliquidClient"
273 );
274
275 Self {
276 config,
277 info_client: Arc::new(info_client),
278 exchange_client: Arc::new(exchange_client),
279 }
280 }
281
282 async fn account_snapshot(
291 &self,
292 _assets: &[AssetNameExchange],
293 instruments: &[InstrumentNameExchange],
294 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
295 let address = self.wallet_h160();
296
297 let (user_state, open_orders) = tokio::try_join!(
299 async {
300 self.info_client
301 .user_state(address)
302 .await
303 .map_err(map_sdk_error)
304 },
305 open_orders(&self.info_client, address)
306 )?;
307
308 let now = Utc::now();
309
310 let account_value =
312 parse_decimal(&user_state.margin_summary.account_value, "account_value")
313 .unwrap_or(Decimal::ZERO);
314 let margin_used = parse_decimal(
315 &user_state.margin_summary.total_margin_used,
316 "total_margin_used",
317 )
318 .unwrap_or(Decimal::ZERO);
319
320 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
323 let balances = vec![AssetBalance::new(
324 AssetNameExchange::from(USDC_ASSET),
325 Balance::new(account_value, free_balance),
326 now,
327 )];
328
329 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
331 None
332 } else {
333 let mut set = HashSet::with_capacity(instruments.len());
334 set.extend(instruments.iter().cloned());
335 Some(set)
336 };
337
338 let mut listing = OpenOrderListing::new(
339 &open_orders,
340 ExchangeId::HyperliquidPerp,
341 instruments,
342 |coin| Some(perp_coin_to_instrument(coin)),
343 );
344
345 let mut instrument_snapshots = Vec::new();
347 for asset_pos in user_state.asset_positions {
348 let pos = &asset_pos.position;
349 let instrument = perp_coin_to_instrument(&pos.coin);
350
351 if instrument_filter
352 .as_ref()
353 .is_some_and(|f| !f.contains(&instrument))
354 {
355 continue;
356 }
357
358 let position = perp_position_report(pos, now);
359
360 let (orders, orders_complete) = match listing.remove(&instrument) {
361 Some(listed) => (listed.orders, listed.orders_complete),
362 None => (Vec::new(), true),
365 };
366
367 instrument_snapshots.push(InstrumentAccountSnapshot {
368 instrument,
369 orders,
370 orders_complete,
371 position,
372 isolated: None,
373 });
374 }
375
376 instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
379 snapshot.position = PositionReport::Flat;
380 snapshot
381 }));
382
383 Ok(AccountSnapshot {
384 exchange: ExchangeId::HyperliquidPerp,
385 balances,
386 instruments: instrument_snapshots,
387 })
388 }
389
390 async fn account_stream(
405 &self,
406 _assets: &[AssetNameExchange],
407 _instruments: &[InstrumentNameExchange],
408 ) -> Result<Self::AccountStream, UnindexedClientError> {
409 let user = self.wallet_h160();
410 let base_url = self.base_url();
411
412 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
415 .await
416 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
417
418 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
420 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
421
422 ws_client
424 .subscribe(Subscription::UserFills { user }, fills_tx)
425 .await
426 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
427
428 ws_client
430 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
431 .await
432 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
433
434 info!(%user, "Subscribed to Hyperliquid account stream");
435
436 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
438
439 let cancel_token = CancellationToken::new();
441
442 let terminated = Arc::new(AtomicBool::new(false));
447
448 let fills_dedup = new_dedup_cache();
452
453 let fills_event_tx = event_tx.clone();
455 let fills_cancel = cancel_token.clone();
456 let fills_terminated = terminated.clone();
457 tokio::spawn(async move {
458 loop {
459 tokio::select! {
460 biased;
461 () = fills_cancel.cancelled() => {
462 debug!("Fills task cancelled");
463 return;
464 }
465 msg = fills_rx.recv() => {
466 let Some(msg) = msg else {
467 debug!("Fills receiver closed");
468 if fills_terminated
471 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
472 .is_ok()
473 {
474 emit_stream_terminated(
475 &fills_event_tx,
476 ExchangeId::HyperliquidPerp,
477 StreamTerminationReason::Error(
478 "hyperliquid account stream closed".to_string(),
479 ),
480 );
481 }
482 return;
483 };
484 match msg {
485 Message::UserFills(fills) => {
486 for fill in fills.data.fills {
496 let Some(event) = fill_to_account_event(&fill) else {
497 continue;
498 };
499 if let Some(key) = dedup_key_from_event(&event)
500 && is_duplicate(&fills_dedup, key)
501 {
502 trace!(
503 tid = fill.tid,
504 "Hyperliquid dedup: skipping fill already delivered"
505 );
506 continue;
507 }
508 if fills_event_tx.send(event).is_err() {
509 debug!("Fills event channel closed");
510 return;
511 }
512 }
513 }
514 Message::NoData => {
515 warn!("UserFills WebSocket disconnected");
516 }
517 Message::HyperliquidError(e) => {
518 error!(%e, "UserFills WebSocket error");
521 }
522 _ => {}
523 }
524 }
525 }
526 }
527 });
528
529 let orders_event_tx = event_tx;
534 let orders_cancel = cancel_token.clone();
535 let orders_terminated = terminated;
536 tokio::spawn(async move {
537 let _ws_client = ws_client;
538
539 loop {
540 tokio::select! {
541 biased;
542 () = orders_cancel.cancelled() => {
543 debug!("Orders task cancelled");
544 return;
545 }
546 msg = orders_rx.recv() => {
547 let Some(msg) = msg else {
548 debug!("Orders receiver closed");
549 if orders_terminated
552 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
553 .is_ok()
554 {
555 emit_stream_terminated(
556 &orders_event_tx,
557 ExchangeId::HyperliquidPerp,
558 StreamTerminationReason::Error(
559 "hyperliquid account stream closed".to_string(),
560 ),
561 );
562 }
563 return;
564 };
565 match msg {
566 Message::OrderUpdates(updates) => {
567 for update in updates.data {
568 if let Some(event) = order_update_to_account_event(&update)
569 && orders_event_tx.send(event).is_err()
570 {
571 debug!("Orders event channel closed");
572 return;
573 }
574 }
575 }
576 Message::NoData => {
577 warn!("OrderUpdates WebSocket disconnected");
578 }
579 Message::HyperliquidError(e) => {
580 error!(%e, "OrderUpdates WebSocket error");
583 }
584 _ => {}
585 }
586 }
587 }
588 }
589 });
590
591 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
593 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
594 Ok(guarded_stream.boxed())
595 }
596
597 async fn cancel_order(
598 &self,
599 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
600 ) -> Option<UnindexedOrderResponseCancel> {
601 use crate::order::{request::OrderResponseCancel, state::Cancelled};
602 use hyperliquid_rust_sdk::{
603 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
604 };
605 use uuid::Uuid;
606
607 let coin = instrument_to_perp_coin(request.key.instrument);
608
609 enum CancelMethod {
610 ByOid(u64),
611 ByCloid(Uuid),
612 }
613
614 let cancel_method = match &request.state.id {
619 Some(VenueOrderId::Assigned(id)) => {
620 id.0.parse::<u64>()
621 .map(CancelMethod::ByOid)
622 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
623 }
624 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
628 .map(CancelMethod::ByCloid)
629 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
630 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
631 };
632
633 let cancel_method = match cancel_method {
634 Ok(method) => method,
635 Err(reason) => {
636 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
637 return Some(OrderResponseCancel {
638 key: OrderKey {
639 exchange: ExchangeId::HyperliquidPerp,
640 instrument: request.key.instrument.clone(),
641 strategy: request.key.strategy.clone(),
642 cid: request.key.cid.clone(),
643 },
644 state: Err(UnindexedOrderError::Rejected(
645 crate::error::ApiError::OrderRejected(reason.to_string()),
646 )),
647 });
648 }
649 };
650
651 let cancelled_id = match &request.state.id {
654 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
655 None => OrderId(request.key.cid.0.clone()),
656 };
657
658 let response = match cancel_method {
659 CancelMethod::ByOid(oid) => {
660 debug!(oid, "Cancelling by OID");
661 let cancel_request = ClientCancelRequest { asset: coin, oid };
662 self.exchange_client.cancel(cancel_request, None).await
663 }
664 CancelMethod::ByCloid(cloid) => {
665 debug!(%cloid, "Cancelling by cloid (no oid assigned)");
666 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
667 self.exchange_client
668 .cancel_by_cloid(cancel_request, None)
669 .await
670 }
671 };
672
673 let response = match response {
674 Ok(r) => r,
675 Err(e) => {
676 warn!(%e, "Cancel order failed (transport)");
677 return Some(OrderResponseCancel {
678 key: OrderKey {
679 exchange: ExchangeId::HyperliquidPerp,
680 instrument: request.key.instrument.clone(),
681 strategy: request.key.strategy.clone(),
682 cid: request.key.cid.clone(),
683 },
684 state: Err(map_order_error(e, request.key.instrument)),
685 });
686 }
687 };
688
689 match response {
690 ExchangeResponseStatus::Ok(_) => {
691 debug!("Cancel order accepted");
692 Some(OrderResponseCancel {
694 key: OrderKey {
695 exchange: ExchangeId::HyperliquidPerp,
696 instrument: request.key.instrument.clone(),
697 strategy: request.key.strategy.clone(),
698 cid: request.key.cid.clone(),
699 },
700 state: Ok(Cancelled::new(
701 cancelled_id.clone(),
702 Utc::now(),
703 Decimal::ZERO, )),
705 })
706 }
707 ExchangeResponseStatus::Err(msg) => {
708 warn!(%msg, "Cancel rejected by exchange");
709 Some(OrderResponseCancel {
710 key: OrderKey {
711 exchange: ExchangeId::HyperliquidPerp,
712 instrument: request.key.instrument.clone(),
713 strategy: request.key.strategy.clone(),
714 cid: request.key.cid.clone(),
715 },
716 state: Err(UnindexedOrderError::Rejected(
717 crate::error::ApiError::OrderRejected(msg),
718 )),
719 })
720 }
721 }
722 }
723
724 async fn open_order(
725 &self,
726 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
727 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
728 use hyperliquid_rust_sdk::{
729 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
730 ExchangeResponseStatus,
731 };
732
733 let make_rejected =
734 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
735 Order {
736 key: OrderKey {
737 exchange: ExchangeId::HyperliquidPerp,
738 instrument: request.key.instrument.clone(),
739 strategy: request.key.strategy.clone(),
740 cid: request.key.cid.clone(),
741 },
742 side: request.state.side,
743 price: request.state.price,
744 quantity: request.state.quantity,
745 kind: request.state.kind,
746 time_in_force: request.state.time_in_force,
747 state: OrderState::inactive(OrderError::Rejected(
748 crate::error::ApiError::OrderRejected(msg),
749 )),
750 }
751 };
752
753 let make_unsupported =
754 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
755 Order {
756 key: OrderKey {
757 exchange: ExchangeId::HyperliquidPerp,
758 instrument: request.key.instrument.clone(),
759 strategy: request.key.strategy.clone(),
760 cid: request.key.cid.clone(),
761 },
762 side: request.state.side,
763 price: request.state.price,
764 quantity: request.state.quantity,
765 kind: request.state.kind,
766 time_in_force: request.state.time_in_force,
767 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
768 }
769 };
770
771 let coin = instrument_to_perp_coin(request.key.instrument);
772 let is_buy = request.state.side == Side::Buy;
773
774 match request.state.kind {
775 OrderKind::Market => {
776 return Some(make_unsupported(
777 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
778 .to_string(),
779 ));
780 }
781 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
782 return Some(make_unsupported(
783 "Hyperliquid does not support trailing stop orders".to_string(),
784 ));
785 }
786 OrderKind::Limit
787 | OrderKind::Stop { .. }
788 | OrderKind::StopLimit { .. }
789 | OrderKind::TakeProfit { .. }
790 | OrderKind::TakeProfitLimit { .. } => {}
791 }
792
793 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
796 return Some(make_rejected(CLOID_REQUIRED.to_string()));
797 };
798
799 let limit_px = match request.state.kind {
800 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
802 round_to_5_sig_figs(trigger_price)
803 }
804 _ => match request.state.price {
805 Some(p) => round_to_5_sig_figs(p),
806 None => {
807 return Some(make_rejected(
808 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
809 .to_string(),
810 ));
811 }
812 },
813 };
814
815 let sz = round_to_5_sig_figs(request.state.quantity);
816
817 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
819 warn!(
820 instrument = %request.key.instrument,
821 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
822 );
823 }
824 let tif = map_tif(&request.state.time_in_force).to_string();
825
826 let order_type = match request.state.kind {
828 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
829 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
830 is_market: true,
831 trigger_px: round_to_5_sig_figs(trigger_price),
832 tpsl: "sl".to_string(),
833 }),
834 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
835 is_market: false,
836 trigger_px: round_to_5_sig_figs(trigger_price),
837 tpsl: "sl".to_string(),
838 }),
839 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
840 is_market: true,
841 trigger_px: round_to_5_sig_figs(trigger_price),
842 tpsl: "tp".to_string(),
843 }),
844 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
845 is_market: false,
846 trigger_px: round_to_5_sig_figs(trigger_price),
847 tpsl: "tp".to_string(),
848 }),
849 OrderKind::Market
851 | OrderKind::TrailingStop { .. }
852 | OrderKind::TrailingStopLimit { .. } => {
853 unreachable!("unsupported order kinds rejected earlier")
854 }
855 };
856
857 let order_request = ClientOrderRequest {
858 asset: coin,
859 is_buy,
860 reduce_only: request.state.reduce_only,
861 limit_px,
862 sz,
863 cloid: Some(cloid),
864 order_type,
865 };
866
867 let response = match self.exchange_client.order(order_request, None).await {
868 Ok(r) => r,
869 Err(e) => {
870 warn!(%e, "Open order failed");
871 return Some(Order {
872 key: OrderKey {
873 exchange: ExchangeId::HyperliquidPerp,
874 instrument: request.key.instrument.clone(),
875 strategy: request.key.strategy.clone(),
876 cid: request.key.cid.clone(),
877 },
878 side: request.state.side,
879 price: request.state.price,
880 quantity: request.state.quantity,
881 kind: request.state.kind,
882 time_in_force: request.state.time_in_force,
883 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
884 });
885 }
886 };
887
888 let state = match response {
890 ExchangeResponseStatus::Ok(exchange_resp) => {
891 let status = exchange_resp
893 .data
894 .and_then(|d| d.statuses.into_iter().next());
895
896 match status {
897 Some(ExchangeDataStatus::Resting(resting)) => {
898 debug!(oid = resting.oid, "Order resting");
899 OrderState::active(Open {
900 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
901 time_exchange: Utc::now(),
902 filled_quantity: Decimal::ZERO,
903 })
904 }
905 Some(ExchangeDataStatus::Filled(filled)) => {
906 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
907 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
909 OrderState::fully_filled(Filled::new(
910 OrderId(format_smolstr!("{}", filled.oid)),
911 Utc::now(),
912 parse_decimal(&filled.total_sz, "total_sz")
913 .unwrap_or(request.state.quantity),
914 avg_price,
915 ))
916 }
917 Some(ExchangeDataStatus::Error(msg)) => {
918 warn!(%msg, "Order rejected by exchange");
919 OrderState::inactive(OrderError::Rejected(
920 crate::error::ApiError::OrderRejected(msg),
921 ))
922 }
923 Some(
924 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
925 ) => {
926 debug!(cid = %request.key.cid, "Order waiting under its cloid");
930 OrderState::active(Open {
931 id: VenueOrderId::ClientAssigned,
932 time_exchange: Utc::now(),
933 filled_quantity: Decimal::ZERO,
934 })
935 }
936 Some(ExchangeDataStatus::Success) | None => {
937 warn!("Order accepted but no order ID returned");
940 OrderState::inactive(OrderError::Rejected(
941 crate::error::ApiError::OrderRejected(
942 "no order ID in response".to_string(),
943 ),
944 ))
945 }
946 }
947 }
948 ExchangeResponseStatus::Err(msg) => {
949 warn!(%msg, "Order rejected");
950 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
951 msg,
952 )))
953 }
954 };
955
956 Some(Order {
957 key: OrderKey {
958 exchange: ExchangeId::HyperliquidPerp,
959 instrument: request.key.instrument.clone(),
960 strategy: request.key.strategy.clone(),
961 cid: request.key.cid.clone(),
962 },
963 side: request.state.side,
964 price: request.state.price,
965 quantity: request.state.quantity,
966 kind: request.state.kind,
967 time_in_force: request.state.time_in_force,
968 state,
969 })
970 }
971
972 async fn fetch_balances(
973 &self,
974 _assets: &[AssetNameExchange],
975 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
976 let address = self.wallet_h160();
977
978 let user_state = self
979 .info_client
980 .user_state(address)
981 .await
982 .map_err(map_sdk_error)?;
983
984 let now = Utc::now();
985
986 let account_value =
988 parse_decimal(&user_state.margin_summary.account_value, "account_value")
989 .unwrap_or(Decimal::ZERO);
990 let margin_used = parse_decimal(
991 &user_state.margin_summary.total_margin_used,
992 "total_margin_used",
993 )
994 .unwrap_or(Decimal::ZERO);
995
996 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
998 Ok(vec![AssetBalance::new(
999 AssetNameExchange::from(USDC_ASSET),
1000 Balance::new(account_value, free_balance),
1001 now,
1002 )])
1003 }
1004
1005 async fn fetch_open_orders(
1006 &self,
1007 instruments: &[InstrumentNameExchange],
1008 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1009 let address = self.wallet_h160();
1010
1011 let open_orders = open_orders(&self.info_client, address).await?;
1012
1013 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1014 None
1015 } else {
1016 let mut set = HashSet::with_capacity(instruments.len());
1017 set.extend(instruments.iter().cloned());
1018 Some(set)
1019 };
1020
1021 Ok(open_orders
1022 .iter()
1023 .filter_map(|order| {
1024 let instrument = perp_coin_to_instrument(&order.coin);
1025 if instrument_filter
1026 .as_ref()
1027 .is_some_and(|f| !f.contains(&instrument))
1028 {
1029 return None;
1030 }
1031 open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1032 })
1033 .collect())
1034 }
1035
1036 async fn fetch_trades(
1037 &self,
1038 time_since: DateTime<Utc>,
1039 instruments: &[InstrumentNameExchange],
1040 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1041 let address = self.wallet_h160();
1042
1043 let fills = user_fills(&self.info_client, address).await?;
1044
1045 #[allow(clippy::cast_sign_loss)] let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1048
1049 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1050 None
1051 } else {
1052 let mut set = HashSet::with_capacity(instruments.len());
1053 set.extend(instruments.iter().cloned());
1054 Some(set)
1055 };
1056
1057 let mut result = Vec::new();
1058 for fill in fills {
1059 if fill.time < time_since_ms {
1061 continue;
1062 }
1063
1064 let instrument = perp_coin_to_instrument(&fill.coin);
1065
1066 if instrument_filter
1067 .as_ref()
1068 .is_some_and(|f| !f.contains(&instrument))
1069 {
1070 continue;
1071 }
1072
1073 let Some(side) = parse_side(&fill.side) else {
1074 continue;
1075 };
1076 let Some(price) = parse_decimal(&fill.px, "px") else {
1077 continue;
1078 };
1079 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1080 continue;
1081 };
1082 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1083
1084 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1085 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1086 continue;
1087 };
1088
1089 result.push(Trade {
1090 id: TradeId(format_smolstr!("{}", fill.tid)),
1091 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1092 instrument,
1093 strategy: StrategyId::unknown(),
1094 time_exchange,
1095 side,
1096 price,
1097 quantity,
1098 order_filled_quantity: None,
1101 fees: AssetFees {
1102 asset: AssetNameExchange::from("USDC"),
1103 fees: fee,
1104 fees_quote: Some(fee),
1105 },
1106 });
1107 }
1108
1109 Ok(result)
1110 }
1111}
1112
1113fn perp_position_report(
1119 position: &hyperliquid_rust_sdk::PositionData,
1120 now: DateTime<Utc>,
1121) -> PositionReport {
1122 let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1123 return PositionReport::Unreported;
1124 };
1125 PositionReport::from_position(Position::new(
1126 quantity,
1127 position
1128 .entry_px
1129 .as_ref()
1130 .and_then(|p| parse_decimal(p, "entry_px")),
1131 parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1132 parse_decimal(&position.margin_used, "margin_used"),
1133 position
1134 .liquidation_px
1135 .as_ref()
1136 .and_then(|p| parse_decimal(p, "liquidation_px")),
1137 Some(Decimal::from(position.leverage.value)),
1138 now,
1139 ))
1140}
1141
1142fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1144 let side = parse_side(&fill.side)?;
1145 let price = parse_decimal(&fill.px, "fill.px")?;
1146 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1147 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1148 let time_exchange = millis_to_datetime(fill.time)?;
1149 let instrument = perp_coin_to_instrument(&fill.coin);
1150 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1151
1152 let trade = Trade {
1153 id: TradeId(format_smolstr!("{}", fill.tid)),
1154 order_id,
1155 instrument,
1156 strategy: StrategyId::unknown(),
1157 time_exchange,
1158 side,
1159 price,
1160 quantity,
1161 order_filled_quantity: None,
1164 fees: AssetFees {
1165 asset: AssetNameExchange::from("USDC"),
1166 fees: fee,
1167 fees_quote: Some(fee),
1168 },
1169 };
1170
1171 Some(AccountEvent::new(
1172 ExchangeId::HyperliquidPerp,
1173 AccountEventKind::Trade(trade),
1174 ))
1175}
1176
1177fn order_update_to_account_event(
1179 update: &hyperliquid_rust_sdk::OrderUpdate,
1180) -> Option<UnindexedAccountEvent> {
1181 common::order_update_to_account_event(
1182 update,
1183 ExchangeId::HyperliquidPerp,
1184 perp_coin_to_instrument(&update.order.coin),
1185 )
1186}
1187
1188#[cfg(test)]
1189#[allow(clippy::unwrap_used)]
1191mod tests {
1192 use super::*;
1193 use rust_decimal_macros::dec;
1194 use rustrade_integration::collection::snapshot::Snapshot;
1195
1196 fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
1197 serde_json::from_value(serde_json::json!({
1198 "coin": "BTC",
1199 "entryPx": "50000.0",
1200 "leverage": {"type": "cross", "value": 5},
1201 "liquidationPx": null,
1202 "marginUsed": "100.0",
1203 "positionValue": "500.0",
1204 "returnOnEquity": "0.0",
1205 "szi": szi,
1206 "unrealizedPnl": "1.5",
1207 "maxLeverage": 50,
1208 "cumFunding": {"allTime": "0", "sinceOpen": "0", "sinceChange": "0"}
1209 }))
1210 .unwrap()
1211 }
1212
1213 #[test]
1214 fn perp_position_report_is_open_flat_or_unreported() {
1215 let now = Utc::now();
1216 let open = perp_position_report(&perp_position("-0.01"), now);
1217 let position = open.open().unwrap();
1218 assert_eq!(
1219 (position.quantity, position.entry_price, position.leverage),
1220 (dec!(-0.01), Some(dec!(50000.0)), Some(dec!(5)))
1221 );
1222 assert_eq!(
1223 perp_position_report(&perp_position("0.0"), now),
1224 PositionReport::Flat
1225 );
1226 assert_eq!(
1227 perp_position_report(&perp_position("not a number"), now),
1228 PositionReport::Unreported
1229 );
1230 }
1231
1232 #[test]
1233 fn test_fill_to_account_event() {
1234 let fill_json = r#"{
1235 "coin": "BTC",
1236 "side": "B",
1237 "px": "65000.5",
1238 "sz": "0.1",
1239 "time": 1714100000000,
1240 "hash": "0xabc123",
1241 "startPosition": "0",
1242 "dir": "Open Long",
1243 "closedPnl": "0",
1244 "oid": 12345,
1245 "cloid": null,
1246 "crossed": false,
1247 "fee": "0.65",
1248 "feeToken": "USDC",
1249 "tid": 99999
1250 }"#;
1251
1252 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1253 let event = fill_to_account_event(&fill).unwrap();
1254
1255 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1256 match event.kind {
1257 AccountEventKind::Trade(trade) => {
1258 assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
1259 assert_eq!(
1260 trade.id.0, "99999",
1261 "the id is the fill's tid, not its hash"
1262 );
1263 assert_eq!(trade.side, Side::Buy);
1264 assert_eq!(trade.price, dec!(65000.5));
1265 assert_eq!(trade.quantity, dec!(0.1));
1266 assert_eq!(trade.fees.fees, dec!(0.65));
1267 }
1268 _ => panic!("Expected Trade event"),
1269 }
1270 }
1271
1272 #[test]
1273 fn test_fill_to_account_event_sell() {
1274 let fill_json = r#"{
1275 "coin": "ETH",
1276 "side": "A",
1277 "px": "3200",
1278 "sz": "1.5",
1279 "time": 1714100000000,
1280 "hash": "0xdef456",
1281 "startPosition": "1.5",
1282 "dir": "Close Long",
1283 "closedPnl": "150.0",
1284 "oid": 12346,
1285 "cloid": null,
1286 "crossed": true,
1287 "fee": "4.8",
1288 "feeToken": "USDC",
1289 "tid": 100000
1290 }"#;
1291
1292 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1293 let event = fill_to_account_event(&fill).unwrap();
1294
1295 match event.kind {
1296 AccountEventKind::Trade(trade) => {
1297 assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
1298 assert_eq!(trade.side, Side::Sell);
1299 assert_eq!(trade.price, dec!(3200));
1300 assert_eq!(trade.quantity, dec!(1.5));
1301 }
1302 _ => panic!("Expected Trade event"),
1303 }
1304 }
1305
1306 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1308 let json = format!(
1309 r#"{{
1310 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
1311 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1312 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1313 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
1314 }}"#
1315 );
1316 serde_json::from_str(&json).unwrap()
1317 }
1318
1319 #[test]
1320 fn a_trade_id_identifies_the_fill_not_the_transaction() {
1321 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1325 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1326
1327 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1328 (first.kind, second.kind)
1329 else {
1330 panic!("Expected Trade events");
1331 };
1332
1333 assert_eq!(first.order_id, second.order_id, "one order produced both");
1334 assert_ne!(first.id, second.id, "but they are distinct fills");
1335 assert_eq!(first.id.0, "111");
1336 assert_eq!(second.id.0, "222");
1337 }
1338
1339 #[test]
1340 fn a_replayed_fill_is_delivered_once() {
1341 let cache = new_dedup_cache();
1344 let event = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1345 let replay = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1346
1347 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1348 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1349 }
1350
1351 #[test]
1352 fn both_fills_of_one_sweep_survive_dedup() {
1353 let cache = new_dedup_cache();
1356 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1357 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1358
1359 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
1360 assert!(!is_duplicate(
1361 &cache,
1362 dedup_key_from_event(&second).unwrap()
1363 ));
1364 }
1365
1366 #[test]
1367 fn one_tid_on_two_instruments_is_two_fills() {
1368 let cache = new_dedup_cache();
1371 let btc = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1372
1373 let eth_json = r#"{
1374 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
1375 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
1376 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
1377 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
1378 }"#;
1379 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
1380 let eth = fill_to_account_event(ð).unwrap();
1381
1382 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
1383 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
1384 }
1385
1386 #[test]
1387 fn test_order_update_to_account_event_open() {
1388 let update_json = r#"{
1389 "order": {
1390 "coin": "BTC",
1391 "side": "B",
1392 "limitPx": "64000",
1393 "sz": "0.5",
1394 "oid": 12345,
1395 "timestamp": 1714100000000,
1396 "origSz": "0.5",
1397 "cloid": null
1398 },
1399 "status": "open",
1400 "statusTimestamp": 1714100000000
1401 }"#;
1402
1403 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1404 let event = order_update_to_account_event(&update).unwrap();
1405
1406 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1407 match event.kind {
1408 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1409 assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
1410 assert_eq!(order.side, Side::Buy);
1411 assert_eq!(order.price, Some(dec!(64000)));
1412 assert_eq!(order.quantity, dec!(0.5));
1413 assert!(matches!(
1414 order.state,
1415 crate::order::state::OrderState::Active(_)
1416 ));
1417 }
1418 _ => panic!("Expected OrderSnapshot event"),
1419 }
1420 }
1421
1422 #[test]
1423 fn test_order_update_to_account_event_filled() {
1424 let update_json = r#"{
1425 "order": {
1426 "coin": "ETH",
1427 "side": "A",
1428 "limitPx": "3250",
1429 "sz": "0",
1430 "oid": 12346,
1431 "timestamp": 1714100000000,
1432 "origSz": "2.0",
1433 "cloid": null
1434 },
1435 "status": "filled",
1436 "statusTimestamp": 1714100001000
1437 }"#;
1438
1439 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1440 let event = order_update_to_account_event(&update).unwrap();
1441
1442 match event.kind {
1443 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1444 assert_eq!(order.side, Side::Sell);
1445 assert!(matches!(
1446 order.state,
1447 crate::order::state::OrderState::Inactive(
1448 crate::order::state::InactiveOrderState::FullyFilled(_)
1449 )
1450 ));
1451 }
1452 _ => panic!("Expected OrderSnapshot event"),
1453 }
1454 }
1455
1456 #[test]
1457 fn test_order_update_to_account_event_cancelled() {
1458 let update_json = r#"{
1459 "order": {
1460 "coin": "SOL",
1461 "side": "B",
1462 "limitPx": "150",
1463 "sz": "10",
1464 "oid": 12347,
1465 "timestamp": 1714100000000,
1466 "origSz": "10",
1467 "cloid": null
1468 },
1469 "status": "canceled",
1470 "statusTimestamp": 1714100002000
1471 }"#;
1472
1473 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1474 let event = order_update_to_account_event(&update).unwrap();
1475
1476 match event.kind {
1477 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1478 assert_eq!(order.key.instrument.as_ref(), "SOL-USD-PERP");
1479 assert!(matches!(
1480 order.state,
1481 crate::order::state::OrderState::Inactive(
1482 crate::order::state::InactiveOrderState::Cancelled(_)
1483 )
1484 ));
1485 }
1486 _ => panic!("Expected OrderSnapshot event"),
1487 }
1488 }
1489
1490 #[test]
1491 fn test_order_update_unknown_status_returns_none() {
1492 let update_json = r#"{
1493 "order": {
1494 "coin": "BTC",
1495 "side": "B",
1496 "limitPx": "64000",
1497 "sz": "0.5",
1498 "oid": 12345,
1499 "timestamp": 1714100000000,
1500 "origSz": "0.5",
1501 "cloid": null
1502 },
1503 "status": "unknown_status",
1504 "statusTimestamp": 1714100000000
1505 }"#;
1506
1507 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1508 let event = order_update_to_account_event(&update);
1509 assert!(event.is_none());
1510 }
1511}