pub mod common;
pub mod config;
pub mod error;
mod funding;
mod order_recovery;
mod perp_account;
mod perp_dexes;
pub mod precision;
pub mod spot;
mod spot_coins;
use crate::client::dedup::new_dedup_cache;
use crate::client::order_recovery::{
KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
};
use crate::{
AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
UnindexedAccountEvent, UnindexedAccountSnapshot,
balance::AssetBalance,
client::{ExecutionClient, OrderStatusClient},
emit_stream_terminated,
error::{
ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
UnindexedOrderError,
},
order::{
Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
state::{Filled, Open, OrderState, UnindexedOrderState},
},
position::{Position, PositionReport},
trade::{AssetFees, Trade, TradeId, TradeOrigin, TradesRead},
};
use chrono::{DateTime, Utc};
use common::{
CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrderListing, UnknownCoins,
cancel_outcome, cid_to_cloid, cloid_to_cid, map_tif, millis_to_datetime, open_order_to_order,
parse_decimal, parse_side, span_millis, user_fills_by_time, warn_unknown_coins,
};
pub use config::{HyperliquidConfig, HyperliquidConfigError, Network};
pub use error::HyperliquidConnectError;
use error::{map_order_error, order_rejection};
use ethers::signers::Signer;
use fnv::FnvHashSet;
use funding::{funding_cash_flow, send_fundings};
use futures::{StreamExt, stream::BoxStream};
use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
use order_recovery::{
ReconnectWatch, fetch_order_record, listed_cids, lookup_from_record, remember_open,
remember_snapshot, send_fills, send_observed, spawn_order_checks,
};
use perp_account::{account_mode, dex_states};
use perp_dexes::{PerpDexes, UnconfiguredDexes, warn_unconfigured_dexes};
pub use precision::OrderPrecision;
use precision::{WireOrderError, wire_order};
use rust_decimal::Decimal;
use rustrade_instrument::{
Side,
asset::name::AssetNameExchange,
exchange::ExchangeId,
instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
};
use smol_str::format_smolstr;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tokio::sync::{Notify, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
#[derive(Debug, Clone)]
pub struct HyperliquidClient {
config: HyperliquidConfig,
info_client: Arc<InfoClient>,
exchange_client: Arc<ExchangeClient>,
dexes: Arc<PerpDexes>,
known_live: SharedKnownLiveOrders,
}
impl HyperliquidClient {
pub async fn connect(config: HyperliquidConfig) -> Result<Self, HyperliquidConnectError> {
let base_url = config.base_url();
let info_client = InfoClient::new(None, Some(base_url))
.await
.map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
let mut dexes = PerpDexes::fetch(&info_client, &config.dexes).await?;
let wallet = config.wallet.clone();
let mut exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
.await
.map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
exchange_client.coin_to_asset.extend(dexes.asset_ids());
dexes.add_default_perps(&exchange_client.meta.universe);
info!(
network = ?config.network,
dexes = ?config.dexes,
wallet = %config.wallet_address_hex(),
"Created HyperliquidClient"
);
Ok(Self {
config,
info_client: Arc::new(info_client),
exchange_client: Arc::new(exchange_client),
dexes: Arc::new(dexes),
known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
})
}
pub fn wallet_address(&self) -> String {
self.config.wallet_address_hex()
}
pub fn order_precision(&self, instrument: &InstrumentNameExchange) -> Option<OrderPrecision> {
let coin = self.dexes.coin(instrument).ok()?;
self.dexes.sz_decimals(coin).map(OrderPrecision::perp)
}
fn wallet_h160(&self) -> ethers::types::H160 {
self.config.wallet.address()
}
fn check_instruments(
&self,
instruments: &[InstrumentNameExchange],
) -> Result<(), UnindexedClientError> {
for instrument in instruments {
self.dexes.coin(instrument).map_err(|reason| {
UnindexedClientError::Api(crate::error::ApiError::InstrumentInvalid(
instrument.clone(),
reason,
))
})?;
}
Ok(())
}
}
impl ExecutionClient for HyperliquidClient {
const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
&[InstrumentKindDiscriminant::Perpetual];
type Config = HyperliquidConfig;
type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
fn new(config: Self::Config) -> Self {
tokio::runtime::Handle::current()
.block_on(Self::connect(config))
.unwrap_or_else(|e| panic!("Failed to create HyperliquidClient: {e}"))
}
async fn account_snapshot(
&self,
_assets: &[AssetNameExchange],
instruments: &[InstrumentNameExchange],
) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
self.check_instruments(instruments)?;
let address = self.wallet_h160();
let info_client = &*self.info_client;
let dexes = &*self.dexes;
let (mode, states, open_orders) = tokio::try_join!(
account_mode(info_client, address),
dex_states(info_client, address, dexes),
perp_account::open_orders(info_client, address, dexes),
)?;
let now = Utc::now();
let balances =
perp_account::snapshot_balances(info_client, address, dexes, mode, &states, now)
.await?;
let instrument_filter = InstrumentFilter::new(instruments);
warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
let mut listing = OpenOrderListing::new(
&open_orders,
ExchangeId::HyperliquidPerp,
instruments,
|coin| dexes.instrument(coin),
);
let mut instrument_snapshots = Vec::new();
for asset_pos in states.iter().flat_map(|(_, state)| &state.asset_positions) {
let pos = &asset_pos.position;
let Some(instrument) = dexes.instrument(&pos.coin) else {
continue;
};
if !instrument_filter.matches(&instrument) {
continue;
}
let position = perp_position_report(pos, now);
let (orders, orders_complete) = match listing.remove(&instrument) {
Some(listed) => (listed.orders, listed.orders_complete),
None => (Vec::new(), true),
};
instrument_snapshots.push(InstrumentAccountSnapshot {
instrument,
orders,
orders_complete,
position,
isolated: None,
});
}
instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
snapshot.position = PositionReport::Flat;
snapshot
}));
let snapshot = AccountSnapshot {
exchange: ExchangeId::HyperliquidPerp,
balances,
instruments: instrument_snapshots,
};
remember_snapshot(&self.known_live, &snapshot);
Ok(snapshot)
}
async fn account_stream(
&self,
_assets: &[AssetNameExchange],
_instruments: &[InstrumentNameExchange],
) -> Result<Self::AccountStream, UnindexedClientError> {
let user = self.wallet_h160();
let base_url = self.config.base_url();
let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
.await
.map_err(|e| ConnectivityError::Socket(e.to_string()))?;
let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
let (fundings_tx, mut fundings_rx) = mpsc::unbounded_channel::<Message>();
ws_client
.subscribe(Subscription::UserFills { user }, fills_tx)
.await
.map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
ws_client
.subscribe(Subscription::OrderUpdates { user }, orders_tx)
.await
.map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
ws_client
.subscribe(Subscription::UserFundings { user }, fundings_tx)
.await
.map_err(|e| ConnectivityError::Socket(format!("UserFundings subscribe: {e}")))?;
info!(%user, "Subscribed to Hyperliquid account stream");
let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
let cancel_token = CancellationToken::new();
let terminated = Arc::new(AtomicBool::new(false));
let fills_dedup = new_dedup_cache();
let reconnected = Arc::new(Notify::new());
let checks_cancel = cancel_token.child_token();
let (list_client, lookup_client) = (self.info_client.clone(), self.info_client.clone());
let (list_dexes, lookup_dexes) = (self.dexes.clone(), self.dexes.clone());
spawn_order_checks(
ExchangeId::HyperliquidPerp,
self.known_live.clone(),
reconnected.clone(),
checks_cancel.clone(),
event_tx.clone(),
move |instruments| {
perp_listed_cids(list_client.clone(), list_dexes.clone(), user, instruments)
},
move |key| perp_order_lookup(lookup_client.clone(), lookup_dexes.clone(), user, key),
);
let fills_event_tx = event_tx.clone();
let fills_cancel = cancel_token.clone();
let fills_terminated = terminated.clone();
let fills_checks_cancel = checks_cancel.clone();
let fills_dexes = self.dexes.clone();
tokio::spawn(async move {
let mut unknown_coins = UnknownCoins::default();
let mut unconfigured = UnconfiguredDexes::default();
let mut reconnects = ReconnectWatch::new(reconnected);
loop {
tokio::select! {
biased;
() = fills_cancel.cancelled() => {
debug!("Fills task cancelled");
return;
}
msg = fills_rx.recv() => {
let Some(msg) = msg else {
debug!("Fills receiver closed");
fills_checks_cancel.cancel();
if fills_terminated
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
emit_stream_terminated(
&fills_event_tx,
ExchangeId::HyperliquidPerp,
StreamTerminationReason::Error(
"hyperliquid account stream closed".to_string(),
),
);
}
return;
};
match msg {
Message::UserFills(fills) => {
let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
unknown_coins.warn_once(&fill.coin);
unconfigured.warn_once(&fills_dexes, &fill.coin);
fill_to_account_event(fill, &fills_dexes)
};
if !send_fills(
&fills.data,
convert,
&fills_dedup,
&fills_event_tx,
&mut reconnects,
) {
debug!("Fills event channel closed");
return;
}
}
Message::NoData => {
warn!("UserFills WebSocket disconnected");
reconnects.dropped();
}
Message::HyperliquidError(e) => {
error!(%e, "UserFills WebSocket error");
}
_ => {}
}
}
}
}
});
let fundings_event_tx = event_tx.clone();
let fundings_cancel = cancel_token.clone();
let fundings_dexes = self.dexes.clone();
let fundings_dedup = new_dedup_cache();
tokio::spawn(async move {
let mut unknown_coins = UnknownCoins::default();
let mut unconfigured = UnconfiguredDexes::default();
loop {
tokio::select! {
biased;
() = fundings_cancel.cancelled() => {
debug!("Fundings task cancelled");
return;
}
msg = fundings_rx.recv() => {
let Some(msg) = msg else {
debug!("Fundings receiver closed");
return;
};
match msg {
Message::UserFundings(fundings) => {
let convert = |funding: &hyperliquid_rust_sdk::UserFunding| {
unknown_coins.warn_once(&funding.coin);
unconfigured.warn_once(&fundings_dexes, &funding.coin);
funding_cash_flow(funding, &fundings_dexes)
};
if !send_fundings(
&fundings.data,
convert,
&fundings_dedup,
&fundings_event_tx,
) {
debug!("Fundings event channel closed");
return;
}
}
Message::NoData => {
warn!("UserFundings WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "UserFundings WebSocket error");
}
_ => {}
}
}
}
}
});
let orders_event_tx = event_tx;
let orders_cancel = cancel_token.clone();
let orders_terminated = terminated;
let orders_known = self.known_live.clone();
let orders_dexes = self.dexes.clone();
tokio::spawn(async move {
let _ws_client = ws_client;
let mut unknown_coins = UnknownCoins::default();
let mut unconfigured = UnconfiguredDexes::default();
loop {
tokio::select! {
biased;
() = orders_cancel.cancelled() => {
debug!("Orders task cancelled");
return;
}
msg = orders_rx.recv() => {
let Some(msg) = msg else {
debug!("Orders receiver closed");
checks_cancel.cancel();
if orders_terminated
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
emit_stream_terminated(
&orders_event_tx,
ExchangeId::HyperliquidPerp,
StreamTerminationReason::Error(
"hyperliquid account stream closed".to_string(),
),
);
}
return;
};
match msg {
Message::OrderUpdates(updates) => {
for update in updates.data {
unknown_coins.warn_once(&update.order.coin);
unconfigured.warn_once(&orders_dexes, &update.order.coin);
if let Some(event) =
order_update_to_account_event(&update, &orders_dexes)
&& !send_observed(&orders_known, &orders_event_tx, event)
{
debug!("Orders event channel closed");
return;
}
}
}
Message::NoData => {
warn!("OrderUpdates WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "OrderUpdates WebSocket error");
}
_ => {}
}
}
}
}
});
let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
Ok(guarded_stream.boxed())
}
async fn cancel_order(
&self,
request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
) -> UnindexedOrderResponseCancel {
use crate::order::{request::OrderResponseCancel, state::Cancelled};
use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
use uuid::Uuid;
let coin = match self.dexes.coin(request.key.instrument) {
Ok(coin) => coin.to_owned(),
Err(reason) => {
warn!(%reason, cid = %request.key.cid, "Cannot cancel order on this instrument");
return OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
state: Err(UnindexedOrderError::Rejected(
crate::error::ApiError::InstrumentInvalid(
request.key.instrument.clone(),
reason,
),
)),
};
}
};
enum CancelMethod {
ByOid(u64),
ByCloid(Uuid),
}
let cancel_method = match &request.state.id {
Some(VenueOrderId::Assigned(id)) => {
id.0.parse::<u64>()
.map(CancelMethod::ByOid)
.map_err(|_| "venue order id is not a numeric Hyperliquid oid")
}
Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
.map(CancelMethod::ByCloid)
.ok_or("order is addressable only by its client id, which is not a canonical UUID"),
None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
};
let cancel_method = match cancel_method {
Ok(method) => method,
Err(reason) => {
warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
return OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
state: Err(UnindexedOrderError::Rejected(
crate::error::ApiError::OrderRejected(reason.to_string()),
)),
};
}
};
let cancelled_id = match &request.state.id {
Some(venue_id) => venue_id.or_client_id(&request.key.cid),
None => OrderId(request.key.cid.0.clone()),
};
let response = match cancel_method {
CancelMethod::ByOid(oid) => {
debug!(oid, "Cancelling by OID");
let cancel_request = ClientCancelRequest { asset: coin, oid };
self.exchange_client.cancel(cancel_request, None).await
}
CancelMethod::ByCloid(cloid) => {
debug!(%cloid, "Cancelling by cloid (no oid assigned)");
let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
self.exchange_client
.cancel_by_cloid(cancel_request, None)
.await
}
};
let response = match response {
Ok(r) => r,
Err(e) => {
warn!(%e, "Cancel order failed (transport)");
return OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
state: Err(map_order_error(e, request.key.instrument)),
};
}
};
match cancel_outcome(response) {
Ok(()) => {
debug!("Cancel order accepted");
self.known_live.lock().ended(&request.key.cid);
OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
state: Ok(Cancelled::new(
cancelled_id.clone(),
Utc::now(),
None, )),
}
}
Err(reason) => {
warn!(%reason, cid = %request.key.cid, "Cancel rejected by exchange");
OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
state: Err(UnindexedOrderError::Rejected(
crate::error::ApiError::OrderRejected(reason),
)),
}
}
}
}
async fn open_order(
&self,
request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
use hyperliquid_rust_sdk::{
ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
};
let make_inactive =
|error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(error),
}
};
let make_rejected = |msg: String| {
make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
msg,
)))
};
let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
if let Some(effect) = request.state.margin_effect {
return make_inactive(OrderError::UnsupportedMarginEffect(effect));
}
let make_invalid_instrument = |reason: String| {
make_inactive(OrderError::Rejected(
crate::error::ApiError::InstrumentInvalid(request.key.instrument.clone(), reason),
))
};
let coin = match self.dexes.coin(request.key.instrument) {
Ok(coin) => coin.to_owned(),
Err(reason) => return make_invalid_instrument(reason),
};
let is_buy = request.state.side == Side::Buy;
match request.state.kind {
OrderKind::Market => {
return make_unsupported(
"Hyperliquid does not support market orders; use Limit with IOC time-in-force"
.to_string(),
);
}
OrderKind::TrailingStop { .. }
| OrderKind::TrailingStopLimit { .. }
| OrderKind::TrailingStopFixedLimit { .. } => {
return make_unsupported(
"Hyperliquid does not support trailing stop orders".to_string(),
);
}
OrderKind::Limit
| OrderKind::Stop { .. }
| OrderKind::StopLimit { .. }
| OrderKind::TakeProfit { .. }
| OrderKind::TakeProfitLimit { .. } => {}
}
let Some(cloid) = cid_to_cloid(&request.key.cid) else {
return make_rejected(CLOID_REQUIRED.to_string());
};
let tif = map_tif(&request.state.time_in_force).to_string();
let Some(precision) = self.order_precision(request.key.instrument) else {
return make_invalid_instrument(format!(
"{coin} was not listed when the client connected; reconnect to trade a perpetual \
listed since"
));
};
let wire = match wire_order(
&precision,
request.state.kind,
tif,
request.state.price,
request.state.quantity,
) {
Ok(wire) => wire,
Err(WireOrderError::Precision(violation)) => {
return make_inactive(OrderError::InvalidPrecision(violation));
}
Err(WireOrderError::MissingPrice) => {
return make_rejected(
"Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
.to_string(),
);
}
Err(WireOrderError::UnsupportedKind) => {
return make_unsupported(format!(
"Hyperliquid does not support {} orders",
request.state.kind
));
}
};
if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
warn!(
instrument = %request.key.instrument,
"FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
);
}
let order_request = ClientOrderRequest {
asset: coin,
is_buy,
reduce_only: request.state.reduce_only,
limit_px: wire.limit_px,
sz: wire.sz,
cloid: Some(cloid),
order_type: wire.order_type,
};
let response = match self.exchange_client.order(order_request, None).await {
Ok(r) => r,
Err(e) => {
warn!(%e, "Open order failed");
return Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(map_order_error(e, request.key.instrument)),
};
}
};
let state = match response {
ExchangeResponseStatus::Ok(exchange_resp) => {
let status = exchange_resp
.data
.and_then(|d| d.statuses.into_iter().next());
match status {
Some(ExchangeDataStatus::Resting(resting)) => {
debug!(oid = resting.oid, "Order resting");
OrderState::active(Open {
id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
time_exchange: Utc::now(),
filled_quantity: Decimal::ZERO,
})
}
Some(ExchangeDataStatus::Filled(filled)) => {
debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
let avg_price = parse_decimal(&filled.avg_px, "avg_px");
OrderState::fully_filled(Filled::new(
OrderId(format_smolstr!("{}", filled.oid)),
Utc::now(),
parse_decimal(&filled.total_sz, "total_sz")
.unwrap_or(request.state.quantity),
avg_price,
))
}
Some(ExchangeDataStatus::Error(msg)) => {
warn!(%msg, "Order rejected by exchange");
OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
}
Some(
ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
) => {
debug!(cid = %request.key.cid, "Order waiting under its cloid");
OrderState::active(Open {
id: VenueOrderId::ClientAssigned,
time_exchange: Utc::now(),
filled_quantity: Decimal::ZERO,
})
}
Some(ExchangeDataStatus::Success) | None => {
warn!("Order accepted but no order ID returned");
OrderState::inactive(OrderError::Rejected(
crate::error::ApiError::OrderRejected(
"no order ID in response".to_string(),
),
))
}
}
}
ExchangeResponseStatus::Err(msg) => {
warn!(%msg, "Order rejected");
OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
}
};
let order = Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
},
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state,
};
self.known_live
.lock()
.placed(&order.key, order.quantity, order.kind, &order.state);
order
}
async fn fetch_balances(
&self,
_assets: &[AssetNameExchange],
) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
perp_account::fetch_balances(&self.info_client, self.wallet_h160(), &self.dexes).await
}
async fn fetch_open_orders(
&self,
instruments: &[InstrumentNameExchange],
) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
self.check_instruments(instruments)?;
let address = self.wallet_h160();
let open_orders =
perp_account::open_orders(&self.info_client, address, &self.dexes).await?;
warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
let instrument_filter = InstrumentFilter::new(instruments);
let orders: Vec<_> = open_orders
.iter()
.filter_map(|order| {
let instrument = self.dexes.instrument(&order.coin)?;
if !instrument_filter.matches(&instrument) {
return None;
}
open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
})
.collect();
remember_open(&self.known_live, &orders);
Ok(orders)
}
async fn fetch_trades(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
instruments: &[InstrumentNameExchange],
) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
self.check_instruments(instruments)?;
let Some((start_ms, end_ms)) = span_millis(start, end) else {
return Ok(TradesRead::complete(Vec::new()));
};
let address = self.wallet_h160();
let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
let held_since = common::held_since(&fills, start_ms);
warn_unknown_coins(fills.iter().map(|fill| fill.coin.as_str()));
warn_unconfigured_dexes(&self.dexes, fills.iter().map(|fill| fill.coin.as_str()));
let instrument_filter = InstrumentFilter::new(instruments);
let mut result = Vec::new();
for fill in fills {
let Some((instrument, collateral)) = self.dexes.perp(&fill.coin) else {
continue;
};
if !instrument_filter.matches(&instrument) {
continue;
}
let Some(side) = parse_side(&fill.side) else {
continue;
};
let Some(price) = parse_decimal(&fill.px, "px") else {
continue;
};
let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
continue;
};
let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
let Some(time_exchange) = millis_to_datetime(fill.time) else {
warn!(time = fill.time, "Invalid fill timestamp, skipping");
continue;
};
if !(start..=end).contains(&time_exchange) {
continue;
}
result.push(Trade {
id: TradeId(format_smolstr!("{}", fill.tid)),
order_id: OrderId(format_smolstr!("{}", fill.oid)),
instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
order_filled_quantity: None,
fees: perp_fill_fees(fill.fee_token.as_deref(), fee, collateral),
origin: TradeOrigin::Order,
position_side: None,
client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
});
}
Ok(TradesRead::complete(result).with_held_since(held_since))
}
}
impl OrderStatusClient for HyperliquidClient {
async fn fetch_ended_orders(
&self,
orders: &[UnindexedOrderKey],
) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
let address = self.wallet_h160();
fetch_ended_by_key(orders, |key| {
perp_order_lookup(self.info_client.clone(), self.dexes.clone(), address, key)
})
.await
}
}
async fn perp_order_lookup(
info_client: Arc<InfoClient>,
dexes: Arc<PerpDexes>,
address: ethers::types::H160,
key: UnindexedOrderKey,
) -> Result<OrderLookup, UnindexedClientError> {
let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
return Ok(OrderLookup::Unknown);
};
let instrument = dexes.instrument(&record.order.coin);
Ok(lookup_from_record(key, &record, instrument))
}
async fn perp_listed_cids(
info_client: Arc<InfoClient>,
dexes: Arc<PerpDexes>,
address: ethers::types::H160,
instruments: Vec<InstrumentNameExchange>,
) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
let rows = perp_account::open_orders(&info_client, address, &dexes).await?;
Ok(listed_cids(&rows, &instruments, |coin| {
dexes.instrument(coin)
}))
}
fn perp_position_report(
position: &hyperliquid_rust_sdk::PositionData,
now: DateTime<Utc>,
) -> PositionReport {
let Some(quantity) = parse_decimal(&position.szi, "szi") else {
return PositionReport::Unreported;
};
PositionReport::from_position(Position::new(
quantity,
position
.entry_px
.as_ref()
.and_then(|p| parse_decimal(p, "entry_px")),
parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
parse_decimal(&position.margin_used, "margin_used"),
position
.liquidation_px
.as_ref()
.and_then(|p| parse_decimal(p, "liquidation_px")),
Some(Decimal::from(position.leverage.value)),
parse_decimal(&position.cum_funding.since_open, "cum_funding.since_open").map(|paid| -paid),
now,
))
}
fn perp_fill_fees(
fee_token: Option<&str>,
fee: Decimal,
collateral: &str,
) -> AssetFees<AssetNameExchange> {
let asset = fee_token.unwrap_or(collateral);
let fees_quote = (asset == collateral).then_some(fee);
AssetFees::new(AssetNameExchange::from(asset), fee, fees_quote)
}
fn fill_to_account_event(
fill: &hyperliquid_rust_sdk::TradeInfo,
dexes: &PerpDexes,
) -> Option<UnindexedAccountEvent> {
let (instrument, collateral) = dexes.perp(&fill.coin)?;
let side = parse_side(&fill.side)?;
let price = parse_decimal(&fill.px, "fill.px")?;
let quantity = parse_decimal(&fill.sz, "fill.sz")?;
let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
let time_exchange = millis_to_datetime(fill.time)?;
let order_id = OrderId(format_smolstr!("{}", fill.oid));
let trade = Trade {
id: TradeId(format_smolstr!("{}", fill.tid)),
order_id,
instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
order_filled_quantity: None,
fees: perp_fill_fees(Some(&fill.fee_token), fee, collateral),
origin: TradeOrigin::Order,
position_side: None,
client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
};
Some(AccountEvent::new(
ExchangeId::HyperliquidPerp,
AccountEventKind::Trade(trade),
))
}
fn order_update_to_account_event(
update: &hyperliquid_rust_sdk::OrderUpdate,
dexes: &PerpDexes,
) -> Option<UnindexedAccountEvent> {
common::order_update_to_account_event(
update,
ExchangeId::HyperliquidPerp,
dexes.instrument(&update.order.coin)?,
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::client::dedup::{dedup_key_from_event, is_duplicate};
use rust_decimal_macros::dec;
use rustrade_integration::collection::snapshot::Snapshot;
use std::collections::HashMap;
fn default_dex() -> PerpDexes {
PerpDexes::default()
}
mod order_recovery {
use super::super::common::info_tests::info_client_against;
use super::super::order_recovery::tests::{CID, CLOID, record_json, row};
use super::*;
use crate::order::state::InactiveOrderState;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let client = Arc::new(info_client_against(server.uri()).await);
(server, client)
}
fn key() -> UnindexedOrderKey {
OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: InstrumentNameExchange::new("BTC-USDC-PERP"),
strategy: StrategyId::new("strategy"),
cid: ClientOrderId::new(CID),
}
}
fn order_status(coin: &str) -> serde_json::Value {
serde_json::json!({
"status": "order",
"order": record_json(coin, "canceled", "0.01", Some(CLOID)),
})
}
#[test]
fn a_reconnect_snapshot_wakes_the_check_once_its_fills_are_sent() {
use futures::FutureExt as _;
let notified = |reconnected: &Notify| reconnected.notified().now_or_never().is_some();
let snapshot = |fills| hyperliquid_rust_sdk::UserFillsData {
is_snapshot: Some(true),
user: ethers::types::H160::zero(),
fills,
};
let reconnected = Arc::new(Notify::new());
let mut reconnects = ReconnectWatch::new(reconnected.clone());
let dedup = new_dedup_cache();
let (tx, mut rx) = mpsc::unbounded_channel();
let tids = |rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>| {
std::iter::from_fn(|| rx.try_recv().ok())
.map(|event| match event.kind {
AccountEventKind::Trade(trade) => trade.id.0.to_string(),
other => panic!("expected a trade, got {other:?}"),
})
.collect::<Vec<_>>()
};
let first = snapshot(vec![sweep_fill(1, "60000")]);
assert!(send_fills(
&first,
|fill| fill_to_account_event(fill, &default_dex()),
&dedup,
&tx,
&mut reconnects
));
assert_eq!(tids(&mut rx), ["1"]);
assert!(!notified(&reconnected));
reconnects.dropped();
let resubscribed = snapshot(vec![sweep_fill(1, "60000"), sweep_fill(2, "60001")]);
assert!(send_fills(
&resubscribed,
|fill| fill_to_account_event(fill, &default_dex()),
&dedup,
&tx,
&mut reconnects
));
assert_eq!(
tids(&mut rx),
["2"],
"the fill already sent is not sent again"
);
assert!(notified(&reconnected));
drop(rx);
reconnects.dropped();
let unsent = snapshot(vec![sweep_fill(3, "60002")]);
assert!(!send_fills(
&unsent,
|fill| fill_to_account_event(fill, &default_dex()),
&dedup,
&tx,
&mut reconnects
));
assert!(!notified(&reconnected));
}
#[tokio::test]
async fn a_perp_lookup_reads_how_the_order_ended() {
let (_server, client) = serve(order_status("BTC")).await;
let lookup =
perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
.await
.unwrap();
let OrderLookup::Ended(order) = lookup else {
panic!("expected an ended order, got {lookup:?}");
};
assert_eq!(order.key, key());
assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
}
#[tokio::test]
async fn a_perp_lookup_of_a_spot_order_is_unknown() {
let (_server, client) = serve(order_status("@107")).await;
let lookup =
perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
.await
.unwrap();
assert!(matches!(lookup, OrderLookup::Unknown));
}
#[tokio::test]
async fn a_perp_listing_names_only_the_orders_on_the_perpetuals_asked_about() {
let rows = serde_json::json!([
row("BTC", 1, Some(CLOID)),
row("ETH", 2, None),
row("@107", 3, None),
]);
let (_server, client) = serve(rows).await;
let listed = perp_listed_cids(
client,
Arc::default(),
ethers::types::H160::zero(),
vec![InstrumentNameExchange::new("BTC-USDC-PERP")],
)
.await
.unwrap();
assert_eq!(
listed.into_iter().collect::<Vec<_>>(),
[ClientOrderId::new(CID)]
);
}
}
async fn client_against(
server: &wiremock::MockServer,
coin_to_asset: HashMap<String, u32>,
) -> HyperliquidClient {
use super::common::info_tests::{
exchange_client_against, info_client_against, test_wallet,
};
let mut dexes = super::perp_dexes::tests::xyz_and_flx().await;
dexes.add_default_perps(&[hyperliquid_rust_sdk::AssetMeta {
name: "BTC".to_owned(),
sz_decimals: 5,
}]);
HyperliquidClient {
config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
info_client: Arc::new(info_client_against(server.uri()).await),
exchange_client: Arc::new(exchange_client_against(server.uri(), coin_to_asset).await),
dexes: Arc::new(dexes),
known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
}
}
mod open_order {
use super::*;
use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn resting_server() -> MockServer {
server_responding(serde_json::json!({
"status": "ok",
"response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
}))
.await
}
async fn server_responding(body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/exchange"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn an_order_refused_for_margin_is_balance_insufficient() {
const MARGIN: &str = "Insufficient margin to place order. asset=0";
const MINIMUM: &str = "Order must have minimum value of $10. asset=0";
let in_status = |error: &str| {
serde_json::json!({
"status": "ok",
"response": {"type": "order", "data": {"statuses": [{"error": error}]}},
})
};
let in_response = |error: &str| serde_json::json!({"status": "err", "response": error});
for (body, expected) in [
(
in_status(MARGIN),
ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
),
(
in_response(MARGIN),
ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
),
(
in_status(MINIMUM),
ApiError::OrderRejected(MINIMUM.to_owned()),
),
(
in_response(MINIMUM),
ApiError::OrderRejected(MINIMUM.to_owned()),
),
] {
let server = server_responding(body.clone()).await;
let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
let order = place(
&client,
&InstrumentNameExchange::from("BTC-USDC-PERP"),
OrderKind::Limit,
Some(dec!(50000)),
dec!(0.001),
)
.await;
let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
reported,
))) = &order.state
else {
panic!("{body}: expected a rejection, got {:?}", order.state);
};
assert_eq!(reported, &expected, "{body}");
}
}
async fn place(
client: &HyperliquidClient,
instrument: &InstrumentNameExchange,
kind: OrderKind,
price: Option<Decimal>,
quantity: Decimal,
) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
client
.open_order(OrderEvent {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument,
strategy: StrategyId::new("strategy"),
cid: ClientOrderId::uuid(),
},
state: RequestOpen::new(
Side::Buy,
price,
quantity,
kind,
TimeInForce::GoodUntilCancelled { post_only: false },
),
})
.await
}
async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.filter(|request| request.url.path() == "/exchange")
.map(|request| {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
body["action"]["orders"][0].clone()
})
.collect()
}
#[tokio::test]
async fn the_client_reports_each_perpetuals_precision() {
let server = MockServer::start().await;
let client = client_against(&server, HashMap::new()).await;
assert_eq!(
client.order_precision(&InstrumentNameExchange::from("BTC-USDC-PERP")),
Some(OrderPrecision::perp(5))
);
assert_eq!(
client.order_precision(&InstrumentNameExchange::from("xyz:TSLA-USDC-PERP")),
Some(OrderPrecision::perp(2)),
"a HIP-3 perpetual's szDecimals come from its DEX's meta"
);
assert_eq!(
client.order_precision(&InstrumentNameExchange::from("ETH-USDC-PERP")),
None,
"not listed when the client connected"
);
assert_eq!(
client.order_precision(&InstrumentNameExchange::from("HYPE-USDC-SPOT")),
None
);
}
#[tokio::test]
async fn an_order_breaking_the_precision_rules_is_refused_unsent() {
let server = resting_server().await;
let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
let cases = [
(
OrderKind::Limit,
dec!(60000),
dec!(0.000001),
OrderField::Quantity,
PrecisionLimit::DecimalPlaces { max: 5 },
),
(
OrderKind::Limit,
dec!(60000),
dec!(0),
OrderField::Quantity,
PrecisionLimit::NotPositive,
),
(
OrderKind::Limit,
dec!(65432.1),
dec!(0.001),
OrderField::Price,
PrecisionLimit::SignificantFigures { max: 5 },
),
(
OrderKind::Limit,
dec!(6543.21),
dec!(0.001),
OrderField::Price,
PrecisionLimit::DecimalPlaces { max: 1 },
),
(
OrderKind::StopLimit {
trigger_price: dec!(6543.21),
},
dec!(6543),
dec!(0.001),
OrderField::TriggerPrice,
PrecisionLimit::DecimalPlaces { max: 1 },
),
];
for (kind, price, quantity, field, limit) in cases {
let order = place(&client, &btc, kind, Some(price), quantity).await;
let expected = PrecisionViolation {
field,
value: match field {
OrderField::Quantity => quantity,
OrderField::TriggerPrice => dec!(6543.21),
_ => price,
},
limit,
};
assert_eq!(
order.state,
OrderState::Inactive(InactiveOrderState::OpenFailed(
OrderError::InvalidPrecision(expected)
)),
"{kind} at {price} for {quantity}"
);
}
assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
}
#[tokio::test]
async fn a_valid_order_is_sent_unchanged() {
let server = resting_server().await;
let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
let order = place(
&client,
&btc,
OrderKind::Limit,
Some(dec!(123456)),
dec!(0.00123),
)
.await;
assert!(
matches!(order.state, OrderState::Active(_)),
"{:?}",
order.state
);
let sent = sent_orders(&server).await;
assert_eq!(sent.len(), 1);
assert_eq!(
(&sent[0]["p"], &sent[0]["s"]),
(&serde_json::json!("123456"), &serde_json::json!("0.00123"))
);
}
#[tokio::test]
async fn an_order_on_a_perpetual_listed_since_connecting_is_refused_unsent() {
let server = resting_server().await;
let client = client_against(&server, HashMap::from([("ETH".to_owned(), 1)])).await;
let order = place(
&client,
&InstrumentNameExchange::from("ETH-USDC-PERP"),
OrderKind::Limit,
Some(dec!(3000)),
dec!(1),
)
.await;
let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
crate::error::ApiError::InstrumentInvalid(_, reason),
))) = &order.state
else {
panic!("expected InstrumentInvalid, got {:?}", order.state);
};
assert!(reason.contains("reconnect"), "{reason}");
assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
}
}
mod cancel {
use super::super::common::info_tests::{CANCEL_NOT_APPLIED, cancel_answer};
use super::*;
use crate::error::ApiError;
use crate::order::{OrderEvent, request::RequestCancel};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn cancel_answered(
answer: serde_json::Value,
) -> (UnindexedOrderResponseCancel, bool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/exchange"))
.respond_with(ResponseTemplate::new(200).set_body_json(answer))
.mount(&server)
.await;
let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
let instrument = InstrumentNameExchange::new("BTC-USDC-PERP");
let key = OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: instrument.clone(),
strategy: StrategyId::new("strategy"),
cid: ClientOrderId::uuid(),
};
let id = VenueOrderId::Assigned(OrderId::new("7"));
let open = Open {
id: id.clone(),
time_exchange: Utc::now(),
filled_quantity: Decimal::ZERO,
};
client
.known_live
.lock()
.live(&key, dec!(1), OrderKind::Limit, &open);
let response = client
.cancel_order(OrderEvent {
key: OrderKey {
exchange: key.exchange,
instrument: &instrument,
strategy: key.strategy.clone(),
cid: key.cid.clone(),
},
state: RequestCancel { id: Some(id) },
})
.await;
let live = client.known_live.lock().contains(&key.cid);
(response, live)
}
#[tokio::test]
async fn a_success_status_cancels_the_order() {
let (response, live) =
cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
assert!(response.state.is_ok(), "{:?}", response.state);
assert!(!live, "a cancelled order is no longer held live");
}
#[tokio::test]
async fn an_error_status_is_a_rejected_cancel_and_the_order_stays_live() {
let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
{"error": CANCEL_NOT_APPLIED}
])))
.await;
let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
&response.state
else {
panic!("expected a rejected cancel, got {:?}", response.state);
};
assert_eq!(reason, CANCEL_NOT_APPLIED);
assert!(live, "the order is still held live");
}
#[tokio::test]
async fn an_answer_without_a_status_is_a_rejected_cancel() {
let (response, live) = cancel_answered(serde_json::json!({
"status": "ok",
"response": {"type": "cancel"},
}))
.await;
assert!(
matches!(
response.state,
Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
),
"{:?}",
response.state
);
assert!(live, "the order is still held live");
}
}
mod instrument_names {
use super::*;
use crate::error::ApiError;
use crate::order::{
OrderEvent,
request::{RequestCancel, RequestOpen},
};
use wiremock::MockServer;
async fn offline_client() -> (MockServer, HyperliquidClient) {
let server = MockServer::start().await;
let client = client_against(&server, HashMap::new()).await;
(server, client)
}
const REFUSED: [&str; 4] = [
"BTC-USD-PERP",
"flx:TSLA-USDC-PERP",
"km:TSLA-USDH-PERP",
"HYPE-USDC-SPOT",
];
fn is_invalid(
error: &ApiError<AssetNameExchange, InstrumentNameExchange>,
name: &str,
) -> bool {
matches!(error, ApiError::InstrumentInvalid(instrument, _) if instrument.as_ref() == name)
}
#[tokio::test]
async fn an_order_on_a_name_the_client_does_not_trade_is_refused_unsent() {
let (_server, client) = offline_client().await;
for name in REFUSED {
let instrument = InstrumentNameExchange::from(name);
let order = client
.open_order(OrderEvent {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: StrategyId::new("strategy"),
cid: ClientOrderId::uuid(),
},
state: RequestOpen::new(
Side::Buy,
Some(dec!(1)),
dec!(1),
OrderKind::Limit,
TimeInForce::GoodUntilCancelled { post_only: false },
),
})
.await;
let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
OrderError::Rejected(error),
)) = &order.state
else {
panic!("{name}: expected a rejection, got {:?}", order.state);
};
assert!(is_invalid(error, name), "{name}: {error:?}");
}
}
#[tokio::test]
async fn a_cancel_on_a_name_the_client_does_not_trade_is_refused_unsent() {
let (_server, client) = offline_client().await;
for name in REFUSED {
let instrument = InstrumentNameExchange::from(name);
let response = client
.cancel_order(OrderEvent {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: &instrument,
strategy: StrategyId::new("strategy"),
cid: ClientOrderId::uuid(),
},
state: RequestCancel {
id: Some(VenueOrderId::Assigned(OrderId::new("1"))),
},
})
.await;
let Err(UnindexedOrderError::Rejected(error)) = &response.state else {
panic!("{name}: expected a rejection, got {:?}", response.state);
};
assert!(is_invalid(error, name), "{name}: {error:?}");
}
}
#[tokio::test]
async fn a_read_filtered_to_a_name_the_client_does_not_trade_fails_unsent() {
let (_server, client) = offline_client().await;
for name in REFUSED {
let instruments = [InstrumentNameExchange::from(name)];
let snapshot = client.account_snapshot(&[], &instruments).await;
let open = client.fetch_open_orders(&instruments).await;
let trades = client
.fetch_trades(
Utc::now() - chrono::Duration::hours(1),
Utc::now(),
&instruments,
)
.await;
for error in [snapshot.err(), open.err(), trades.err()] {
let Some(UnindexedClientError::Api(error)) = &error else {
panic!("{name}: expected an API error, got {error:?}");
};
assert!(is_invalid(error, name), "{name}: {error:?}");
}
}
}
}
fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
serde_json::from_value(serde_json::json!({
"coin": "BTC",
"entryPx": "50000.0",
"leverage": {"type": "cross", "value": 5},
"liquidationPx": null,
"marginUsed": "100.0",
"positionValue": "500.0",
"returnOnEquity": "0.0",
"szi": szi,
"unrealizedPnl": "1.5",
"maxLeverage": 50,
"cumFunding": {"allTime": "3.5", "sinceOpen": "1.25", "sinceChange": "0"}
}))
.unwrap()
}
#[test]
fn perp_position_report_is_open_flat_or_unreported() {
let now = Utc::now();
let open = perp_position_report(&perp_position("-0.01"), now);
let position = open.open().unwrap();
assert_eq!(
(
position.quantity,
position.entry_price,
position.leverage,
position.carry
),
(
dec!(-0.01),
Some(dec!(50000.0)),
Some(dec!(5)),
Some(dec!(-1.25))
)
);
assert_eq!(
perp_position_report(&perp_position("0.0"), now),
PositionReport::Flat
);
assert_eq!(
perp_position_report(&perp_position("not a number"), now),
PositionReport::Unreported
);
}
#[test]
fn test_fill_to_account_event() {
let fill_json = r#"{
"coin": "BTC",
"side": "B",
"px": "65000.5",
"sz": "0.1",
"time": 1714100000000,
"hash": "0xabc123",
"startPosition": "0",
"dir": "Open Long",
"closedPnl": "0",
"oid": 12345,
"cloid": null,
"crossed": false,
"fee": "0.65",
"feeToken": "USDC",
"tid": 99999
}"#;
let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
let event = fill_to_account_event(&fill, &default_dex()).unwrap();
assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
match event.kind {
AccountEventKind::Trade(trade) => {
assert_eq!(trade.instrument.as_ref(), "BTC-USDC-PERP");
assert_eq!(
trade.id.0, "99999",
"the id is the fill's tid, not its hash"
);
assert_eq!(trade.side, Side::Buy);
assert_eq!(trade.price, dec!(65000.5));
assert_eq!(trade.quantity, dec!(0.1));
assert_eq!(trade.fees.fees, dec!(0.65));
assert_eq!(trade.fees.asset.as_ref(), "USDC");
assert_eq!(trade.fees.fees_quote, Some(dec!(0.65)));
}
_ => panic!("Expected Trade event"),
}
}
#[tokio::test]
async fn a_fill_carries_the_client_order_id_of_its_cloid() {
let fill_json = r#"{
"coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
"time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
"dir": "Open Long", "closedPnl": "0", "oid": 7,
"cloid": "0x0123456789abcdef0123456789abcdef",
"crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
}"#;
let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
let dexes = perp_dexes::tests::xyz_and_flx().await;
let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
else {
panic!("Expected Trade event");
};
assert_eq!(
trade.client_order_id,
cloid_to_cid("0x0123456789abcdef0123456789abcdef")
);
assert!(trade.client_order_id.is_some());
}
#[tokio::test]
async fn a_builder_deployed_fill_is_named_and_charged_in_its_dex_collateral() {
let fill_json = r#"{
"coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
"time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
"dir": "Open Long", "closedPnl": "0", "oid": 7, "cloid": null,
"crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
}"#;
let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
let dexes = perp_dexes::tests::xyz_and_flx().await;
let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
else {
panic!("Expected Trade event");
};
assert_eq!(trade.instrument.as_ref(), "flx:TSLA-USDH-PERP");
assert_eq!(trade.client_order_id, None, "placed without a cloid");
assert_eq!(trade.fees.asset.as_ref(), "USDH");
assert_eq!(trade.fees.fees, dec!(0.2));
assert_eq!(
trade.fees.fees_quote,
Some(dec!(0.2)),
"the fee is in the instrument's quote"
);
assert!(fill_to_account_event(&fill, &default_dex()).is_none());
}
#[test]
fn perp_fill_fees_reads_the_fee_token_and_falls_back_to_the_collateral() {
assert_eq!(
perp_fill_fees(Some("USDT0"), dec!(1.5), "USDC"),
AssetFees::new(AssetNameExchange::from("USDT0"), dec!(1.5), None)
);
assert_eq!(
perp_fill_fees(Some("USDC"), dec!(1.5), "USDC"),
AssetFees::new(AssetNameExchange::from("USDC"), dec!(1.5), Some(dec!(1.5)))
);
assert_eq!(
perp_fill_fees(None, dec!(1.5), "USDH"),
AssetFees::new(AssetNameExchange::from("USDH"), dec!(1.5), Some(dec!(1.5))),
"an absent feeToken is the DEX's collateral"
);
}
#[test]
fn test_fill_to_account_event_sell() {
let fill_json = r#"{
"coin": "ETH",
"side": "A",
"px": "3200",
"sz": "1.5",
"time": 1714100000000,
"hash": "0xdef456",
"startPosition": "1.5",
"dir": "Close Long",
"closedPnl": "150.0",
"oid": 12346,
"cloid": null,
"crossed": true,
"fee": "4.8",
"feeToken": "USDC",
"tid": 100000
}"#;
let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
let event = fill_to_account_event(&fill, &default_dex()).unwrap();
match event.kind {
AccountEventKind::Trade(trade) => {
assert_eq!(trade.instrument.as_ref(), "ETH-USDC-PERP");
assert_eq!(trade.side, Side::Sell);
assert_eq!(trade.price, dec!(3200));
assert_eq!(trade.quantity, dec!(1.5));
}
_ => panic!("Expected Trade event"),
}
}
fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
let json = format!(
r#"{{
"coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
"time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
"dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
"crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
}}"#
);
serde_json::from_str(&json).unwrap()
}
#[test]
fn a_trade_id_identifies_the_fill_not_the_transaction() {
let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
(first.kind, second.kind)
else {
panic!("Expected Trade events");
};
assert_eq!(first.order_id, second.order_id, "one order produced both");
assert_ne!(first.id, second.id, "but they are distinct fills");
assert_eq!(first.id.0, "111");
assert_eq!(second.id.0, "222");
}
#[test]
fn a_replayed_fill_is_delivered_once() {
let cache = new_dedup_cache();
let event = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
let replay = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
}
#[test]
fn both_fills_of_one_sweep_survive_dedup() {
let cache = new_dedup_cache();
let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
assert!(!is_duplicate(
&cache,
dedup_key_from_event(&second).unwrap()
));
}
#[test]
fn one_tid_on_two_instruments_is_two_fills() {
let cache = new_dedup_cache();
let btc = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
let eth_json = r#"{
"coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
"time": 1714100000000, "hash": "0xother", "startPosition": "0",
"dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
"crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
}"#;
let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
let eth = fill_to_account_event(ð, &default_dex()).unwrap();
assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
assert!(!is_duplicate(&cache, dedup_key_from_event(ð).unwrap()));
}
#[test]
fn test_order_update_to_account_event_open() {
let update_json = r#"{
"order": {
"coin": "BTC",
"side": "B",
"limitPx": "64000",
"sz": "0.5",
"oid": 12345,
"timestamp": 1714100000000,
"origSz": "0.5",
"cloid": null
},
"status": "open",
"statusTimestamp": 1714100000000
}"#;
let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
let event = order_update_to_account_event(&update, &default_dex()).unwrap();
assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.key.instrument.as_ref(), "BTC-USDC-PERP");
assert_eq!(order.side, Side::Buy);
assert_eq!(order.price, Some(dec!(64000)));
assert_eq!(order.quantity, dec!(0.5));
assert!(matches!(
order.state,
crate::order::state::OrderState::Active(_)
));
}
_ => panic!("Expected OrderSnapshot event"),
}
}
#[test]
fn test_order_update_to_account_event_filled() {
let update_json = r#"{
"order": {
"coin": "ETH",
"side": "A",
"limitPx": "3250",
"sz": "0",
"oid": 12346,
"timestamp": 1714100000000,
"origSz": "2.0",
"cloid": null
},
"status": "filled",
"statusTimestamp": 1714100001000
}"#;
let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
let event = order_update_to_account_event(&update, &default_dex()).unwrap();
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.side, Side::Sell);
assert!(matches!(
order.state,
crate::order::state::OrderState::Inactive(
crate::order::state::InactiveOrderState::FullyFilled(_)
)
));
}
_ => panic!("Expected OrderSnapshot event"),
}
}
#[test]
fn test_order_update_to_account_event_cancelled() {
let update_json = r#"{
"order": {
"coin": "SOL",
"side": "B",
"limitPx": "150",
"sz": "10",
"oid": 12347,
"timestamp": 1714100000000,
"origSz": "10",
"cloid": null
},
"status": "canceled",
"statusTimestamp": 1714100002000
}"#;
let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
let event = order_update_to_account_event(&update, &default_dex()).unwrap();
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.key.instrument.as_ref(), "SOL-USDC-PERP");
assert!(matches!(
order.state,
crate::order::state::OrderState::Inactive(
crate::order::state::InactiveOrderState::Cancelled(_)
)
));
}
_ => panic!("Expected OrderSnapshot event"),
}
}
#[test]
fn test_order_update_unknown_status_returns_none() {
let update_json = r#"{
"order": {
"coin": "BTC",
"side": "B",
"limitPx": "64000",
"sz": "0.5",
"oid": 12345,
"timestamp": 1714100000000,
"origSz": "0.5",
"cloid": null
},
"status": "unknown_status",
"statusTimestamp": 1714100000000
}"#;
let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
let event = order_update_to_account_event(&update, &default_dex());
assert!(event.is_none());
}
}