use super::common::{
CancelOnDropStream, cid_to_cloid, instrument_to_spot_coin, is_spot_coin, map_tif,
millis_to_datetime, parse_decimal, parse_side, round_to_5_sig_figs, spot_coin_to_instrument,
};
use super::config::HyperliquidConfig;
use super::error::{map_order_error, map_sdk_error};
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},
},
trade::{AssetFees, Trade, TradeId},
};
use chrono::{DateTime, Utc};
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};
#[derive(Debug, Clone)]
pub struct HyperliquidSpotClient {
config: HyperliquidConfig,
info_client: Arc<InfoClient>,
exchange_client: Arc<ExchangeClient>,
}
impl HyperliquidSpotClient {
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 HyperliquidSpotClient"
);
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 HyperliquidSpotClient {
const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
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 HyperliquidSpotClient"
);
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 (token_balances, open_orders) = tokio::try_join!(
async {
self.info_client
.user_token_balances(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 balances = parse_token_balances(&token_balances.balances, 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::new();
for order in &open_orders {
if !is_spot_coin(&order.coin) {
continue;
}
let instrument = spot_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::HyperliquidSpot,
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 instrument_snapshots: Vec<_> = orders_by_instrument
.into_iter()
.map(|(instrument, orders)| InstrumentAccountSnapshot {
instrument,
orders,
position: None, })
.collect();
Ok(AccountSnapshot {
exchange: ExchangeId::HyperliquidSpot,
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 spot 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!("Spot fills task cancelled");
return;
}
msg = fills_rx.recv() => {
let Some(msg) = msg else {
debug!("Spot fills receiver closed");
return;
};
match msg {
Message::UserFills(fills) => {
for fill in fills.data.fills {
if !is_spot_coin(&fill.coin) {
continue;
}
if let Some(event) = fill_to_account_event(&fill)
&& fills_event_tx.send(event).is_err()
{
debug!("Spot fills event channel closed");
return;
}
}
}
Message::NoData => {
warn!("Spot UserFills WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "Spot UserFills WebSocket error");
let _ = fills_event_tx.send(AccountEvent::new(
ExchangeId::HyperliquidSpot,
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!("Spot orders task cancelled");
return;
}
msg = orders_rx.recv() => {
let Some(msg) = msg else {
debug!("Spot orders receiver closed");
return;
};
match msg {
Message::OrderUpdates(updates) => {
for update in updates.data {
if !is_spot_coin(&update.order.coin) {
continue;
}
if let Some(event) = order_update_to_account_event(&update)
&& orders_event_tx.send(event).is_err()
{
debug!("Spot orders event channel closed");
return;
}
}
}
Message::NoData => {
warn!("Spot OrderUpdates WebSocket disconnected");
}
Message::HyperliquidError(e) => {
error!(%e, "Spot OrderUpdates WebSocket error");
let _ = orders_event_tx.send(AccountEvent::new(
ExchangeId::HyperliquidSpot,
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 = match instrument_to_spot_coin(request.key.instrument) {
Some(c) => c,
None => {
warn!(
instrument = %request.key.instrument,
"Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
);
return Some(OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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 instrument format: {}",
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::HyperliquidSpot,
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::HyperliquidSpot,
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, "Spot cancel order failed (transport)");
return Some(OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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!("Spot cancel order accepted");
Some(OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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, "Spot cancel rejected by exchange");
Some(OrderResponseCancel {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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 = match instrument_to_spot_coin(request.key.instrument) {
Some(c) => c,
None => {
warn!(
instrument = %request.key.instrument,
"Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
);
return Some(Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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(OrderError::Rejected(
crate::error::ApiError::OrderRejected(format!(
"Invalid instrument format: {}",
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);
let notional = request.state.price * request.state.quantity;
if notional < Decimal::TEN {
warn!(
instrument = %request.key.instrument,
%notional,
"Spot order below $10 minimum notional value"
);
return Some(Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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(OrderError::Rejected(
crate::error::ApiError::OrderRejected(format!(
"Spot order notional ${notional} below $10 minimum"
)),
)),
});
}
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, "Spot open order failed");
return Some(Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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, "Spot 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, "Spot 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, "Spot 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!("Spot 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, "Spot order rejected");
OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
msg,
)))
}
};
Some(Order {
key: OrderKey {
exchange: ExchangeId::HyperliquidSpot,
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 token_balances = self
.info_client
.user_token_balances(address)
.await
.map_err(map_sdk_error)?;
let now = Utc::now();
Ok(parse_token_balances(&token_balances.balances, 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 {
if !is_spot_coin(&order.coin) {
continue;
}
let instrument = spot_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::HyperliquidSpot,
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 Some((base_asset, quote_asset)) = fill.coin.split_once('/') else {
continue;
};
let instrument = spot_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;
};
let fee_asset = if matches!(side, Side::Buy) {
base_asset
} else {
quote_asset
};
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(fee_asset),
fees: fee,
fees_quote: if fee_asset == quote_asset {
Some(fee)
} else {
None
},
},
});
}
Ok(result)
}
}
fn parse_token_balances(
balances: &[hyperliquid_rust_sdk::UserTokenBalance],
now: DateTime<Utc>,
) -> Vec<AssetBalance<AssetNameExchange>> {
balances
.iter()
.map(|bal| {
let total = parse_decimal(&bal.total, "total").unwrap_or(Decimal::ZERO);
let hold = parse_decimal(&bal.hold, "hold").unwrap_or(Decimal::ZERO);
let free = (total - hold).max(Decimal::ZERO);
AssetBalance::new(
AssetNameExchange::from(bal.coin.as_str()),
Balance::new(total, free),
now,
)
})
.collect()
}
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 (_base, quote_asset) = fill.coin.split_once('/').unwrap_or(("", "USDC"));
let instrument = spot_coin_to_instrument(&fill.coin);
let order_id = OrderId(format_smolstr!("{}", fill.oid));
let fee_asset = fill.fee_token.as_str();
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(fee_asset),
fees: fee,
fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
Some(fee)
} else {
None
},
},
};
Some(AccountEvent::new(
ExchangeId::HyperliquidSpot,
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 = spot_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::HyperliquidSpot,
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::HyperliquidSpot,
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_spot() {
let fill_json = r#"{
"coin": "PURR/USDC",
"side": "B",
"px": "0.05",
"sz": "1000",
"time": 1714100000000,
"hash": "0xspot123",
"startPosition": "0",
"dir": "Open Long",
"closedPnl": "0",
"oid": 12345,
"cloid": null,
"crossed": false,
"fee": "0.025",
"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::HyperliquidSpot);
match event.kind {
AccountEventKind::Trade(trade) => {
assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
assert_eq!(trade.side, Side::Buy);
assert_eq!(trade.price, dec!(0.05));
assert_eq!(trade.quantity, dec!(1000));
assert_eq!(trade.fees.fees, dec!(0.025));
assert_eq!(trade.fees.asset.as_ref(), "USDC");
}
_ => panic!("Expected Trade event"),
}
}
#[test]
fn test_order_update_to_account_event_spot() {
let update_json = r#"{
"order": {
"coin": "HYPE/USDC",
"side": "A",
"limitPx": "25.5",
"sz": "10",
"oid": 12346,
"timestamp": 1714100000000,
"origSz": "10",
"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::HyperliquidSpot);
match event.kind {
AccountEventKind::OrderSnapshot(Snapshot(order)) => {
assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
assert_eq!(order.side, Side::Sell);
assert_eq!(order.price, dec!(25.5));
assert_eq!(order.quantity, dec!(10));
assert!(matches!(
order.state,
crate::order::state::OrderState::Active(_)
));
}
_ => panic!("Expected OrderSnapshot event"),
}
}
}