use crate::ws::parse_decimal;
use guilder_abstraction::{
AssetContext, Balance, Deposit, Fill, FundingPayment, L2Level, L2Snapshot, Liquidation,
OrderSide, OrderStatus, OrderUpdate, UserFill, Withdrawal,
};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsBook {
pub(crate) coin: String,
pub(crate) levels: Vec<Vec<HyperliquidWsLevel>>,
pub(crate) time: i64,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsLevel {
pub(crate) px: String,
pub(crate) sz: String,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HyperliquidWsAssetCtx {
pub(crate) coin: String,
pub(crate) ctx: HyperliquidWsPerpsCtx,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HyperliquidWsPerpsCtx {
pub(crate) open_interest: String,
pub(crate) funding: String,
pub(crate) mark_px: String,
pub(crate) day_ntl_vlm: String,
pub(crate) mid_px: Option<String>,
pub(crate) oracle_px: Option<String>,
pub(crate) premium: Option<String>,
pub(crate) prev_day_px: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsUserEvent {
pub(crate) liquidation: Option<HyperliquidWsLiquidation>,
pub(crate) fills: Option<Vec<HyperliquidWsUserFill>>,
pub(crate) funding: Option<HyperliquidWsFunding>,
pub(crate) spot_state: Option<HyperliquidWsSpotState>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsSpotState {
pub(crate) balances: Option<Vec<HyperliquidWsSpotBalance>>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsSpotBalance {
pub(crate) coin: String,
pub(crate) total: String,
pub(crate) hold: String,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsLiquidation {
pub(crate) liquidated_user: String,
pub(crate) liquidated_ntl_pos: String,
pub(crate) liquidated_account_value: String,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsUserFill {
pub(crate) coin: String,
pub(crate) px: String,
pub(crate) sz: String,
pub(crate) side: String,
pub(crate) time: i64,
pub(crate) oid: i64,
pub(crate) fee: String,
#[serde(default)]
pub(crate) cloid: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsFunding {
pub(crate) time: i64,
pub(crate) coin: String,
pub(crate) usdc: String,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsTrade {
pub(crate) coin: String,
pub(crate) side: String,
pub(crate) px: String,
pub(crate) sz: String,
pub(crate) time: i64,
pub(crate) tid: i64,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsOrderUpdate {
pub(crate) order: HyperliquidWsOrderInfo,
pub(crate) status: String,
#[serde(rename = "statusTimestamp")]
pub(crate) status_timestamp: i64,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HyperliquidWsOrderInfo {
pub(crate) coin: String,
pub(crate) side: String,
pub(crate) limit_px: String,
pub(crate) sz: String,
pub(crate) oid: i64,
pub(crate) orig_sz: String,
#[serde(default)]
pub(crate) cloid: Option<String>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsLedgerUpdates {
#[serde(default)]
pub(crate) updates: Vec<HyperliquidWsLedgerEntry>,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsLedgerEntry {
pub(crate) time: i64,
pub(crate) delta: HyperliquidWsLedgerDelta,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsLedgerDelta {
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) usdc: Option<String>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct HyperliquidWsEnvelope {
pub(crate) channel: String,
#[serde(default)]
pub(crate) data: Value,
}
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct HyperliquidWsSubscriptionResponse {
#[serde(default)]
pub(crate) method: Option<String>,
#[serde(default)]
pub(crate) subscription: Value,
#[serde(default)]
pub(crate) success: Option<bool>,
}
#[derive(Clone, Debug)]
pub(crate) enum HyperliquidWsInboundMessage {
Pong,
L2Book(HyperliquidWsBook),
ActiveAssetCtx(HyperliquidWsAssetCtx),
Trades(Vec<HyperliquidWsTrade>),
User(HyperliquidWsUserEvent),
OrderUpdates(Vec<HyperliquidWsOrderUpdate>),
NonFundingLedger(HyperliquidWsLedgerUpdates),
SubscriptionResponse(HyperliquidWsSubscriptionResponse),
Unknown { channel: String, data: Value },
}
impl TryFrom<HyperliquidWsEnvelope> for HyperliquidWsInboundMessage {
type Error = serde_json::Error;
fn try_from(env: HyperliquidWsEnvelope) -> Result<Self, Self::Error> {
match env.channel.as_str() {
"pong" => Ok(Self::Pong),
"l2Book" => serde_json::from_value(env.data).map(Self::L2Book),
"activeAssetCtx" => serde_json::from_value(env.data).map(Self::ActiveAssetCtx),
"trades" => serde_json::from_value(env.data).map(Self::Trades),
"user" | "userEvents" => serde_json::from_value(env.data).map(Self::User),
"orderUpdates" => serde_json::from_value(env.data).map(Self::OrderUpdates),
"userNonFundingLedgerUpdates" => {
serde_json::from_value(env.data).map(Self::NonFundingLedger)
}
"subscriptionResponse" => serde_json::from_value(env.data).map(Self::SubscriptionResponse),
_ => Ok(Self::Unknown {
channel: env.channel,
data: env.data,
}),
}
}
}
impl HyperliquidWsInboundMessage {
pub fn as_l2_snapshot(&self) -> Option<L2Snapshot> {
let HyperliquidWsInboundMessage::L2Book(book) = self else {
return None;
};
let bids = book
.levels
.first()
.into_iter()
.flatten()
.filter_map(|level| {
Some(L2Level {
price: parse_decimal(&level.px)?,
volume: parse_decimal(&level.sz)?,
})
})
.collect();
let asks = book
.levels
.get(1)
.into_iter()
.flatten()
.filter_map(|level| {
Some(L2Level {
price: parse_decimal(&level.px)?,
volume: parse_decimal(&level.sz)?,
})
})
.collect();
Some(L2Snapshot {
symbol: book.coin.clone(),
bids,
asks,
sequence: book.time,
})
}
pub fn as_asset_context(&self) -> Option<AssetContext> {
let HyperliquidWsInboundMessage::ActiveAssetCtx(update) = self else {
return None;
};
let ctx = &update.ctx;
let open_interest = parse_decimal(&ctx.open_interest)?;
let funding_rate = parse_decimal(&ctx.funding)?;
let mark_price = parse_decimal(&ctx.mark_px)?;
let day_volume = parse_decimal(&ctx.day_ntl_vlm)?;
Some(AssetContext {
symbol: update.coin.clone(),
open_interest,
funding_rate,
mark_price,
day_volume,
mid_price: ctx.mid_px.as_deref().and_then(parse_decimal),
oracle_price: ctx.oracle_px.as_deref().and_then(parse_decimal),
premium: ctx.premium.as_deref().and_then(parse_decimal),
prev_day_price: ctx.prev_day_px.as_deref().and_then(parse_decimal),
sz_decimals: 0,
})
}
pub fn as_trades(&self) -> Option<Vec<Fill>> {
let HyperliquidWsInboundMessage::Trades(trades) = self else {
return None;
};
let result: Vec<_> = trades
.iter()
.filter_map(|t| {
let side = if t.side == "B" {
OrderSide::Buy
} else {
OrderSide::Sell
};
let price = parse_decimal(&t.px)?;
let volume = parse_decimal(&t.sz)?;
Some(Fill {
symbol: t.coin.clone(),
price,
volume,
side,
timestamp_ms: t.time,
trade_id: t.tid,
})
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn as_liquidation(&self) -> Option<Liquidation> {
let HyperliquidWsInboundMessage::User(event) = self else {
return None;
};
let liq = event.liquidation.as_ref()?;
let notional_position = parse_decimal(&liq.liquidated_ntl_pos)?;
let account_value = parse_decimal(&liq.liquidated_account_value)?;
Some(Liquidation {
symbol: String::new(),
side: OrderSide::Sell,
liquidated_user: liq.liquidated_user.clone(),
notional_position,
account_value,
})
}
pub fn as_user_fills(&self) -> Option<Vec<UserFill>> {
let HyperliquidWsInboundMessage::User(event) = self else {
return None;
};
let fills = event.fills.as_ref()?;
let result: Vec<_> = fills
.iter()
.filter_map(|f| {
let side = if f.side == "B" {
OrderSide::Buy
} else {
OrderSide::Sell
};
let price = parse_decimal(&f.px)?;
let quantity = parse_decimal(&f.sz)?;
let fee_usd = parse_decimal(&f.fee)?;
Some(UserFill {
order_id: f.oid,
symbol: f.coin.clone(),
side,
price,
quantity,
fee_usd,
timestamp_ms: f.time,
cloid: f.cloid.clone(),
})
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn as_funding_payment(&self) -> Option<FundingPayment> {
let HyperliquidWsInboundMessage::User(event) = self else {
return None;
};
let funding = event.funding.as_ref()?;
let amount_usd = parse_decimal(&funding.usdc)?;
Some(FundingPayment {
symbol: funding.coin.clone(),
amount_usd,
timestamp_ms: funding.time,
})
}
pub fn as_spot_balance(&self) -> Option<Vec<Balance>> {
let HyperliquidWsInboundMessage::User(event) = self else {
return None;
};
let spot_state = event.spot_state.as_ref()?;
let balances = spot_state.balances.as_ref()?;
let result: Vec<_> = balances
.iter()
.filter_map(|b| {
let total = parse_decimal(&b.total)?;
let locked = parse_decimal(&b.hold)?;
let available = total - locked;
Some(Balance {
coin: b.coin.clone(),
total,
available,
locked,
})
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn as_order_updates(&self) -> Option<Vec<OrderUpdate>> {
let HyperliquidWsInboundMessage::OrderUpdates(updates) = self else {
return None;
};
let result: Vec<_> = updates
.iter()
.map(|u| {
let status = match u.status.as_str() {
"open" => OrderStatus::Placed,
"filled" => OrderStatus::Filled,
"canceled" | "cancelled" => OrderStatus::Cancelled,
_ => OrderStatus::PartiallyFilled,
};
let side = if u.order.side == "B" {
OrderSide::Buy
} else {
OrderSide::Sell
};
OrderUpdate {
order_id: u.order.oid,
symbol: u.order.coin.clone(),
status,
side: Some(side),
price: parse_decimal(&u.order.limit_px),
quantity: parse_decimal(&u.order.orig_sz),
remaining_quantity: parse_decimal(&u.order.sz),
timestamp_ms: u.status_timestamp,
cloid: u.order.cloid.clone(),
}
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn as_deposits(&self) -> Option<Vec<Deposit>> {
let HyperliquidWsInboundMessage::NonFundingLedger(ledger) = self else {
return None;
};
let result: Vec<_> = ledger
.updates
.iter()
.filter_map(|e| {
if e.delta.kind != "deposit" {
return None;
}
let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
Some(Deposit {
asset: "USDC".to_string(),
amount_usd,
timestamp_ms: e.time,
})
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn as_withdrawals(&self) -> Option<Vec<Withdrawal>> {
let HyperliquidWsInboundMessage::NonFundingLedger(ledger) = self else {
return None;
};
let result: Vec<_> = ledger
.updates
.iter()
.filter_map(|e| {
if e.delta.kind != "withdraw" {
return None;
}
let amount_usd = e.delta.usdc.as_deref().and_then(parse_decimal)?;
Some(Withdrawal {
asset: "USDC".to_string(),
amount_usd,
timestamp_ms: e.time,
})
})
.collect();
if result.is_empty() {
None
} else {
Some(result)
}
}
pub fn channel_name(&self) -> &str {
match self {
HyperliquidWsInboundMessage::Pong => "pong",
HyperliquidWsInboundMessage::L2Book(_) => "l2Book",
HyperliquidWsInboundMessage::ActiveAssetCtx(_) => "activeAssetCtx",
HyperliquidWsInboundMessage::Trades(_) => "trades",
HyperliquidWsInboundMessage::User(_) => "user",
HyperliquidWsInboundMessage::OrderUpdates(_) => "orderUpdates",
HyperliquidWsInboundMessage::NonFundingLedger(_) => "userNonFundingLedgerUpdates",
HyperliquidWsInboundMessage::SubscriptionResponse(_) => "subscriptionResponse",
HyperliquidWsInboundMessage::Unknown { channel, .. } => channel,
}
}
pub fn routing_key(&self) -> Option<String> {
match self {
HyperliquidWsInboundMessage::L2Book(book) => Some(book.coin.clone()),
HyperliquidWsInboundMessage::ActiveAssetCtx(ctx) => Some(ctx.coin.clone()),
HyperliquidWsInboundMessage::Trades(trades) => trades.first().map(|t| t.coin.clone()),
_ => None,
}
}
}