1pub mod common;
89pub mod config;
90pub mod error;
91pub mod spot;
92
93use crate::client::dedup::{dedup_key_from_event, is_duplicate, new_dedup_cache};
94use crate::{
95 AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
96 UnindexedAccountEvent, UnindexedAccountSnapshot,
97 balance::{AssetBalance, Balance},
98 client::ExecutionClient,
99 emit_stream_terminated,
100 error::{
101 ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
102 UnindexedOrderError,
103 },
104 order::{
105 Order, OrderKey, OrderKind, TimeInForce,
106 id::{ClientOrderId, OrderId, StrategyId},
107 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
108 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
109 },
110 position::Position,
111 trade::{AssetFees, Trade, TradeId},
112};
113use chrono::{DateTime, Utc};
114use common::{
115 CancelOnDropStream, cid_to_cloid, instrument_to_perp_coin, map_tif, millis_to_datetime,
116 parse_decimal, parse_side, perp_coin_to_instrument, round_to_5_sig_figs, user_fills,
117};
118pub use config::{HyperliquidConfig, HyperliquidConfigError};
119use error::{map_order_error, map_sdk_error};
120use ethers::signers::Signer;
121use futures::{StreamExt, stream::BoxStream};
122use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
123use rust_decimal::Decimal;
124use rustrade_instrument::{
125 Side,
126 asset::name::AssetNameExchange,
127 exchange::ExchangeId,
128 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
129};
130use rustrade_integration::collection::snapshot::Snapshot;
131use smol_str::{SmolStr, format_smolstr};
132use std::{
133 collections::HashSet,
134 sync::{
135 Arc,
136 atomic::{AtomicBool, Ordering},
137 },
138};
139use tokio::sync::mpsc;
140use tokio_util::sync::CancellationToken;
141use tracing::{debug, error, info, trace, warn};
142
143const USDC_ASSET: &str = "USDC";
145
146#[derive(Debug, Clone)]
151pub struct HyperliquidClient {
152 config: HyperliquidConfig,
153 info_client: Arc<InfoClient>,
154 exchange_client: Arc<ExchangeClient>,
155}
156
157impl HyperliquidClient {
158 pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
163 let base_url = if config.testnet {
164 BaseUrl::Testnet
165 } else {
166 BaseUrl::Mainnet
167 };
168
169 let info_client = InfoClient::new(None, Some(base_url))
170 .await
171 .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
172
173 let wallet = config.wallet.clone();
174 let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
175 .await
176 .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
177
178 info!(
179 testnet = config.testnet,
180 wallet = %config.wallet_address_hex(),
181 "Created HyperliquidClient"
182 );
183
184 Ok(Self {
185 config,
186 info_client: Arc::new(info_client),
187 exchange_client: Arc::new(exchange_client),
188 })
189 }
190
191 fn base_url(&self) -> BaseUrl {
193 if self.config.testnet {
194 BaseUrl::Testnet
195 } else {
196 BaseUrl::Mainnet
197 }
198 }
199
200 pub fn wallet_address(&self) -> String {
202 self.config.wallet_address_hex()
203 }
204
205 fn wallet_h160(&self) -> ethers::types::H160 {
207 self.config.wallet.address()
208 }
209}
210
211impl ExecutionClient for HyperliquidClient {
212 const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
213
214 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
217 &[InstrumentKindDiscriminant::Perpetual];
218
219 type Config = HyperliquidConfig;
220 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
221
222 fn new(config: Self::Config) -> Self {
235 let base_url = if config.testnet {
236 BaseUrl::Testnet
237 } else {
238 BaseUrl::Mainnet
239 };
240
241 let handle = tokio::runtime::Handle::current();
244
245 let info_client = handle.block_on(async {
246 InfoClient::new(None, Some(base_url))
247 .await
248 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
249 });
250
251 let wallet = config.wallet.clone();
252 let exchange_client = handle.block_on(async {
253 ExchangeClient::new(None, wallet, Some(base_url), None, None)
254 .await
255 .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
256 });
257
258 info!(
259 testnet = config.testnet,
260 wallet = %config.wallet_address_hex(),
261 "Created HyperliquidClient"
262 );
263
264 Self {
265 config,
266 info_client: Arc::new(info_client),
267 exchange_client: Arc::new(exchange_client),
268 }
269 }
270
271 async fn account_snapshot(
272 &self,
273 _assets: &[AssetNameExchange],
274 instruments: &[InstrumentNameExchange],
275 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
276 let address = self.wallet_h160();
277
278 let (user_state, open_orders) = tokio::try_join!(
280 async {
281 self.info_client
282 .user_state(address)
283 .await
284 .map_err(map_sdk_error)
285 },
286 async {
287 self.info_client
288 .open_orders(address)
289 .await
290 .map_err(map_sdk_error)
291 }
292 )?;
293
294 let now = Utc::now();
295
296 let account_value =
298 parse_decimal(&user_state.margin_summary.account_value, "account_value")
299 .unwrap_or(Decimal::ZERO);
300 let margin_used = parse_decimal(
301 &user_state.margin_summary.total_margin_used,
302 "total_margin_used",
303 )
304 .unwrap_or(Decimal::ZERO);
305
306 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
309 let balances = vec![AssetBalance::new(
310 AssetNameExchange::from(USDC_ASSET),
311 Balance::new(account_value, free_balance),
312 now,
313 )];
314
315 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
317 None
318 } else {
319 let mut set = HashSet::with_capacity(instruments.len());
320 set.extend(instruments.iter().cloned());
321 Some(set)
322 };
323
324 let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
326 std::collections::HashMap::with_capacity(open_orders.len());
327
328 for order in &open_orders {
329 let instrument = perp_coin_to_instrument(&order.coin);
330 if instrument_filter
331 .as_ref()
332 .is_some_and(|f| !f.contains(&instrument))
333 {
334 continue;
335 }
336
337 let Some(side) = parse_side(&order.side) else {
338 continue;
339 };
340 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
341 continue;
342 };
343 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
344 continue;
345 };
346 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
347 warn!(
348 oid = order.oid,
349 timestamp = order.timestamp,
350 "Invalid order timestamp, skipping"
351 );
352 continue;
353 };
354
355 let order_id = format_smolstr!("{}", order.oid);
356 let order_snapshot = Order {
357 key: OrderKey {
358 exchange: ExchangeId::HyperliquidPerp,
359 instrument: instrument.clone(),
360 strategy: StrategyId::unknown(),
361 cid: ClientOrderId::new(order_id.clone()),
362 },
363 side,
364 price: Some(price),
365 quantity,
366 kind: OrderKind::Limit,
367 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
368 state: crate::order::state::OrderState::active(Open {
369 id: OrderId(order_id),
370 time_exchange,
371 filled_quantity: Decimal::ZERO,
372 }),
373 };
374
375 orders_by_instrument
376 .entry(instrument)
377 .or_default()
378 .push(order_snapshot);
379 }
380
381 let mut instrument_snapshots = Vec::new();
383 for asset_pos in user_state.asset_positions {
384 let pos = &asset_pos.position;
385 let instrument = perp_coin_to_instrument(&pos.coin);
386
387 if instrument_filter
388 .as_ref()
389 .is_some_and(|f| !f.contains(&instrument))
390 {
391 continue;
392 }
393
394 let quantity = parse_decimal(&pos.szi, "szi").unwrap_or(Decimal::ZERO);
395 let entry_price = pos
396 .entry_px
397 .as_ref()
398 .and_then(|p| parse_decimal(p, "entry_px"));
399 let unrealized_pnl = parse_decimal(&pos.unrealized_pnl, "unrealized_pnl");
400 let margin_used = parse_decimal(&pos.margin_used, "margin_used");
401 let liquidation_price = pos
402 .liquidation_px
403 .as_ref()
404 .and_then(|p| parse_decimal(p, "liquidation_px"));
405 let leverage = Some(Decimal::from(pos.leverage.value));
406
407 let position = if quantity.is_zero() {
408 None
409 } else {
410 Some(Position::new(
411 quantity,
412 entry_price,
413 unrealized_pnl,
414 margin_used,
415 liquidation_price,
416 leverage,
417 now,
418 ))
419 };
420
421 let orders = orders_by_instrument.remove(&instrument).unwrap_or_default();
422
423 instrument_snapshots.push(InstrumentAccountSnapshot {
424 instrument,
425 orders,
426 position,
427 isolated: None,
428 });
429 }
430
431 for (instrument, orders) in orders_by_instrument {
433 instrument_snapshots.push(InstrumentAccountSnapshot {
434 instrument,
435 orders,
436 position: None,
437 isolated: None,
438 });
439 }
440
441 Ok(AccountSnapshot {
442 exchange: ExchangeId::HyperliquidPerp,
443 balances,
444 instruments: instrument_snapshots,
445 })
446 }
447
448 async fn account_stream(
463 &self,
464 _assets: &[AssetNameExchange],
465 _instruments: &[InstrumentNameExchange],
466 ) -> Result<Self::AccountStream, UnindexedClientError> {
467 let user = self.wallet_h160();
468 let base_url = self.base_url();
469
470 let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
473 .await
474 .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
475
476 let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
478 let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
479
480 ws_client
482 .subscribe(Subscription::UserFills { user }, fills_tx)
483 .await
484 .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
485
486 ws_client
488 .subscribe(Subscription::OrderUpdates { user }, orders_tx)
489 .await
490 .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
491
492 info!(%user, "Subscribed to Hyperliquid account stream");
493
494 let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
496
497 let cancel_token = CancellationToken::new();
499
500 let terminated = Arc::new(AtomicBool::new(false));
505
506 let fills_dedup = new_dedup_cache();
510
511 let fills_event_tx = event_tx.clone();
513 let fills_cancel = cancel_token.clone();
514 let fills_terminated = terminated.clone();
515 tokio::spawn(async move {
516 loop {
517 tokio::select! {
518 biased;
519 () = fills_cancel.cancelled() => {
520 debug!("Fills task cancelled");
521 return;
522 }
523 msg = fills_rx.recv() => {
524 let Some(msg) = msg else {
525 debug!("Fills receiver closed");
526 if fills_terminated
529 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
530 .is_ok()
531 {
532 emit_stream_terminated(
533 &fills_event_tx,
534 ExchangeId::HyperliquidPerp,
535 StreamTerminationReason::Error(
536 "hyperliquid account stream closed".to_string(),
537 ),
538 );
539 }
540 return;
541 };
542 match msg {
543 Message::UserFills(fills) => {
544 for fill in fills.data.fills {
554 let Some(event) = fill_to_account_event(&fill) else {
555 continue;
556 };
557 if let Some(key) = dedup_key_from_event(&event)
558 && is_duplicate(&fills_dedup, key)
559 {
560 trace!(
561 tid = fill.tid,
562 "Hyperliquid dedup: skipping fill already delivered"
563 );
564 continue;
565 }
566 if fills_event_tx.send(event).is_err() {
567 debug!("Fills event channel closed");
568 return;
569 }
570 }
571 }
572 Message::NoData => {
573 warn!("UserFills WebSocket disconnected");
574 }
575 Message::HyperliquidError(e) => {
576 error!(%e, "UserFills WebSocket error");
579 }
580 _ => {}
581 }
582 }
583 }
584 }
585 });
586
587 let orders_event_tx = event_tx;
592 let orders_cancel = cancel_token.clone();
593 let orders_terminated = terminated;
594 tokio::spawn(async move {
595 let _ws_client = ws_client;
596
597 loop {
598 tokio::select! {
599 biased;
600 () = orders_cancel.cancelled() => {
601 debug!("Orders task cancelled");
602 return;
603 }
604 msg = orders_rx.recv() => {
605 let Some(msg) = msg else {
606 debug!("Orders receiver closed");
607 if orders_terminated
610 .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
611 .is_ok()
612 {
613 emit_stream_terminated(
614 &orders_event_tx,
615 ExchangeId::HyperliquidPerp,
616 StreamTerminationReason::Error(
617 "hyperliquid account stream closed".to_string(),
618 ),
619 );
620 }
621 return;
622 };
623 match msg {
624 Message::OrderUpdates(updates) => {
625 for update in updates.data {
626 if let Some(event) = order_update_to_account_event(&update)
627 && orders_event_tx.send(event).is_err()
628 {
629 debug!("Orders event channel closed");
630 return;
631 }
632 }
633 }
634 Message::NoData => {
635 warn!("OrderUpdates WebSocket disconnected");
636 }
637 Message::HyperliquidError(e) => {
638 error!(%e, "OrderUpdates WebSocket error");
641 }
642 _ => {}
643 }
644 }
645 }
646 }
647 });
648
649 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
651 let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
652 Ok(guarded_stream.boxed())
653 }
654
655 async fn cancel_order(
656 &self,
657 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
658 ) -> Option<UnindexedOrderResponseCancel> {
659 use crate::order::{request::OrderResponseCancel, state::Cancelled};
660 use hyperliquid_rust_sdk::{
661 ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
662 };
663 use uuid::Uuid;
664
665 let coin = instrument_to_perp_coin(request.key.instrument);
666
667 let order_id = match &request.state.id {
669 Some(id) => id,
670 None => {
671 warn!("Cancel request missing order ID");
672 return Some(OrderResponseCancel {
673 key: OrderKey {
674 exchange: ExchangeId::HyperliquidPerp,
675 instrument: request.key.instrument.clone(),
676 strategy: request.key.strategy.clone(),
677 cid: request.key.cid.clone(),
678 },
679 state: Err(UnindexedOrderError::Rejected(
680 crate::error::ApiError::OrderRejected("Missing order ID".to_string()),
681 )),
682 });
683 }
684 };
685
686 enum CancelMethod {
691 ByOid(u64),
692 ByCloid(Uuid),
693 }
694
695 let cancel_method = if let Ok(oid) = order_id.0.parse::<u64>() {
696 CancelMethod::ByOid(oid)
697 } else if let Ok(uuid) = Uuid::parse_str(&order_id.0) {
698 CancelMethod::ByCloid(uuid)
699 } else {
700 warn!(?order_id, "Order ID is neither u64 (OID) nor UUID (cloid)");
701 return Some(OrderResponseCancel {
702 key: OrderKey {
703 exchange: ExchangeId::HyperliquidPerp,
704 instrument: request.key.instrument.clone(),
705 strategy: request.key.strategy.clone(),
706 cid: request.key.cid.clone(),
707 },
708 state: Err(UnindexedOrderError::Rejected(
709 crate::error::ApiError::OrderRejected(
710 "Invalid order ID: must be numeric OID or UUID (for trigger orders)"
711 .to_string(),
712 ),
713 )),
714 });
715 };
716
717 let response = match cancel_method {
718 CancelMethod::ByOid(oid) => {
719 debug!(oid, "Cancelling by OID");
720 let cancel_request = ClientCancelRequest { asset: coin, oid };
721 self.exchange_client.cancel(cancel_request, None).await
722 }
723 CancelMethod::ByCloid(cloid) => {
724 debug!(%cloid, "Cancelling by cloid (trigger order)");
725 let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
726 self.exchange_client
727 .cancel_by_cloid(cancel_request, None)
728 .await
729 }
730 };
731
732 let response = match response {
733 Ok(r) => r,
734 Err(e) => {
735 warn!(%e, "Cancel order failed (transport)");
736 return Some(OrderResponseCancel {
737 key: OrderKey {
738 exchange: ExchangeId::HyperliquidPerp,
739 instrument: request.key.instrument.clone(),
740 strategy: request.key.strategy.clone(),
741 cid: request.key.cid.clone(),
742 },
743 state: Err(map_order_error(e, request.key.instrument)),
744 });
745 }
746 };
747
748 match response {
749 ExchangeResponseStatus::Ok(_) => {
750 debug!("Cancel order accepted");
751 Some(OrderResponseCancel {
753 key: OrderKey {
754 exchange: ExchangeId::HyperliquidPerp,
755 instrument: request.key.instrument.clone(),
756 strategy: request.key.strategy.clone(),
757 cid: request.key.cid.clone(),
758 },
759 state: Ok(Cancelled::new(
760 order_id.clone(),
761 Utc::now(),
762 Decimal::ZERO, )),
764 })
765 }
766 ExchangeResponseStatus::Err(msg) => {
767 warn!(%msg, "Cancel rejected by exchange");
768 Some(OrderResponseCancel {
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 state: Err(UnindexedOrderError::Rejected(
776 crate::error::ApiError::OrderRejected(msg),
777 )),
778 })
779 }
780 }
781 }
782
783 async fn open_order(
784 &self,
785 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
786 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
787 use hyperliquid_rust_sdk::{
788 ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
789 ExchangeResponseStatus,
790 };
791
792 let make_rejected =
793 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
794 Order {
795 key: OrderKey {
796 exchange: ExchangeId::HyperliquidPerp,
797 instrument: request.key.instrument.clone(),
798 strategy: request.key.strategy.clone(),
799 cid: request.key.cid.clone(),
800 },
801 side: request.state.side,
802 price: request.state.price,
803 quantity: request.state.quantity,
804 kind: request.state.kind,
805 time_in_force: request.state.time_in_force,
806 state: OrderState::inactive(OrderError::Rejected(
807 crate::error::ApiError::OrderRejected(msg),
808 )),
809 }
810 };
811
812 let make_unsupported =
813 |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
814 Order {
815 key: OrderKey {
816 exchange: ExchangeId::HyperliquidPerp,
817 instrument: request.key.instrument.clone(),
818 strategy: request.key.strategy.clone(),
819 cid: request.key.cid.clone(),
820 },
821 side: request.state.side,
822 price: request.state.price,
823 quantity: request.state.quantity,
824 kind: request.state.kind,
825 time_in_force: request.state.time_in_force,
826 state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
827 }
828 };
829
830 let coin = instrument_to_perp_coin(request.key.instrument);
831 let is_buy = request.state.side == Side::Buy;
832
833 match request.state.kind {
834 OrderKind::Market => {
835 return Some(make_unsupported(
836 "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
837 .to_string(),
838 ));
839 }
840 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
841 return Some(make_unsupported(
842 "Hyperliquid does not support trailing stop orders".to_string(),
843 ));
844 }
845 OrderKind::Limit
846 | OrderKind::Stop { .. }
847 | OrderKind::StopLimit { .. }
848 | OrderKind::TakeProfit { .. }
849 | OrderKind::TakeProfitLimit { .. } => {}
850 }
851
852 let cloid = cid_to_cloid(&request.key.cid);
853 let cloid_is_some = cloid.is_some();
854 let is_trigger_order = matches!(
855 request.state.kind,
856 OrderKind::Stop { .. }
857 | OrderKind::StopLimit { .. }
858 | OrderKind::TakeProfit { .. }
859 | OrderKind::TakeProfitLimit { .. }
860 );
861 if is_trigger_order && cloid.is_none() {
862 return Some(make_rejected(
863 "Trigger orders require UUID-format client order ID (use ClientOrderId::uuid()). \
864 Non-UUID IDs cannot be cancelled via cancel_by_cloid()."
865 .to_string(),
866 ));
867 }
868
869 let limit_px = match request.state.kind {
870 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
872 round_to_5_sig_figs(trigger_price)
873 }
874 _ => match request.state.price {
875 Some(p) => round_to_5_sig_figs(p),
876 None => {
877 return Some(make_rejected(
878 "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
879 .to_string(),
880 ));
881 }
882 },
883 };
884
885 let sz = round_to_5_sig_figs(request.state.quantity);
886
887 if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
889 warn!(
890 instrument = %request.key.instrument,
891 "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
892 );
893 }
894 let tif = map_tif(&request.state.time_in_force).to_string();
895
896 let order_type = match request.state.kind {
898 OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
899 OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
900 is_market: true,
901 trigger_px: round_to_5_sig_figs(trigger_price),
902 tpsl: "sl".to_string(),
903 }),
904 OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
905 is_market: false,
906 trigger_px: round_to_5_sig_figs(trigger_price),
907 tpsl: "sl".to_string(),
908 }),
909 OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
910 is_market: true,
911 trigger_px: round_to_5_sig_figs(trigger_price),
912 tpsl: "tp".to_string(),
913 }),
914 OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
915 is_market: false,
916 trigger_px: round_to_5_sig_figs(trigger_price),
917 tpsl: "tp".to_string(),
918 }),
919 OrderKind::Market
921 | OrderKind::TrailingStop { .. }
922 | OrderKind::TrailingStopLimit { .. } => {
923 unreachable!("unsupported order kinds rejected earlier")
924 }
925 };
926
927 let order_request = ClientOrderRequest {
928 asset: coin,
929 is_buy,
930 reduce_only: request.state.reduce_only,
931 limit_px,
932 sz,
933 cloid,
934 order_type,
935 };
936
937 let response = match self.exchange_client.order(order_request, None).await {
938 Ok(r) => r,
939 Err(e) => {
940 warn!(%e, "Open order failed");
941 return 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: OrderState::inactive(map_order_error(e, request.key.instrument)),
954 });
955 }
956 };
957
958 let state = match response {
960 ExchangeResponseStatus::Ok(exchange_resp) => {
961 let status = exchange_resp
963 .data
964 .and_then(|d| d.statuses.into_iter().next());
965
966 match status {
967 Some(ExchangeDataStatus::Resting(resting)) => {
968 debug!(oid = resting.oid, "Order resting");
969 OrderState::active(Open {
970 id: OrderId(format_smolstr!("{}", resting.oid)),
971 time_exchange: Utc::now(),
972 filled_quantity: Decimal::ZERO,
973 })
974 }
975 Some(ExchangeDataStatus::Filled(filled)) => {
976 debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
977 let avg_price = parse_decimal(&filled.avg_px, "avg_px");
979 OrderState::fully_filled(Filled::new(
980 OrderId(format_smolstr!("{}", filled.oid)),
981 Utc::now(),
982 parse_decimal(&filled.total_sz, "total_sz")
983 .unwrap_or(request.state.quantity),
984 avg_price,
985 ))
986 }
987 Some(ExchangeDataStatus::Error(msg)) => {
988 warn!(%msg, "Order rejected by exchange");
989 OrderState::inactive(OrderError::Rejected(
990 crate::error::ApiError::OrderRejected(msg),
991 ))
992 }
993 Some(
994 ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
995 ) => {
996 if cloid_is_some {
1002 debug!(cloid = %request.key.cid.0, "Order waiting (cloid trackable)");
1003 OrderState::active(Open {
1004 id: OrderId(request.key.cid.0.clone()),
1005 time_exchange: Utc::now(),
1006 filled_quantity: Decimal::ZERO,
1007 })
1008 } else {
1009 warn!("Order accepted but cid is not UUID — untrackable");
1010 OrderState::inactive(OrderError::Rejected(
1011 crate::error::ApiError::OrderRejected(
1012 "order accepted but cid is not UUID; cannot track for cancel"
1013 .to_string(),
1014 ),
1015 ))
1016 }
1017 }
1018 Some(ExchangeDataStatus::Success) | None => {
1019 warn!("Order accepted but no order ID returned");
1022 OrderState::inactive(OrderError::Rejected(
1023 crate::error::ApiError::OrderRejected(
1024 "no order ID in response".to_string(),
1025 ),
1026 ))
1027 }
1028 }
1029 }
1030 ExchangeResponseStatus::Err(msg) => {
1031 warn!(%msg, "Order rejected");
1032 OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
1033 msg,
1034 )))
1035 }
1036 };
1037
1038 Some(Order {
1039 key: OrderKey {
1040 exchange: ExchangeId::HyperliquidPerp,
1041 instrument: request.key.instrument.clone(),
1042 strategy: request.key.strategy.clone(),
1043 cid: request.key.cid.clone(),
1044 },
1045 side: request.state.side,
1046 price: request.state.price,
1047 quantity: request.state.quantity,
1048 kind: request.state.kind,
1049 time_in_force: request.state.time_in_force,
1050 state,
1051 })
1052 }
1053
1054 async fn fetch_balances(
1055 &self,
1056 _assets: &[AssetNameExchange],
1057 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1058 let address = self.wallet_h160();
1059
1060 let user_state = self
1061 .info_client
1062 .user_state(address)
1063 .await
1064 .map_err(map_sdk_error)?;
1065
1066 let now = Utc::now();
1067
1068 let account_value =
1070 parse_decimal(&user_state.margin_summary.account_value, "account_value")
1071 .unwrap_or(Decimal::ZERO);
1072 let margin_used = parse_decimal(
1073 &user_state.margin_summary.total_margin_used,
1074 "total_margin_used",
1075 )
1076 .unwrap_or(Decimal::ZERO);
1077
1078 let free_balance = (account_value - margin_used).max(Decimal::ZERO);
1080 Ok(vec![AssetBalance::new(
1081 AssetNameExchange::from(USDC_ASSET),
1082 Balance::new(account_value, free_balance),
1083 now,
1084 )])
1085 }
1086
1087 async fn fetch_open_orders(
1088 &self,
1089 instruments: &[InstrumentNameExchange],
1090 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1091 let address = self.wallet_h160();
1092
1093 let open_orders = self
1094 .info_client
1095 .open_orders(address)
1096 .await
1097 .map_err(map_sdk_error)?;
1098
1099 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1100 None
1101 } else {
1102 let mut set = HashSet::with_capacity(instruments.len());
1103 set.extend(instruments.iter().cloned());
1104 Some(set)
1105 };
1106
1107 let mut result = Vec::new();
1108 for order in open_orders {
1109 let instrument = perp_coin_to_instrument(&order.coin);
1110
1111 if instrument_filter
1112 .as_ref()
1113 .is_some_and(|f| !f.contains(&instrument))
1114 {
1115 continue;
1116 }
1117
1118 let Some(side) = parse_side(&order.side) else {
1119 continue;
1120 };
1121 let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
1122 continue;
1123 };
1124 let Some(quantity) = parse_decimal(&order.sz, "sz") else {
1125 continue;
1126 };
1127 let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
1128 warn!(
1129 oid = order.oid,
1130 timestamp = order.timestamp,
1131 "Invalid order timestamp, skipping"
1132 );
1133 continue;
1134 };
1135
1136 let order_id = format_smolstr!("{}", order.oid);
1137 result.push(Order {
1138 key: OrderKey {
1139 exchange: ExchangeId::HyperliquidPerp,
1140 instrument,
1141 strategy: StrategyId::unknown(),
1142 cid: ClientOrderId::new(order_id.clone()),
1143 },
1144 side,
1145 price: Some(price),
1146 quantity,
1147 kind: OrderKind::Limit,
1148 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1149 state: Open {
1150 id: OrderId(order_id),
1151 time_exchange,
1152 filled_quantity: Decimal::ZERO,
1153 },
1154 });
1155 }
1156
1157 Ok(result)
1158 }
1159
1160 async fn fetch_trades(
1161 &self,
1162 time_since: DateTime<Utc>,
1163 instruments: &[InstrumentNameExchange],
1164 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1165 let address = self.wallet_h160();
1166
1167 let fills = user_fills(&self.info_client, address).await?;
1168
1169 #[allow(clippy::cast_sign_loss)] let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1172
1173 let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1174 None
1175 } else {
1176 let mut set = HashSet::with_capacity(instruments.len());
1177 set.extend(instruments.iter().cloned());
1178 Some(set)
1179 };
1180
1181 let mut result = Vec::new();
1182 for fill in fills {
1183 if fill.time < time_since_ms {
1185 continue;
1186 }
1187
1188 let instrument = perp_coin_to_instrument(&fill.coin);
1189
1190 if instrument_filter
1191 .as_ref()
1192 .is_some_and(|f| !f.contains(&instrument))
1193 {
1194 continue;
1195 }
1196
1197 let Some(side) = parse_side(&fill.side) else {
1198 continue;
1199 };
1200 let Some(price) = parse_decimal(&fill.px, "px") else {
1201 continue;
1202 };
1203 let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1204 continue;
1205 };
1206 let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1207
1208 let Some(time_exchange) = millis_to_datetime(fill.time) else {
1209 warn!(time = fill.time, "Invalid fill timestamp, skipping");
1210 continue;
1211 };
1212
1213 result.push(Trade {
1214 id: TradeId(format_smolstr!("{}", fill.tid)),
1215 order_id: OrderId(format_smolstr!("{}", fill.oid)),
1216 instrument,
1217 strategy: StrategyId::unknown(),
1218 time_exchange,
1219 side,
1220 price,
1221 quantity,
1222 order_filled_quantity: None,
1225 fees: AssetFees {
1226 asset: AssetNameExchange::from("USDC"),
1227 fees: fee,
1228 fees_quote: Some(fee),
1229 },
1230 });
1231 }
1232
1233 Ok(result)
1234 }
1235}
1236
1237fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1239 let side = parse_side(&fill.side)?;
1240 let price = parse_decimal(&fill.px, "fill.px")?;
1241 let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1242 let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1243 let time_exchange = millis_to_datetime(fill.time)?;
1244 let instrument = perp_coin_to_instrument(&fill.coin);
1245 let order_id = OrderId(format_smolstr!("{}", fill.oid));
1246
1247 let trade = Trade {
1248 id: TradeId(format_smolstr!("{}", fill.tid)),
1249 order_id,
1250 instrument,
1251 strategy: StrategyId::unknown(),
1252 time_exchange,
1253 side,
1254 price,
1255 quantity,
1256 order_filled_quantity: None,
1259 fees: AssetFees {
1260 asset: AssetNameExchange::from("USDC"),
1261 fees: fee,
1262 fees_quote: Some(fee),
1263 },
1264 };
1265
1266 Some(AccountEvent::new(
1267 ExchangeId::HyperliquidPerp,
1268 AccountEventKind::Trade(trade),
1269 ))
1270}
1271
1272fn order_update_to_account_event(
1274 update: &hyperliquid_rust_sdk::OrderUpdate,
1275) -> Option<UnindexedAccountEvent> {
1276 let order = &update.order;
1277 let side = parse_side(&order.side)?;
1278 let price = parse_decimal(&order.limit_px, "order.limit_px")?;
1279 let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
1280 let time_exchange = millis_to_datetime(update.status_timestamp)?;
1281 let instrument = perp_coin_to_instrument(&order.coin);
1282
1283 let order_id_smol = format_smolstr!("{}", order.oid);
1285 let cid = order
1286 .cloid
1287 .as_deref()
1288 .map(|c| ClientOrderId::new(SmolStr::new(c)))
1289 .unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
1290
1291 let state = match update.status.as_str() {
1293 "open" | "resting" => {
1294 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1295 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1296 crate::order::state::OrderState::active(Open {
1297 id: OrderId(order_id_smol),
1298 time_exchange,
1299 filled_quantity,
1300 })
1301 }
1302 "filled" => crate::order::state::OrderState::fully_filled(Filled::new(
1303 OrderId(order_id_smol),
1304 time_exchange,
1305 orig_sz, None, )),
1308 "canceled" | "cancelled" => {
1309 let current_sz = parse_decimal(&order.sz, "order.sz")?;
1310 let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1311 crate::order::state::OrderState::inactive(Cancelled::new(
1312 OrderId(order_id_smol),
1313 time_exchange,
1314 filled_quantity,
1315 ))
1316 }
1317 status => {
1318 warn!(%status, "Unknown order status");
1319 return None;
1320 }
1321 };
1322
1323 let order_snapshot = Order {
1326 key: OrderKey {
1327 exchange: ExchangeId::HyperliquidPerp,
1328 instrument,
1329 strategy: StrategyId::unknown(),
1330 cid,
1331 },
1332 side,
1333 price: Some(price),
1334 quantity: orig_sz,
1335 kind: OrderKind::Limit,
1336 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1337 state,
1338 };
1339
1340 Some(AccountEvent::new(
1341 ExchangeId::HyperliquidPerp,
1342 AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
1343 ))
1344}
1345
1346#[cfg(test)]
1347#[allow(clippy::unwrap_used)]
1349mod tests {
1350 use super::*;
1351 use rust_decimal_macros::dec;
1352
1353 #[test]
1354 fn test_fill_to_account_event() {
1355 let fill_json = r#"{
1356 "coin": "BTC",
1357 "side": "B",
1358 "px": "65000.5",
1359 "sz": "0.1",
1360 "time": 1714100000000,
1361 "hash": "0xabc123",
1362 "startPosition": "0",
1363 "dir": "Open Long",
1364 "closedPnl": "0",
1365 "oid": 12345,
1366 "cloid": null,
1367 "crossed": false,
1368 "fee": "0.65",
1369 "feeToken": "USDC",
1370 "tid": 99999
1371 }"#;
1372
1373 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1374 let event = fill_to_account_event(&fill).unwrap();
1375
1376 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1377 match event.kind {
1378 AccountEventKind::Trade(trade) => {
1379 assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
1380 assert_eq!(
1381 trade.id.0, "99999",
1382 "the id is the fill's tid, not its hash"
1383 );
1384 assert_eq!(trade.side, Side::Buy);
1385 assert_eq!(trade.price, dec!(65000.5));
1386 assert_eq!(trade.quantity, dec!(0.1));
1387 assert_eq!(trade.fees.fees, dec!(0.65));
1388 }
1389 _ => panic!("Expected Trade event"),
1390 }
1391 }
1392
1393 #[test]
1394 fn test_fill_to_account_event_sell() {
1395 let fill_json = r#"{
1396 "coin": "ETH",
1397 "side": "A",
1398 "px": "3200",
1399 "sz": "1.5",
1400 "time": 1714100000000,
1401 "hash": "0xdef456",
1402 "startPosition": "1.5",
1403 "dir": "Close Long",
1404 "closedPnl": "150.0",
1405 "oid": 12346,
1406 "cloid": null,
1407 "crossed": true,
1408 "fee": "4.8",
1409 "feeToken": "USDC",
1410 "tid": 100000
1411 }"#;
1412
1413 let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1414 let event = fill_to_account_event(&fill).unwrap();
1415
1416 match event.kind {
1417 AccountEventKind::Trade(trade) => {
1418 assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
1419 assert_eq!(trade.side, Side::Sell);
1420 assert_eq!(trade.price, dec!(3200));
1421 assert_eq!(trade.quantity, dec!(1.5));
1422 }
1423 _ => panic!("Expected Trade event"),
1424 }
1425 }
1426
1427 fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1429 let json = format!(
1430 r#"{{
1431 "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
1432 "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1433 "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1434 "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
1435 }}"#
1436 );
1437 serde_json::from_str(&json).unwrap()
1438 }
1439
1440 #[test]
1441 fn a_trade_id_identifies_the_fill_not_the_transaction() {
1442 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1446 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1447
1448 let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1449 (first.kind, second.kind)
1450 else {
1451 panic!("Expected Trade events");
1452 };
1453
1454 assert_eq!(first.order_id, second.order_id, "one order produced both");
1455 assert_ne!(first.id, second.id, "but they are distinct fills");
1456 assert_eq!(first.id.0, "111");
1457 assert_eq!(second.id.0, "222");
1458 }
1459
1460 #[test]
1461 fn a_replayed_fill_is_delivered_once() {
1462 let cache = new_dedup_cache();
1465 let event = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1466 let replay = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1467
1468 assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1469 assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1470 }
1471
1472 #[test]
1473 fn both_fills_of_one_sweep_survive_dedup() {
1474 let cache = new_dedup_cache();
1477 let first = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1478 let second = fill_to_account_event(&sweep_fill(222, "65001.0")).unwrap();
1479
1480 assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
1481 assert!(!is_duplicate(
1482 &cache,
1483 dedup_key_from_event(&second).unwrap()
1484 ));
1485 }
1486
1487 #[test]
1488 fn one_tid_on_two_instruments_is_two_fills() {
1489 let cache = new_dedup_cache();
1492 let btc = fill_to_account_event(&sweep_fill(111, "65000.5")).unwrap();
1493
1494 let eth_json = r#"{
1495 "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
1496 "time": 1714100000000, "hash": "0xother", "startPosition": "0",
1497 "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
1498 "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
1499 }"#;
1500 let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
1501 let eth = fill_to_account_event(ð).unwrap();
1502
1503 assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
1504 assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
1505 }
1506
1507 #[test]
1508 fn test_order_update_to_account_event_open() {
1509 let update_json = r#"{
1510 "order": {
1511 "coin": "BTC",
1512 "side": "B",
1513 "limitPx": "64000",
1514 "sz": "0.5",
1515 "oid": 12345,
1516 "timestamp": 1714100000000,
1517 "origSz": "0.5",
1518 "cloid": null
1519 },
1520 "status": "open",
1521 "statusTimestamp": 1714100000000
1522 }"#;
1523
1524 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1525 let event = order_update_to_account_event(&update).unwrap();
1526
1527 assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
1528 match event.kind {
1529 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1530 assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
1531 assert_eq!(order.side, Side::Buy);
1532 assert_eq!(order.price, Some(dec!(64000)));
1533 assert_eq!(order.quantity, dec!(0.5));
1534 assert!(matches!(
1535 order.state,
1536 crate::order::state::OrderState::Active(_)
1537 ));
1538 }
1539 _ => panic!("Expected OrderSnapshot event"),
1540 }
1541 }
1542
1543 #[test]
1544 fn test_order_update_to_account_event_filled() {
1545 let update_json = r#"{
1546 "order": {
1547 "coin": "ETH",
1548 "side": "A",
1549 "limitPx": "3250",
1550 "sz": "0",
1551 "oid": 12346,
1552 "timestamp": 1714100000000,
1553 "origSz": "2.0",
1554 "cloid": null
1555 },
1556 "status": "filled",
1557 "statusTimestamp": 1714100001000
1558 }"#;
1559
1560 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1561 let event = order_update_to_account_event(&update).unwrap();
1562
1563 match event.kind {
1564 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1565 assert_eq!(order.side, Side::Sell);
1566 assert!(matches!(
1567 order.state,
1568 crate::order::state::OrderState::Inactive(
1569 crate::order::state::InactiveOrderState::FullyFilled(_)
1570 )
1571 ));
1572 }
1573 _ => panic!("Expected OrderSnapshot event"),
1574 }
1575 }
1576
1577 #[test]
1578 fn test_order_update_to_account_event_cancelled() {
1579 let update_json = r#"{
1580 "order": {
1581 "coin": "SOL",
1582 "side": "B",
1583 "limitPx": "150",
1584 "sz": "10",
1585 "oid": 12347,
1586 "timestamp": 1714100000000,
1587 "origSz": "10",
1588 "cloid": null
1589 },
1590 "status": "canceled",
1591 "statusTimestamp": 1714100002000
1592 }"#;
1593
1594 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1595 let event = order_update_to_account_event(&update).unwrap();
1596
1597 match event.kind {
1598 AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1599 assert_eq!(order.key.instrument.as_ref(), "SOL-USD-PERP");
1600 assert!(matches!(
1601 order.state,
1602 crate::order::state::OrderState::Inactive(
1603 crate::order::state::InactiveOrderState::Cancelled(_)
1604 )
1605 ));
1606 }
1607 _ => panic!("Expected OrderSnapshot event"),
1608 }
1609 }
1610
1611 #[test]
1612 fn test_order_update_unknown_status_returns_none() {
1613 let update_json = r#"{
1614 "order": {
1615 "coin": "BTC",
1616 "side": "B",
1617 "limitPx": "64000",
1618 "sz": "0.5",
1619 "oid": 12345,
1620 "timestamp": 1714100000000,
1621 "origSz": "0.5",
1622 "cloid": null
1623 },
1624 "status": "unknown_status",
1625 "statusTimestamp": 1714100000000
1626 }"#;
1627
1628 let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1629 let event = order_update_to_account_event(&update);
1630 assert!(event.is_none());
1631 }
1632}