pub mod common;
pub mod config;
pub mod error;
pub mod spot;
use crate::{
AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
UnindexedAccountEvent, UnindexedAccountSnapshot,
balance::{AssetBalance, Balance},
client::ExecutionClient,
error::{ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
order::{
Order, OrderKey, OrderKind, TimeInForce,
id::{ClientOrderId, OrderId, StrategyId},
request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
},
position::Position,
trade::{AssetFees, Trade, TradeId},
};
use chrono::{DateTime, Utc};
use common::{
CancelOnDropStream, cid_to_cloid, instrument_to_perp_coin, map_tif, millis_to_datetime,
parse_decimal, parse_side, perp_coin_to_instrument, round_to_5_sig_figs,
};
use config::HyperliquidConfig;
use error::{map_order_error, map_sdk_error};
use ethers::signers::Signer;
use futures::{StreamExt, stream::BoxStream};
use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, asset::name::AssetNameExchange, exchange::ExchangeId,
instrument::name::InstrumentNameExchange,
};
use rustrade_integration::collection::snapshot::Snapshot;
use smol_str::{SmolStr, format_smolstr};
use std::{collections::HashSet, sync::Arc};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
const USDC_ASSET: &str = "USDC";
#[derive(Debug, Clone)]
pub struct HyperliquidClient {
config: HyperliquidConfig,
info_client: Arc<InfoClient>,
exchange_client: Arc<ExchangeClient>,
}
impl HyperliquidClient {
pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
let base_url = if config.testnet {
BaseUrl::Testnet
} else {
BaseUrl::Mainnet
};
let info_client = InfoClient::new(None, Some(base_url))
.await
.map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
let wallet = config.wallet.clone();
let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
.await
.map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
info!(
testnet = config.testnet,
wallet = %config.wallet_address_hex(),
"Created HyperliquidClient"
);
Ok(Self {
config,
info_client: Arc::new(info_client),
exchange_client: Arc::new(exchange_client),
})
}
fn base_url(&self) -> BaseUrl {
if self.config.testnet {
BaseUrl::Testnet
} else {
BaseUrl::Mainnet
}
}
pub fn wallet_address(&self) -> String {
self.config.wallet_address_hex()
}
fn wallet_h160(&self) -> ethers::types::H160 {
self.config.wallet.address()
}
}
impl ExecutionClient for HyperliquidClient {
const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
type Config = HyperliquidConfig;
type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
fn new(config: Self::Config) -> Self {
let base_url = if config.testnet {
BaseUrl::Testnet
} else {
BaseUrl::Mainnet
};
let handle = tokio::runtime::Handle::current();
let info_client = handle.block_on(async {
InfoClient::new(None, Some(base_url))
.await
.unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
});
let wallet = config.wallet.clone();
let exchange_client = handle.block_on(async {
ExchangeClient::new(None, wallet, Some(base_url), None, None)
.await
.unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
});
info!(
testnet = config.testnet,
wallet = %config.wallet_address_hex(),
"Created HyperliquidClient"
);
Self {
config,
info_client: Arc::new(info_client),
exchange_client: Arc::new(exchange_client),
}
}
async fn account_snapshot(
&self,
_assets: &[AssetNameExchange],
instruments: &[InstrumentNameExchange],
) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
let address = self.wallet_h160();
let (user_state, open_orders) = tokio::try_join!(
async {
self.info_client
.user_state(address)
.await
.map_err(map_sdk_error)
},
async {
self.info_client
.open_orders(address)
.await
.map_err(map_sdk_error)
}
)?;
let now = Utc::now();
let account_value =
parse_decimal(&user_state.margin_summary.account_value, "account_value")
.unwrap_or(Decimal::ZERO);
let margin_used = parse_decimal(
&user_state.margin_summary.total_margin_used,
"total_margin_used",
)
.unwrap_or(Decimal::ZERO);
let free_balance = (account_value - margin_used).max(Decimal::ZERO);
let balances = vec![AssetBalance::new(
AssetNameExchange::from(USDC_ASSET),
Balance::new(account_value, free_balance),
now,
)];
let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
let mut set = HashSet::with_capacity(instruments.len());
set.extend(instruments.iter().cloned());
Some(set)
};
let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
std::collections::HashMap::with_capacity(open_orders.len());
for order in &open_orders {
let instrument = perp_coin_to_instrument(&order.coin);
if instrument_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
let Some(side) = parse_side(&order.side) else {
continue;
};
let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
continue;
};
let Some(quantity) = parse_decimal(&order.sz, "sz") else {
continue;
};
let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
warn!(
oid = order.oid,
timestamp = order.timestamp,
"Invalid order timestamp, skipping"
);
continue;
};
let order_id = format_smolstr!("{}", order.oid);
let order_snapshot = Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument: instrument.clone(),
strategy: StrategyId::unknown(),
cid: ClientOrderId::new(order_id.clone()),
},
side,
price,
quantity,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: crate::order::state::OrderState::active(Open {
id: OrderId(order_id),
time_exchange,
filled_quantity: Decimal::ZERO,
}),
};
orders_by_instrument
.entry(instrument)
.or_default()
.push(order_snapshot);
}
let mut instrument_snapshots = Vec::new();
for asset_pos in user_state.asset_positions {
let pos = &asset_pos.position;
let instrument = perp_coin_to_instrument(&pos.coin);
if instrument_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
let quantity = parse_decimal(&pos.szi, "szi").unwrap_or(Decimal::ZERO);
let entry_price = pos
.entry_px
.as_ref()
.and_then(|p| parse_decimal(p, "entry_px"));
let unrealized_pnl = parse_decimal(&pos.unrealized_pnl, "unrealized_pnl");
let margin_used = parse_decimal(&pos.margin_used, "margin_used");
let liquidation_price = pos
.liquidation_px
.as_ref()
.and_then(|p| parse_decimal(p, "liquidation_px"));
let leverage = Some(Decimal::from(pos.leverage.value));
let position = if quantity.is_zero() {
None
} else {
Some(Position::new(
quantity,
entry_price,
unrealized_pnl,
margin_used,
liquidation_price,
leverage,
now,
))
};
let orders = orders_by_instrument.remove(&instrument).unwrap_or_default();
instrument_snapshots.push(InstrumentAccountSnapshot {
instrument,
orders,
position,
});
}
for (instrument, orders) in orders_by_instrument {
instrument_snapshots.push(InstrumentAccountSnapshot {
instrument,
orders,
position: None,
});
}
Ok(AccountSnapshot {
exchange: ExchangeId::HyperliquidPerp,
balances,
instruments: instrument_snapshots,
})
}
async fn account_stream(
&self,
_assets: &[AssetNameExchange],
_instruments: &[InstrumentNameExchange],
) -> Result<Self::AccountStream, UnindexedClientError> {
let user = self.wallet_h160();
let base_url = self.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>();
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}")))?;
info!(%user, "Subscribed to Hyperliquid account stream");
let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
let cancel_token = CancellationToken::new();
let fills_event_tx = event_tx.clone();
let fills_cancel = cancel_token.clone();
tokio::spawn(async move {
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");
return;
};
match msg {
Message::UserFills(fills) => {
for fill in fills.data.fills {
if let Some(event) = fill_to_account_event(&fill)
&& fills_event_tx.send(event).is_err()
{
debug!("Fills event channel closed");
return;
}
}
}
Message::NoData => {
warn!("UserFills WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "UserFills WebSocket error");
let _ = fills_event_tx.send(AccountEvent::new(
ExchangeId::HyperliquidPerp,
AccountEventKind::StreamError(e),
));
}
_ => {}
}
}
}
}
});
let orders_event_tx = event_tx;
let orders_cancel = cancel_token.clone();
tokio::spawn(async move {
let _ws_client = ws_client;
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");
return;
};
match msg {
Message::OrderUpdates(updates) => {
for update in updates.data {
if let Some(event) = order_update_to_account_event(&update)
&& orders_event_tx.send(event).is_err()
{
debug!("Orders event channel closed");
return;
}
}
}
Message::NoData => {
warn!("OrderUpdates WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "OrderUpdates WebSocket error");
let _ = orders_event_tx.send(AccountEvent::new(
ExchangeId::HyperliquidPerp,
AccountEventKind::StreamError(e),
));
}
_ => {}
}
}
}
}
});
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>,
) -> Option<UnindexedOrderResponseCancel> {
use crate::order::{request::OrderResponseCancel, state::Cancelled};
use hyperliquid_rust_sdk::ClientCancelRequest;
let coin = instrument_to_perp_coin(request.key.instrument);
let order_id = match &request.state.id {
Some(id) => id,
None => {
warn!("Cancel request missing order ID");
return Some(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("Missing order ID".to_string()),
)),
});
}
};
let oid: u64 = match order_id.0.parse() {
Ok(id) => id,
Err(e) => {
warn!(?order_id, %e, "Failed to parse order ID as u64");
return Some(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(format!("Invalid order ID: {e}")),
)),
});
}
};
let cancel_request = ClientCancelRequest { asset: coin, oid };
use hyperliquid_rust_sdk::ExchangeResponseStatus;
let response = match self.exchange_client.cancel(cancel_request, None).await {
Ok(r) => r,
Err(e) => {
warn!(%e, "Cancel order failed (transport)");
return Some(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 response {
ExchangeResponseStatus::Ok(_) => {
debug!("Cancel order accepted");
Some(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(
order_id.clone(),
Utc::now(),
Decimal::ZERO, )),
})
}
ExchangeResponseStatus::Err(msg) => {
warn!(%msg, "Cancel rejected by exchange");
Some(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(msg),
)),
})
}
}
}
async fn open_order(
&self,
request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
use hyperliquid_rust_sdk::{
ClientLimit, ClientOrder, ClientOrderRequest, ExchangeDataStatus,
ExchangeResponseStatus,
};
let coin = instrument_to_perp_coin(request.key.instrument);
let is_buy = request.state.side == Side::Buy;
let limit_px = round_to_5_sig_figs(request.state.price);
let sz = round_to_5_sig_figs(request.state.quantity);
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 tif = map_tif(&request.state.time_in_force).to_string();
let cloid = cid_to_cloid(&request.key.cid);
let order_request = ClientOrderRequest {
asset: coin,
is_buy,
reduce_only: request.state.reduce_only,
limit_px,
sz,
cloid,
order_type: ClientOrder::Limit(ClientLimit { tif }),
};
let response = match self.exchange_client.order(order_request, None).await {
Ok(r) => r,
Err(e) => {
warn!(%e, "Open order failed");
return Some(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: 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(
crate::error::ApiError::OrderRejected(msg),
))
}
Some(ExchangeDataStatus::WaitingForFill)
| Some(ExchangeDataStatus::WaitingForTrigger) => {
warn!("Trigger/conditional orders not supported");
OrderState::inactive(OrderError::Rejected(
crate::error::ApiError::OrderRejected(
"trigger/conditional orders not supported".to_string(),
),
))
}
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(crate::error::ApiError::OrderRejected(
msg,
)))
}
};
Some(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,
})
}
async fn fetch_balances(
&self,
_assets: &[AssetNameExchange],
) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
let address = self.wallet_h160();
let user_state = self
.info_client
.user_state(address)
.await
.map_err(map_sdk_error)?;
let now = Utc::now();
let account_value =
parse_decimal(&user_state.margin_summary.account_value, "account_value")
.unwrap_or(Decimal::ZERO);
let margin_used = parse_decimal(
&user_state.margin_summary.total_margin_used,
"total_margin_used",
)
.unwrap_or(Decimal::ZERO);
let free_balance = (account_value - margin_used).max(Decimal::ZERO);
Ok(vec![AssetBalance::new(
AssetNameExchange::from(USDC_ASSET),
Balance::new(account_value, free_balance),
now,
)])
}
async fn fetch_open_orders(
&self,
instruments: &[InstrumentNameExchange],
) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
let address = self.wallet_h160();
let open_orders = self
.info_client
.open_orders(address)
.await
.map_err(map_sdk_error)?;
let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
let mut set = HashSet::with_capacity(instruments.len());
set.extend(instruments.iter().cloned());
Some(set)
};
let mut result = Vec::new();
for order in open_orders {
let instrument = perp_coin_to_instrument(&order.coin);
if instrument_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
let Some(side) = parse_side(&order.side) else {
continue;
};
let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
continue;
};
let Some(quantity) = parse_decimal(&order.sz, "sz") else {
continue;
};
let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
warn!(
oid = order.oid,
timestamp = order.timestamp,
"Invalid order timestamp, skipping"
);
continue;
};
let order_id = format_smolstr!("{}", order.oid);
result.push(Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument,
strategy: StrategyId::unknown(),
cid: ClientOrderId::new(order_id.clone()),
},
side,
price,
quantity,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: Open {
id: OrderId(order_id),
time_exchange,
filled_quantity: Decimal::ZERO,
},
});
}
Ok(result)
}
async fn fetch_trades(
&self,
time_since: DateTime<Utc>,
instruments: &[InstrumentNameExchange],
) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
let address = self.wallet_h160();
let fills = self
.info_client
.user_fills(address)
.await
.map_err(map_sdk_error)?;
#[allow(clippy::cast_sign_loss)] let time_since_ms = time_since.timestamp_millis().max(0) as u64;
let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
let mut set = HashSet::with_capacity(instruments.len());
set.extend(instruments.iter().cloned());
Some(set)
};
let mut result = Vec::new();
for fill in fills {
if fill.time < time_since_ms {
continue;
}
let instrument = perp_coin_to_instrument(&fill.coin);
if instrument_filter
.as_ref()
.is_some_and(|f| !f.contains(&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;
};
result.push(Trade {
id: TradeId(SmolStr::new(&fill.hash)),
order_id: OrderId(format_smolstr!("{}", fill.oid)),
instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
fees: AssetFees {
asset: AssetNameExchange::from("USDC"),
fees: fee,
fees_quote: Some(fee),
},
});
}
Ok(result)
}
}
fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
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 instrument = perp_coin_to_instrument(&fill.coin);
let order_id = OrderId(format_smolstr!("{}", fill.oid));
let trade = Trade {
id: TradeId(SmolStr::new(&fill.hash)),
order_id,
instrument,
strategy: StrategyId::unknown(),
time_exchange,
side,
price,
quantity,
fees: AssetFees {
asset: AssetNameExchange::from("USDC"),
fees: fee,
fees_quote: Some(fee),
},
};
Some(AccountEvent::new(
ExchangeId::HyperliquidPerp,
AccountEventKind::Trade(trade),
))
}
fn order_update_to_account_event(
update: &hyperliquid_rust_sdk::OrderUpdate,
) -> Option<UnindexedAccountEvent> {
let order = &update.order;
let side = parse_side(&order.side)?;
let price = parse_decimal(&order.limit_px, "order.limit_px")?;
let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
let time_exchange = millis_to_datetime(update.status_timestamp)?;
let instrument = perp_coin_to_instrument(&order.coin);
let order_id_smol = format_smolstr!("{}", order.oid);
let cid = order
.cloid
.as_deref()
.map(|c| ClientOrderId::new(SmolStr::new(c)))
.unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
let state = match update.status.as_str() {
"open" | "resting" => {
let current_sz = parse_decimal(&order.sz, "order.sz")?;
let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
crate::order::state::OrderState::active(Open {
id: OrderId(order_id_smol),
time_exchange,
filled_quantity,
})
}
"filled" => crate::order::state::OrderState::fully_filled(Filled::new(
OrderId(order_id_smol),
time_exchange,
orig_sz, None, )),
"canceled" | "cancelled" => {
let current_sz = parse_decimal(&order.sz, "order.sz")?;
let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
crate::order::state::OrderState::inactive(Cancelled::new(
OrderId(order_id_smol),
time_exchange,
filled_quantity,
))
}
status => {
warn!(%status, "Unknown order status");
return None;
}
};
let order_snapshot = Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidPerp,
instrument,
strategy: StrategyId::unknown(),
cid,
},
side,
price,
quantity: orig_sz,
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state,
};
Some(AccountEvent::new(
ExchangeId::HyperliquidPerp,
AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[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).unwrap();
assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
match event.kind {
AccountEventKind::Trade(trade) => {
assert_eq!(trade.instrument.as_ref(), "BTC-USD-PERP");
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));
}
_ => panic!("Expected Trade event"),
}
}
#[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).unwrap();
match event.kind {
AccountEventKind::Trade(trade) => {
assert_eq!(trade.instrument.as_ref(), "ETH-USD-PERP");
assert_eq!(trade.side, Side::Sell);
assert_eq!(trade.price, dec!(3200));
assert_eq!(trade.quantity, dec!(1.5));
}
_ => panic!("Expected Trade event"),
}
}
#[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).unwrap();
assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.key.instrument.as_ref(), "BTC-USD-PERP");
assert_eq!(order.side, Side::Buy);
assert_eq!(order.price, 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).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).unwrap();
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.key.instrument.as_ref(), "SOL-USD-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);
assert!(event.is_none());
}
}