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,
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(
283 &self,
284 _assets: &[AssetNameExchange],
285 instruments: &[InstrumentNameExchange],
286 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
287 let address = self.wallet_h160();
288
289 let (user_state, open_orders) = tokio::try_join!(
291 async {
292 self.info_client
293 .user_state(address)
294 .await
295 .map_err(map_sdk_error)
296 },
297 open_orders(&self.info_client, address)
298 )?;
299
300 let now = Utc::now();
301
302 let account_value =
304 parse_decimal(&user_state.margin_summary.account_value, "account_value")
305 .unwrap_or(Decimal::ZERO);
306 let margin_used = parse_decimal(
307 &user_state.margin_summary.total_margin_used,
308 "total_margin_used",
309 )
310 .unwrap_or(Decimal::ZERO);
311
312 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
315 let balances = vec![AssetBalance::new(
316 AssetNameExchange::from(USDC_ASSET),
317 Balance::new(account_value, free_balance),
318 now,
319 )];
320
321 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
323 None
324 } else {
325 let mut set = HashSet::with_capacity(instruments.len());
326 set.extend(instruments.iter().cloned());
327 Some(set)
328 };
329
330 let mut listing = OpenOrderListing::new(
331 &open_orders,
332 ExchangeId::HyperliquidPerp,
333 instruments,
334 |coin| Some(perp_coin_to_instrument(coin)),
335 );
336
337 let mut instrument_snapshots = Vec::new();
339 for asset_pos in user_state.asset_positions {
340 let pos = &asset_pos.position;
341 let instrument = perp_coin_to_instrument(&pos.coin);
342
343 if instrument_filter
344 .as_ref()
345 .is_some_and(|f| !f.contains(&instrument))
346 {
347 continue;
348 }
349
350 let quantity = parse_decimal(&pos.szi, "szi").unwrap_or(Decimal::ZERO);
351 let entry_price = pos
352 .entry_px
353 .as_ref()
354 .and_then(|p| parse_decimal(p, "entry_px"));
355 let unrealized_pnl = parse_decimal(&pos.unrealized_pnl, "unrealized_pnl");
356 let margin_used = parse_decimal(&pos.margin_used, "margin_used");
357 let liquidation_price = pos
358 .liquidation_px
359 .as_ref()
360 .and_then(|p| parse_decimal(p, "liquidation_px"));
361 let leverage = Some(Decimal::from(pos.leverage.value));
362
363 let position = if quantity.is_zero() {
364 None
365 } else {
366 Some(Position::new(
367 quantity,
368 entry_price,
369 unrealized_pnl,
370 margin_used,
371 liquidation_price,
372 leverage,
373 now,
374 ))
375 };
376
377 let (orders, orders_complete) = match listing.remove(&instrument) {
378 Some(listed) => (listed.orders, listed.orders_complete),
379 None => (Vec::new(), true),
382 };
383
384 instrument_snapshots.push(InstrumentAccountSnapshot {
385 instrument,
386 orders,
387 orders_complete,
388 position,
389 isolated: None,
390 });
391 }
392
393 instrument_snapshots.extend(listing.into_snapshots());
395
396 Ok(AccountSnapshot {
397 exchange: ExchangeId::HyperliquidPerp,
398 balances,
399 instruments: instrument_snapshots,
400 })
401 }
402
403 async fn account_stream(
418 &self,
419 _assets: &[AssetNameExchange],
420 _instruments: &[InstrumentNameExchange],
421 ) -> Result<Self::AccountStream, UnindexedClientError> {
422 let user = self.wallet_h160();
423 let base_url = self.base_url();
424
425 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
428 .await
429 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
430
431 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
433 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
434
435 ws_client
437 .subscribe(Subscription::UserFills { user }, fills_tx)
438 .await
439 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
440
441 ws_client
443 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
444 .await
445 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
446
447 info!(%user, "Subscribed to Hyperliquid account stream");
448
449 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
451
452 let cancel_token = CancellationToken::new();
454
455 let terminated = Arc::new(AtomicBool::new(false));
460
461 let fills_dedup = new_dedup_cache();
465
466 let fills_event_tx = event_tx.clone();
468 let fills_cancel = cancel_token.clone();
469 let fills_terminated = terminated.clone();
470 tokio::spawn(async move {
471 loop {
472 tokio::select! {
473 biased;
474 () = fills_cancel.cancelled() => {
475 debug!("Fills task cancelled");
476 return;
477 }
478 msg = fills_rx.recv() => {
479 let Some(msg) = msg else {
480 debug!("Fills receiver closed");
481 if fills_terminated
484 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
485 .is_ok()
486 {
487 emit_stream_terminated(
488 &fills_event_tx,
489 ExchangeId::HyperliquidPerp,
490 StreamTerminationReason::Error(
491 "hyperliquid account stream closed".to_string(),
492 ),
493 );
494 }
495 return;
496 };
497 match msg {
498 Message::UserFills(fills) => {
499 for fill in fills.data.fills {
509 let Some(event) = fill_to_account_event(&fill) else {
510 continue;
511 };
512 if let Some(key) = dedup_key_from_event(&event)
513 && is_duplicate(&fills_dedup, key)
514 {
515 trace!(
516 tid = fill.tid,
517 "Hyperliquid dedup: skipping fill already delivered"
518 );
519 continue;
520 }
521 if fills_event_tx.send(event).is_err() {
522 debug!("Fills event channel closed");
523 return;
524 }
525 }
526 }
527 Message::NoData => {
528 warn!("UserFills WebSocket disconnected");
529 }
530 Message::HyperliquidError(e) => {
531 error!(%e, "UserFills WebSocket error");
534 }
535 _ => {}
536 }
537 }
538 }
539 }
540 });
541
542 let orders_event_tx = event_tx;
547 let orders_cancel = cancel_token.clone();
548 let orders_terminated = terminated;
549 tokio::spawn(async move {
550 let _ws_client = ws_client;
551
552 loop {
553 tokio::select! {
554 biased;
555 () = orders_cancel.cancelled() => {
556 debug!("Orders task cancelled");
557 return;
558 }
559 msg = orders_rx.recv() => {
560 let Some(msg) = msg else {
561 debug!("Orders receiver closed");
562 if orders_terminated
565 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
566 .is_ok()
567 {
568 emit_stream_terminated(
569 &orders_event_tx,
570 ExchangeId::HyperliquidPerp,
571 StreamTerminationReason::Error(
572 "hyperliquid account stream closed".to_string(),
573 ),
574 );
575 }
576 return;
577 };
578 match msg {
579 Message::OrderUpdates(updates) => {
580 for update in updates.data {
581 if let Some(event) = order_update_to_account_event(&update)
582 && orders_event_tx.send(event).is_err()
583 {
584 debug!("Orders event channel closed");
585 return;
586 }
587 }
588 }
589 Message::NoData => {
590 warn!("OrderUpdates WebSocket disconnected");
591 }
592 Message::HyperliquidError(e) => {
593 error!(%e, "OrderUpdates WebSocket error");
596 }
597 _ => {}
598 }
599 }
600 }
601 }
602 });
603
604 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
606 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
607 Ok(guarded_stream.boxed())
608 }
609
610 async fn cancel_order(
611 &self,
612 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
613 ) -> Option<UnindexedOrderResponseCancel> {
614 use crate::order::{request::OrderResponseCancel, state::Cancelled};
615 use hyperliquid_rust_sdk::{
616 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
617 };
618 use uuid::Uuid;
619
620 let coin = instrument_to_perp_coin(request.key.instrument);
621
622 enum CancelMethod {
623 ByOid(u64),
624 ByCloid(Uuid),
625 }
626
627 let cancel_method = match &request.state.id {
632 Some(VenueOrderId::Assigned(id)) => {
633 id.0.parse::<u64>()
634 .map(CancelMethod::ByOid)
635 .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
636 }
637 Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
641 .map(CancelMethod::ByCloid)
642 .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
643 None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
644 };
645
646 let cancel_method = match cancel_method {
647 Ok(method) => method,
648 Err(reason) => {
649 warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
650 return Some(OrderResponseCancel {
651 key: OrderKey {
652 exchange: ExchangeId::HyperliquidPerp,
653 instrument: request.key.instrument.clone(),
654 strategy: request.key.strategy.clone(),
655 cid: request.key.cid.clone(),
656 },
657 state: Err(UnindexedOrderError::Rejected(
658 crate::error::ApiError::OrderRejected(reason.to_string()),
659 )),
660 });
661 }
662 };
663
664 let cancelled_id = match &request.state.id {
667 Some(venue_id) => venue_id.or_client_id(&request.key.cid),
668 None => OrderId(request.key.cid.0.clone()),
669 };
670
671 let response = match cancel_method {
672 CancelMethod::ByOid(oid) => {
673 debug!(oid, "Cancelling by OID");
674 let cancel_request = ClientCancelRequest { asset: coin, oid };
675 self.exchange_client.cancel(cancel_request, None).await
676 }
677 CancelMethod::ByCloid(cloid) => {
678 debug!(%cloid, "Cancelling by cloid (no oid assigned)");
679 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
680 self.exchange_client
681 .cancel_by_cloid(cancel_request, None)
682 .await
683 }
684 };
685
686 let response = match response {
687 Ok(r) => r,
688 Err(e) => {
689 warn!(%e, "Cancel order failed (transport)");
690 return Some(OrderResponseCancel {
691 key: OrderKey {
692 exchange: ExchangeId::HyperliquidPerp,
693 instrument: request.key.instrument.clone(),
694 strategy: request.key.strategy.clone(),
695 cid: request.key.cid.clone(),
696 },
697 state: Err(map_order_error(e, request.key.instrument)),
698 });
699 }
700 };
701
702 match response {
703 ExchangeResponseStatus::Ok(_) => {
704 debug!("Cancel order accepted");
705 Some(OrderResponseCancel {
707 key: OrderKey {
708 exchange: ExchangeId::HyperliquidPerp,
709 instrument: request.key.instrument.clone(),
710 strategy: request.key.strategy.clone(),
711 cid: request.key.cid.clone(),
712 },
713 state: Ok(Cancelled::new(
714 cancelled_id.clone(),
715 Utc::now(),
716 Decimal::ZERO, )),
718 })
719 }
720 ExchangeResponseStatus::Err(msg) => {
721 warn!(%msg, "Cancel rejected by exchange");
722 Some(OrderResponseCancel {
723 key: OrderKey {
724 exchange: ExchangeId::HyperliquidPerp,
725 instrument: request.key.instrument.clone(),
726 strategy: request.key.strategy.clone(),
727 cid: request.key.cid.clone(),
728 },
729 state: Err(UnindexedOrderError::Rejected(
730 crate::error::ApiError::OrderRejected(msg),
731 )),
732 })
733 }
734 }
735 }
736
737 async fn open_order(
738 &self,
739 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
740 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
741 use hyperliquid_rust_sdk::{
742 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
743 ExchangeResponseStatus,
744 };
745
746 let make_rejected =
747 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
748 Order {
749 key: OrderKey {
750 exchange: ExchangeId::HyperliquidPerp,
751 instrument: request.key.instrument.clone(),
752 strategy: request.key.strategy.clone(),
753 cid: request.key.cid.clone(),
754 },
755 side: request.state.side,
756 price: request.state.price,
757 quantity: request.state.quantity,
758 kind: request.state.kind,
759 time_in_force: request.state.time_in_force,
760 state: OrderState::inactive(OrderError::Rejected(
761 crate::error::ApiError::OrderRejected(msg),
762 )),
763 }
764 };
765
766 let make_unsupported =
767 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
768 Order {
769 key: OrderKey {
770 exchange: ExchangeId::HyperliquidPerp,
771 instrument: request.key.instrument.clone(),
772 strategy: request.key.strategy.clone(),
773 cid: request.key.cid.clone(),
774 },
775 side: request.state.side,
776 price: request.state.price,
777 quantity: request.state.quantity,
778 kind: request.state.kind,
779 time_in_force: request.state.time_in_force,
780 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
781 }
782 };
783
784 let coin = instrument_to_perp_coin(request.key.instrument);
785 let is_buy = request.state.side == Side::Buy;
786
787 match request.state.kind {
788 OrderKind::Market => {
789 return Some(make_unsupported(
790 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
791 .to_string(),
792 ));
793 }
794 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
795 return Some(make_unsupported(
796 "Hyperliquid does not support trailing stop orders".to_string(),
797 ));
798 }
799 OrderKind::Limit
800 | OrderKind::Stop { .. }
801 | OrderKind::StopLimit { .. }
802 | OrderKind::TakeProfit { .. }
803 | OrderKind::TakeProfitLimit { .. } => {}
804 }
805
806 let Some(cloid) = cid_to_cloid(&request.key.cid) else {
809 return Some(make_rejected(CLOID_REQUIRED.to_string()));
810 };
811
812 let limit_px = match request.state.kind {
813 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
815 round_to_5_sig_figs(trigger_price)
816 }
817 _ => match request.state.price {
818 Some(p) => round_to_5_sig_figs(p),
819 None => {
820 return Some(make_rejected(
821 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
822 .to_string(),
823 ));
824 }
825 },
826 };
827
828 let sz = round_to_5_sig_figs(request.state.quantity);
829
830 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
832 warn!(
833 instrument = %request.key.instrument,
834 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
835 );
836 }
837 let tif = map_tif(&request.state.time_in_force).to_string();
838
839 let order_type = match request.state.kind {
841 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
842 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
843 is_market: true,
844 trigger_px: round_to_5_sig_figs(trigger_price),
845 tpsl: "sl".to_string(),
846 }),
847 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
848 is_market: false,
849 trigger_px: round_to_5_sig_figs(trigger_price),
850 tpsl: "sl".to_string(),
851 }),
852 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
853 is_market: true,
854 trigger_px: round_to_5_sig_figs(trigger_price),
855 tpsl: "tp".to_string(),
856 }),
857 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
858 is_market: false,
859 trigger_px: round_to_5_sig_figs(trigger_price),
860 tpsl: "tp".to_string(),
861 }),
862 OrderKind::Market
864 | OrderKind::TrailingStop { .. }
865 | OrderKind::TrailingStopLimit { .. } => {
866 unreachable!("unsupported order kinds rejected earlier")
867 }
868 };
869
870 let order_request = ClientOrderRequest {
871 asset: coin,
872 is_buy,
873 reduce_only: request.state.reduce_only,
874 limit_px,
875 sz,
876 cloid: Some(cloid),
877 order_type,
878 };
879
880 let response = match self.exchange_client.order(order_request, None).await {
881 Ok(r) => r,
882 Err(e) => {
883 warn!(%e, "Open order failed");
884 return Some(Order {
885 key: OrderKey {
886 exchange: ExchangeId::HyperliquidPerp,
887 instrument: request.key.instrument.clone(),
888 strategy: request.key.strategy.clone(),
889 cid: request.key.cid.clone(),
890 },
891 side: request.state.side,
892 price: request.state.price,
893 quantity: request.state.quantity,
894 kind: request.state.kind,
895 time_in_force: request.state.time_in_force,
896 state: OrderState::inactive(map_order_error(e, request.key.instrument)),
897 });
898 }
899 };
900
901 let state = match response {
903 ExchangeResponseStatus::Ok(exchange_resp) => {
904 let status = exchange_resp
906 .data
907 .and_then(|d| d.statuses.into_iter().next());
908
909 match status {
910 Some(ExchangeDataStatus::Resting(resting)) => {
911 debug!(oid = resting.oid, "Order resting");
912 OrderState::active(Open {
913 id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
914 time_exchange: Utc::now(),
915 filled_quantity: Decimal::ZERO,
916 })
917 }
918 Some(ExchangeDataStatus::Filled(filled)) => {
919 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
920 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
922 OrderState::fully_filled(Filled::new(
923 OrderId(format_smolstr!("{}", filled.oid)),
924 Utc::now(),
925 parse_decimal(&filled.total_sz, "total_sz")
926 .unwrap_or(request.state.quantity),
927 avg_price,
928 ))
929 }
930 Some(ExchangeDataStatus::Error(msg)) => {
931 warn!(%msg, "Order rejected by exchange");
932 OrderState::inactive(OrderError::Rejected(
933 crate::error::ApiError::OrderRejected(msg),
934 ))
935 }
936 Some(
937 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
938 ) => {
939 debug!(cid = %request.key.cid, "Order waiting under its cloid");
943 OrderState::active(Open {
944 id: VenueOrderId::ClientAssigned,
945 time_exchange: Utc::now(),
946 filled_quantity: Decimal::ZERO,
947 })
948 }
949 Some(ExchangeDataStatus::Success) | None => {
950 warn!("Order accepted but no order ID returned");
953 OrderState::inactive(OrderError::Rejected(
954 crate::error::ApiError::OrderRejected(
955 "no order ID in response".to_string(),
956 ),
957 ))
958 }
959 }
960 }
961 ExchangeResponseStatus::Err(msg) => {
962 warn!(%msg, "Order rejected");
963 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
964 msg,
965 )))
966 }
967 };
968
969 Some(Order {
970 key: OrderKey {
971 exchange: ExchangeId::HyperliquidPerp,
972 instrument: request.key.instrument.clone(),
973 strategy: request.key.strategy.clone(),
974 cid: request.key.cid.clone(),
975 },
976 side: request.state.side,
977 price: request.state.price,
978 quantity: request.state.quantity,
979 kind: request.state.kind,
980 time_in_force: request.state.time_in_force,
981 state,
982 })
983 }
984
985 async fn fetch_balances(
986 &self,
987 _assets: &[AssetNameExchange],
988 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
989 let address = self.wallet_h160();
990
991 let user_state = self
992 .info_client
993 .user_state(address)
994 .await
995 .map_err(map_sdk_error)?;
996
997 let now = Utc::now();
998
999 let account_value =
1001 parse_decimal(&user_state.margin_summary.account_value, "account_value")
1002 .unwrap_or(Decimal::ZERO);
1003 let margin_used = parse_decimal(
1004 &user_state.margin_summary.total_margin_used,
1005 "total_margin_used",
1006 )
1007 .unwrap_or(Decimal::ZERO);
1008
1009 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
1011 Ok(vec![AssetBalance::new(
1012 AssetNameExchange::from(USDC_ASSET),
1013 Balance::new(account_value, free_balance),
1014 now,
1015 )])
1016 }
1017
1018 async fn fetch_open_orders(
1019 &self,
1020 instruments: &[InstrumentNameExchange],
1021 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1022 let address = self.wallet_h160();
1023
1024 let open_orders = open_orders(&self.info_client, address).await?;
1025
1026 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1027 None
1028 } else {
1029 let mut set = HashSet::with_capacity(instruments.len());
1030 set.extend(instruments.iter().cloned());
1031 Some(set)
1032 };
1033
1034 Ok(open_orders
1035 .iter()
1036 .filter_map(|order| {
1037 let instrument = perp_coin_to_instrument(&order.coin);
1038 if instrument_filter
1039 .as_ref()
1040 .is_some_and(|f| !f.contains(&instrument))
1041 {
1042 return None;
1043 }
1044 open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1045 })
1046 .collect())
1047 }
1048
1049 async fn fetch_trades(
1050 &self,
1051 time_since: DateTime<Utc>,
1052 instruments: &[InstrumentNameExchange],
1053 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1054 let address = self.wallet_h160();
1055
1056 let fills = user_fills(&self.info_client, address).await?;
1057
1058 #[allow(clippy::cast_sign_loss)] let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1061
1062 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1063 None
1064 } else {
1065 let mut set = HashSet::with_capacity(instruments.len());
1066 set.extend(instruments.iter().cloned());
1067 Some(set)
1068 };
1069
1070 let mut result = Vec::new();
1071 for fill in fills {
1072 if fill.time < time_since_ms {
1074 continue;
1075 }
1076
1077 let instrument = perp_coin_to_instrument(&fill.coin);
1078
1079 if instrument_filter
1080 .as_ref()
1081 .is_some_and(|f| !f.contains(&instrument))
1082 {
1083 continue;
1084 }
1085
1086 let Some(side) = parse_side(&fill.side) else {
1087 continue;
1088 };
1089 let Some(price) = parse_decimal(&fill.px, "px") else {
1090 continue;
1091 };
1092 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1093 continue;
1094 };
1095 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1096
1097 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1098 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1099 continue;
1100 };
1101
1102 result.push(Trade {
1103 id: TradeId(format_smolstr!("{}", fill.tid)),
1104 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1105 instrument,
1106 strategy: StrategyId::unknown(),
1107 time_exchange,
1108 side,
1109 price,
1110 quantity,
1111 order_filled_quantity: None,
1114 fees: AssetFees {
1115 asset: AssetNameExchange::from("USDC"),
1116 fees: fee,
1117 fees_quote: Some(fee),
1118 },
1119 });
1120 }
1121
1122 Ok(result)
1123 }
1124}
1125
1126fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1128 let side = parse_side(&fill.side)?;
1129 let price = parse_decimal(&fill.px, "fill.px")?;
1130 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1131 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1132 let time_exchange = millis_to_datetime(fill.time)?;
1133 let instrument = perp_coin_to_instrument(&fill.coin);
1134 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1135
1136 let trade = Trade {
1137 id: TradeId(format_smolstr!("{}", fill.tid)),
1138 order_id,
1139 instrument,
1140 strategy: StrategyId::unknown(),
1141 time_exchange,
1142 side,
1143 price,
1144 quantity,
1145 order_filled_quantity: None,
1148 fees: AssetFees {
1149 asset: AssetNameExchange::from("USDC"),
1150 fees: fee,
1151 fees_quote: Some(fee),
1152 },
1153 };
1154
1155 Some(AccountEvent::new(
1156 ExchangeId::HyperliquidPerp,
1157 AccountEventKind::Trade(trade),
1158 ))
1159}
1160
1161fn order_update_to_account_event(
1163 update: &hyperliquid_rust_sdk::OrderUpdate,
1164) -> Option<UnindexedAccountEvent> {
1165 common::order_update_to_account_event(
1166 update,
1167 ExchangeId::HyperliquidPerp,
1168 perp_coin_to_instrument(&update.order.coin),
1169 )
1170}
1171
1172#[cfg(test)]
1173#[allow(clippy::unwrap_used)]
1175mod tests {
1176 use super::*;
1177 use rust_decimal_macros::dec;
1178 use rustrade_integration::collection::snapshot::Snapshot;
1179
1180 #[test]
1181 fn test_fill_to_account_event() {
1182 let fill_json = r#"{
1183 "coin": "BTC",
1184 "side": "B",
1185 "px": "65000.5",
1186 "sz": "0.1",
1187 "time": 1714100000000,
1188 "hash": "0xabc123",
1189 "startPosition": "0",
1190 "dir": "Open Long",
1191 "closedPnl": "0",
1192 "oid": 12345,
1193 "cloid": null,
1194 "crossed": false,
1195 "fee": "0.65",
1196 "feeToken": "USDC",
1197 "tid": 99999
1198 }"#;
1199
1200 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1201 let event = fill_to_account_event(&fill).unwrap();
1202
1203 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1204 match event.kind {
1205 AccountEventKind::Trade(trade) => {
1206 assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
1207 assert_eq!(
1208 trade.id.0, "99999",
1209 "the id is the fill's tid, not its hash"
1210 );
1211 assert_eq!(trade.side, Side::Buy);
1212 assert_eq!(trade.price, dec!(65000.5));
1213 assert_eq!(trade.quantity, dec!(0.1));
1214 assert_eq!(trade.fees.fees, dec!(0.65));
1215 }
1216 _ => panic!("Expected Trade event"),
1217 }
1218 }
1219
1220 #[test]
1221 fn test_fill_to_account_event_sell() {
1222 let fill_json = r#"{
1223 "coin": "ETH",
1224 "side": "A",
1225 "px": "3200",
1226 "sz": "1.5",
1227 "time": 1714100000000,
1228 "hash": "0xdef456",
1229 "startPosition": "1.5",
1230 "dir": "Close Long",
1231 "closedPnl": "150.0",
1232 "oid": 12346,
1233 "cloid": null,
1234 "crossed": true,
1235 "fee": "4.8",
1236 "feeToken": "USDC",
1237 "tid": 100000
1238 }"#;
1239
1240 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1241 let event = fill_to_account_event(&fill).unwrap();
1242
1243 match event.kind {
1244 AccountEventKind::Trade(trade) => {
1245 assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
1246 assert_eq!(trade.side, Side::Sell);
1247 assert_eq!(trade.price, dec!(3200));
1248 assert_eq!(trade.quantity, dec!(1.5));
1249 }
1250 _ => panic!("Expected Trade event"),
1251 }
1252 }
1253
1254 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1256 let json = format!(
1257 r#"{{
1258 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
1259 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1260 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1261 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
1262 }}"#
1263 );
1264 serde_json::from_str(&json).unwrap()
1265 }
1266
1267 #[test]
1268 fn a_trade_id_identifies_the_fill_not_the_transaction() {
1269 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1273 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1274
1275 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1276 (first.kind, second.kind)
1277 else {
1278 panic!("Expected Trade events");
1279 };
1280
1281 assert_eq!(first.order_id, second.order_id, "one order produced both");
1282 assert_ne!(first.id, second.id, "but they are distinct fills");
1283 assert_eq!(first.id.0, "111");
1284 assert_eq!(second.id.0, "222");
1285 }
1286
1287 #[test]
1288 fn a_replayed_fill_is_delivered_once() {
1289 let cache = new_dedup_cache();
1292 let event = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1293 let replay = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1294
1295 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1296 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1297 }
1298
1299 #[test]
1300 fn both_fills_of_one_sweep_survive_dedup() {
1301 let cache = new_dedup_cache();
1304 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1305 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1306
1307 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
1308 assert!(!is_duplicate(
1309 &cache,
1310 dedup_key_from_event(&second).unwrap()
1311 ));
1312 }
1313
1314 #[test]
1315 fn one_tid_on_two_instruments_is_two_fills() {
1316 let cache = new_dedup_cache();
1319 let btc = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1320
1321 let eth_json = r#"{
1322 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
1323 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
1324 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
1325 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
1326 }"#;
1327 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
1328 let eth = fill_to_account_event(ð).unwrap();
1329
1330 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
1331 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
1332 }
1333
1334 #[test]
1335 fn test_order_update_to_account_event_open() {
1336 let update_json = r#"{
1337 "order": {
1338 "coin": "BTC",
1339 "side": "B",
1340 "limitPx": "64000",
1341 "sz": "0.5",
1342 "oid": 12345,
1343 "timestamp": 1714100000000,
1344 "origSz": "0.5",
1345 "cloid": null
1346 },
1347 "status": "open",
1348 "statusTimestamp": 1714100000000
1349 }"#;
1350
1351 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1352 let event = order_update_to_account_event(&update).unwrap();
1353
1354 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1355 match event.kind {
1356 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1357 assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
1358 assert_eq!(order.side, Side::Buy);
1359 assert_eq!(order.price, Some(dec!(64000)));
1360 assert_eq!(order.quantity, dec!(0.5));
1361 assert!(matches!(
1362 order.state,
1363 crate::order::state::OrderState::Active(_)
1364 ));
1365 }
1366 _ => panic!("Expected OrderSnapshot event"),
1367 }
1368 }
1369
1370 #[test]
1371 fn test_order_update_to_account_event_filled() {
1372 let update_json = r#"{
1373 "order": {
1374 "coin": "ETH",
1375 "side": "A",
1376 "limitPx": "3250",
1377 "sz": "0",
1378 "oid": 12346,
1379 "timestamp": 1714100000000,
1380 "origSz": "2.0",
1381 "cloid": null
1382 },
1383 "status": "filled",
1384 "statusTimestamp": 1714100001000
1385 }"#;
1386
1387 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1388 let event = order_update_to_account_event(&update).unwrap();
1389
1390 match event.kind {
1391 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1392 assert_eq!(order.side, Side::Sell);
1393 assert!(matches!(
1394 order.state,
1395 crate::order::state::OrderState::Inactive(
1396 crate::order::state::InactiveOrderState::FullyFilled(_)
1397 )
1398 ));
1399 }
1400 _ => panic!("Expected OrderSnapshot event"),
1401 }
1402 }
1403
1404 #[test]
1405 fn test_order_update_to_account_event_cancelled() {
1406 let update_json = r#"{
1407 "order": {
1408 "coin": "SOL",
1409 "side": "B",
1410 "limitPx": "150",
1411 "sz": "10",
1412 "oid": 12347,
1413 "timestamp": 1714100000000,
1414 "origSz": "10",
1415 "cloid": null
1416 },
1417 "status": "canceled",
1418 "statusTimestamp": 1714100002000
1419 }"#;
1420
1421 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1422 let event = order_update_to_account_event(&update).unwrap();
1423
1424 match event.kind {
1425 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1426 assert_eq!(order.key.instrument.as_ref(), "SOL-USD-PERP");
1427 assert!(matches!(
1428 order.state,
1429 crate::order::state::OrderState::Inactive(
1430 crate::order::state::InactiveOrderState::Cancelled(_)
1431 )
1432 ));
1433 }
1434 _ => panic!("Expected OrderSnapshot event"),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_order_update_unknown_status_returns_none() {
1440 let update_json = r#"{
1441 "order": {
1442 "coin": "BTC",
1443 "side": "B",
1444 "limitPx": "64000",
1445 "sz": "0.5",
1446 "oid": 12345,
1447 "timestamp": 1714100000000,
1448 "origSz": "0.5",
1449 "cloid": null
1450 },
1451 "status": "unknown_status",
1452 "statusTimestamp": 1714100000000
1453 }"#;
1454
1455 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1456 let event = order_update_to_account_event(&update);
1457 assert!(event.is_none());
1458 }
1459}