Skip to main content

guilder_client_hyperliquid/
client.rs

1use guilder_abstraction::{self, L2Update, Fill, AssetContext, Liquidation, BoxStream, Side, OrderSide, OrderType, TimeInForce, OrderPlacement, Position, OpenOrder, UserFill, OrderUpdate, FundingPayment, Deposit, Withdrawal};
2use futures_util::{stream, SinkExt, StreamExt};
3use reqwest::Client;
4use rust_decimal::Decimal;
5use serde::Deserialize;
6use serde_json::Value;
7use std::collections::HashMap;
8use std::str::FromStr;
9use tokio_tungstenite::{connect_async, tungstenite::Message};
10
11const HYPERLIQUID_INFO_URL: &str = "https://api.hyperliquid.xyz/info";
12const HYPERLIQUID_WS_URL: &str = "wss://api.hyperliquid.xyz/ws";
13
14pub struct HyperliquidClient {
15    client: Client,
16}
17
18impl HyperliquidClient {
19    pub fn new() -> Self {
20        HyperliquidClient { client: Client::new() }
21    }
22}
23
24// --- Deserialization types for Hyperliquid REST responses ---
25
26#[derive(Deserialize)]
27struct MetaResponse {
28    universe: Vec<AssetInfo>,
29}
30
31#[derive(Deserialize)]
32struct AssetInfo {
33    name: String,
34}
35
36/// Response from metaAndAssetCtxs: [meta, [ctx, ...]]
37type MetaAndAssetCtxsResponse = (MetaResponse, Vec<RestAssetCtx>);
38
39#[derive(Deserialize)]
40#[serde(rename_all = "camelCase")]
41#[allow(dead_code)]
42struct RestAssetCtx {
43    open_interest: String,
44    funding: String,
45    mark_px: String,
46    day_ntl_vlm: String,
47}
48
49// --- WebSocket envelope and data shapes ---
50
51#[derive(Deserialize)]
52struct WsEnvelope {
53    channel: String,
54    data: Value,
55}
56
57#[derive(Deserialize)]
58struct WsBook {
59    coin: String,
60    levels: Vec<Vec<WsLevel>>,
61    time: i64,
62}
63
64#[derive(Deserialize)]
65struct WsLevel {
66    px: String,
67    sz: String,
68}
69
70#[derive(Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct WsAssetCtx {
73    coin: String,
74    ctx: WsPerpsCtx,
75}
76
77#[derive(Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct WsPerpsCtx {
80    open_interest: String,
81    funding: String,
82    mark_px: String,
83    day_ntl_vlm: String,
84}
85
86#[derive(Deserialize)]
87struct WsUserEvent {
88    liquidation: Option<WsLiquidation>,
89}
90
91#[derive(Deserialize)]
92struct WsLiquidation {
93    liquidated_user: String,
94    liquidated_ntl_pos: String,
95    liquidated_account_value: String,
96}
97
98#[derive(Deserialize)]
99struct WsTrade {
100    coin: String,
101    side: String,
102    px: String,
103    sz: String,
104    time: i64,
105    tid: i64,
106}
107
108fn parse_decimal(s: &str) -> Option<Decimal> {
109    Decimal::from_str(s).ok()
110}
111
112// --- Trait implementations ---
113
114#[allow(async_fn_in_trait)]
115impl guilder_abstraction::TestServer for HyperliquidClient {
116    /// Sends a lightweight allMids request; returns true if the server responds 200 OK.
117    async fn ping(&self) -> Result<bool, String> {
118        self.client
119            .post(HYPERLIQUID_INFO_URL)
120            .json(&serde_json::json!({"type": "allMids"}))
121            .send()
122            .await
123            .map(|r| r.status().is_success())
124            .map_err(|e| e.to_string())
125    }
126
127    /// Hyperliquid has no dedicated server-time endpoint; returns local UTC ms.
128    async fn get_server_time(&self) -> Result<i64, String> {
129        Ok(std::time::SystemTime::now()
130            .duration_since(std::time::UNIX_EPOCH)
131            .map(|d| d.as_millis() as i64)
132            .unwrap_or(0))
133    }
134}
135
136#[allow(async_fn_in_trait)]
137impl guilder_abstraction::GetMarketData for HyperliquidClient {
138    /// Returns all perpetual asset names from Hyperliquid's meta endpoint.
139    async fn get_symbol(&self) -> Result<Vec<String>, String> {
140        let resp = self.client
141            .post(HYPERLIQUID_INFO_URL)
142            .json(&serde_json::json!({"type": "meta"}))
143            .send()
144            .await
145            .map_err(|e| e.to_string())?;
146        resp.json::<MetaResponse>()
147            .await
148            .map(|r| r.universe.into_iter().map(|a| a.name).collect())
149            .map_err(|e| e.to_string())
150    }
151
152    /// Returns the current open interest for `symbol` from metaAndAssetCtxs.
153    async fn get_open_interest(&self, symbol: String) -> Result<Decimal, String> {
154        let resp = self.client
155            .post(HYPERLIQUID_INFO_URL)
156            .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
157            .send()
158            .await
159            .map_err(|e| e.to_string())?;
160        let (meta, ctxs) = resp.json::<MetaAndAssetCtxsResponse>()
161            .await
162            .map_err(|e| e.to_string())?;
163        meta.universe.iter()
164            .position(|a| a.name == symbol)
165            .and_then(|i| ctxs.get(i))
166            .and_then(|ctx| parse_decimal(&ctx.open_interest))
167            .ok_or_else(|| format!("symbol {} not found", symbol))
168    }
169
170    /// Returns a full AssetContext snapshot for `symbol` from metaAndAssetCtxs.
171    async fn get_asset_context(&self, symbol: String) -> Result<AssetContext, String> {
172        let resp = self.client
173            .post(HYPERLIQUID_INFO_URL)
174            .json(&serde_json::json!({"type": "metaAndAssetCtxs"}))
175            .send()
176            .await
177            .map_err(|e| e.to_string())?;
178        let (meta, ctxs) = resp.json::<MetaAndAssetCtxsResponse>()
179            .await
180            .map_err(|e| e.to_string())?;
181        let idx = meta.universe.iter()
182            .position(|a| a.name == symbol)
183            .ok_or_else(|| format!("symbol {} not found", symbol))?;
184        let ctx = ctxs.get(idx).ok_or_else(|| format!("symbol {} not found", symbol))?;
185        Ok(AssetContext {
186            symbol,
187            open_interest: parse_decimal(&ctx.open_interest).ok_or("invalid open_interest")?,
188            funding_rate: parse_decimal(&ctx.funding).ok_or("invalid funding")?,
189            mark_price: parse_decimal(&ctx.mark_px).ok_or("invalid mark_px")?,
190            day_volume: parse_decimal(&ctx.day_ntl_vlm).ok_or("invalid day_ntl_vlm")?,
191        })
192    }
193
194    /// Returns the mid-price of `symbol` (e.g. "BTC") from allMids.
195    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
196        let resp = self.client
197            .post(HYPERLIQUID_INFO_URL)
198            .json(&serde_json::json!({"type": "allMids"}))
199            .send()
200            .await
201            .map_err(|e| e.to_string())?;
202        resp.json::<HashMap<String, String>>()
203            .await
204            .map_err(|e| e.to_string())?
205            .get(&symbol)
206            .and_then(|s| parse_decimal(s))
207            .ok_or_else(|| format!("symbol {} not found", symbol))
208    }
209}
210
211#[allow(unused_variables)]
212#[allow(async_fn_in_trait)]
213impl guilder_abstraction::ManageOrder for HyperliquidClient {
214    async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
215        unimplemented!()
216    }
217
218    async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
219        unimplemented!()
220    }
221
222    async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
223        unimplemented!()
224    }
225
226    async fn cancel_all_order(&self) -> Result<bool, String> {
227        unimplemented!()
228    }
229}
230
231#[allow(async_fn_in_trait)]
232impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
233    /// Streams L2 orderbook updates for `symbol`. Each message from Hyperliquid is a
234    /// full-depth snapshot; every level is emitted as an individual `L2Update` event.
235    /// All levels in the same snapshot share the same `sequence` value.
236    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<L2Update> {
237        Box::pin(async_stream::stream! {
238            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
239            let sub = serde_json::json!({
240                "method": "subscribe",
241                "subscription": {"type": "l2Book", "coin": symbol}
242            });
243            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
244
245            while let Some(Ok(Message::Text(text))) = ws.next().await {
246                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
247                if env.channel != "l2Book" { continue; }
248                let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { continue; };
249
250                for level in book.levels.first().into_iter().flatten() {
251                    if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
252                        yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time };
253                    }
254                }
255                for level in book.levels.get(1).into_iter().flatten() {
256                    if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
257                        yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time };
258                    }
259                }
260            }
261        })
262    }
263
264    /// Streams asset context updates for `symbol` via Hyperliquid's `activeAssetCtx` subscription.
265    /// Each message carries OI, funding rate, mark price, and 24h notional volume.
266    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<AssetContext> {
267        Box::pin(async_stream::stream! {
268            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
269            let sub = serde_json::json!({
270                "method": "subscribe",
271                "subscription": {"type": "activeAssetCtx", "coin": symbol}
272            });
273            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
274
275            while let Some(Ok(Message::Text(text))) = ws.next().await {
276                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
277                if env.channel != "activeAssetCtx" { continue; }
278                let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { continue; };
279                let ctx = &update.ctx;
280                if let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
281                    parse_decimal(&ctx.open_interest),
282                    parse_decimal(&ctx.funding),
283                    parse_decimal(&ctx.mark_px),
284                    parse_decimal(&ctx.day_ntl_vlm),
285                ) {
286                    yield AssetContext { symbol: update.coin, open_interest, funding_rate, mark_price, day_volume };
287                }
288            }
289        })
290    }
291
292    /// Streams liquidation events for a user address via Hyperliquid's `userEvents` subscription.
293    /// Note: Hyperliquid's liquidation event is account-level; `symbol` is set to empty string
294    /// and `side` defaults to `OrderSide::Sell` as the data source does not provide per-position detail.
295    fn subscribe_liquidation(&self, user: String) -> BoxStream<Liquidation> {
296        Box::pin(async_stream::stream! {
297            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
298            let sub = serde_json::json!({
299                "method": "subscribe",
300                "subscription": {"type": "userEvents", "user": user}
301            });
302            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
303
304            while let Some(Ok(Message::Text(text))) = ws.next().await {
305                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
306                if env.channel != "userEvents" { continue; }
307                let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
308                let Some(liq) = event.liquidation else { continue; };
309                if let (Some(notional_position), Some(account_value)) = (
310                    parse_decimal(&liq.liquidated_ntl_pos),
311                    parse_decimal(&liq.liquidated_account_value),
312                ) {
313                    yield Liquidation {
314                        symbol: String::new(),
315                        side: OrderSide::Sell,
316                        liquidated_user: liq.liquidated_user,
317                        notional_position,
318                        account_value,
319                    };
320                }
321            }
322        })
323    }
324
325    /// Streams public trade fills for `symbol`. Maps to Hyperliquid's `trades` subscription.
326    /// `side` reflects the aggressor: "B" (buyer) → `OrderSide::Buy`, otherwise → `OrderSide::Sell`.
327    fn subscribe_fill(&self, symbol: String) -> BoxStream<Fill> {
328        Box::pin(async_stream::stream! {
329            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
330            let sub = serde_json::json!({
331                "method": "subscribe",
332                "subscription": {"type": "trades", "coin": symbol}
333            });
334            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
335
336            while let Some(Ok(Message::Text(text))) = ws.next().await {
337                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
338                if env.channel != "trades" { continue; }
339                let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { continue; };
340
341                for trade in trades {
342                    let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
343                    if let (Some(price), Some(volume)) = (parse_decimal(&trade.px), parse_decimal(&trade.sz)) {
344                        yield Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid };
345                    }
346                }
347            }
348        })
349    }
350}
351
352#[allow(unused_variables)]
353#[allow(async_fn_in_trait)]
354impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
355    async fn get_positions(&self) -> Result<Vec<Position>, String> {
356        unimplemented!()
357    }
358
359    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
360        unimplemented!()
361    }
362
363    async fn get_collateral(&self) -> Result<Decimal, String> {
364        unimplemented!()
365    }
366}
367
368#[allow(unused_variables)]
369#[allow(async_fn_in_trait)]
370impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
371    fn subscribe_user_fills(&self) -> BoxStream<UserFill> {
372        Box::pin(stream::pending())
373    }
374
375    fn subscribe_order_updates(&self) -> BoxStream<OrderUpdate> {
376        Box::pin(stream::pending())
377    }
378
379    fn subscribe_funding_payments(&self) -> BoxStream<FundingPayment> {
380        Box::pin(stream::pending())
381    }
382
383    fn subscribe_deposits(&self) -> BoxStream<Deposit> {
384        Box::pin(stream::pending())
385    }
386
387    fn subscribe_withdrawals(&self) -> BoxStream<Withdrawal> {
388        Box::pin(stream::pending())
389    }
390}