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 the mid-price of `symbol` (e.g. "BTC") from allMids.
171    async fn get_price(&self, symbol: String) -> Result<Decimal, String> {
172        let resp = self.client
173            .post(HYPERLIQUID_INFO_URL)
174            .json(&serde_json::json!({"type": "allMids"}))
175            .send()
176            .await
177            .map_err(|e| e.to_string())?;
178        resp.json::<HashMap<String, String>>()
179            .await
180            .map_err(|e| e.to_string())?
181            .get(&symbol)
182            .and_then(|s| parse_decimal(s))
183            .ok_or_else(|| format!("symbol {} not found", symbol))
184    }
185}
186
187#[allow(unused_variables)]
188#[allow(async_fn_in_trait)]
189impl guilder_abstraction::ManageOrder for HyperliquidClient {
190    async fn place_order(&self, symbol: String, side: OrderSide, price: Decimal, volume: Decimal, order_type: OrderType, time_in_force: TimeInForce) -> Result<OrderPlacement, String> {
191        unimplemented!()
192    }
193
194    async fn change_order_by_cloid(&self, cloid: i64, price: Decimal, volume: Decimal) -> Result<i64, String> {
195        unimplemented!()
196    }
197
198    async fn cancel_order(&self, cloid: i64) -> Result<i64, String> {
199        unimplemented!()
200    }
201
202    async fn cancel_all_order(&self) -> Result<bool, String> {
203        unimplemented!()
204    }
205}
206
207#[allow(async_fn_in_trait)]
208impl guilder_abstraction::SubscribeMarketData for HyperliquidClient {
209    /// Streams L2 orderbook updates for `symbol`. Each message from Hyperliquid is a
210    /// full-depth snapshot; every level is emitted as an individual `L2Update` event.
211    /// All levels in the same snapshot share the same `sequence` value.
212    fn subscribe_l2_update(&self, symbol: String) -> BoxStream<L2Update> {
213        Box::pin(async_stream::stream! {
214            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
215            let sub = serde_json::json!({
216                "method": "subscribe",
217                "subscription": {"type": "l2Book", "coin": symbol}
218            });
219            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
220
221            while let Some(Ok(Message::Text(text))) = ws.next().await {
222                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
223                if env.channel != "l2Book" { continue; }
224                let Ok(book) = serde_json::from_value::<WsBook>(env.data) else { continue; };
225
226                for level in book.levels.first().into_iter().flatten() {
227                    if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
228                        yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Ask, sequence: book.time };
229                    }
230                }
231                for level in book.levels.get(1).into_iter().flatten() {
232                    if let (Some(price), Some(volume)) = (parse_decimal(&level.px), parse_decimal(&level.sz)) {
233                        yield L2Update { symbol: book.coin.clone(), price, volume, side: Side::Bid, sequence: book.time };
234                    }
235                }
236            }
237        })
238    }
239
240    /// Streams asset context updates for `symbol` via Hyperliquid's `activeAssetCtx` subscription.
241    /// Each message carries OI, funding rate, mark price, and 24h notional volume.
242    fn subscribe_asset_context(&self, symbol: String) -> BoxStream<AssetContext> {
243        Box::pin(async_stream::stream! {
244            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
245            let sub = serde_json::json!({
246                "method": "subscribe",
247                "subscription": {"type": "activeAssetCtx", "coin": symbol}
248            });
249            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
250
251            while let Some(Ok(Message::Text(text))) = ws.next().await {
252                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
253                if env.channel != "activeAssetCtx" { continue; }
254                let Ok(update) = serde_json::from_value::<WsAssetCtx>(env.data) else { continue; };
255                let ctx = &update.ctx;
256                if let (Some(open_interest), Some(funding_rate), Some(mark_price), Some(day_volume)) = (
257                    parse_decimal(&ctx.open_interest),
258                    parse_decimal(&ctx.funding),
259                    parse_decimal(&ctx.mark_px),
260                    parse_decimal(&ctx.day_ntl_vlm),
261                ) {
262                    yield AssetContext { symbol: update.coin, open_interest, funding_rate, mark_price, day_volume };
263                }
264            }
265        })
266    }
267
268    /// Streams liquidation events for a user address via Hyperliquid's `userEvents` subscription.
269    /// Note: Hyperliquid's liquidation event is account-level; `symbol` is set to empty string
270    /// and `side` defaults to `OrderSide::Sell` as the data source does not provide per-position detail.
271    fn subscribe_liquidation(&self, user: String) -> BoxStream<Liquidation> {
272        Box::pin(async_stream::stream! {
273            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
274            let sub = serde_json::json!({
275                "method": "subscribe",
276                "subscription": {"type": "userEvents", "user": user}
277            });
278            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
279
280            while let Some(Ok(Message::Text(text))) = ws.next().await {
281                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
282                if env.channel != "userEvents" { continue; }
283                let Ok(event) = serde_json::from_value::<WsUserEvent>(env.data) else { continue; };
284                let Some(liq) = event.liquidation else { continue; };
285                if let (Some(notional_position), Some(account_value)) = (
286                    parse_decimal(&liq.liquidated_ntl_pos),
287                    parse_decimal(&liq.liquidated_account_value),
288                ) {
289                    yield Liquidation {
290                        symbol: String::new(),
291                        side: OrderSide::Sell,
292                        liquidated_user: liq.liquidated_user,
293                        notional_position,
294                        account_value,
295                    };
296                }
297            }
298        })
299    }
300
301    /// Streams public trade fills for `symbol`. Maps to Hyperliquid's `trades` subscription.
302    /// `side` reflects the aggressor: "B" (buyer) → `OrderSide::Buy`, otherwise → `OrderSide::Sell`.
303    fn subscribe_fill(&self, symbol: String) -> BoxStream<Fill> {
304        Box::pin(async_stream::stream! {
305            let Ok((mut ws, _)) = connect_async(HYPERLIQUID_WS_URL).await else { return; };
306            let sub = serde_json::json!({
307                "method": "subscribe",
308                "subscription": {"type": "trades", "coin": symbol}
309            });
310            if ws.send(Message::Text(sub.to_string().into())).await.is_err() { return; }
311
312            while let Some(Ok(Message::Text(text))) = ws.next().await {
313                let Ok(env) = serde_json::from_str::<WsEnvelope>(&text) else { continue; };
314                if env.channel != "trades" { continue; }
315                let Ok(trades) = serde_json::from_value::<Vec<WsTrade>>(env.data) else { continue; };
316
317                for trade in trades {
318                    let side = if trade.side == "B" { OrderSide::Buy } else { OrderSide::Sell };
319                    if let (Some(price), Some(volume)) = (parse_decimal(&trade.px), parse_decimal(&trade.sz)) {
320                        yield Fill { symbol: trade.coin, price, volume, side, timestamp_ms: trade.time, trade_id: trade.tid };
321                    }
322                }
323            }
324        })
325    }
326}
327
328#[allow(unused_variables)]
329#[allow(async_fn_in_trait)]
330impl guilder_abstraction::GetAccountSnapshot for HyperliquidClient {
331    async fn get_positions(&self) -> Result<Vec<Position>, String> {
332        unimplemented!()
333    }
334
335    async fn get_open_orders(&self) -> Result<Vec<OpenOrder>, String> {
336        unimplemented!()
337    }
338
339    async fn get_collateral(&self) -> Result<Decimal, String> {
340        unimplemented!()
341    }
342}
343
344#[allow(unused_variables)]
345#[allow(async_fn_in_trait)]
346impl guilder_abstraction::SubscribeUserEvents for HyperliquidClient {
347    fn subscribe_user_fills(&self) -> BoxStream<UserFill> {
348        Box::pin(stream::pending())
349    }
350
351    fn subscribe_order_updates(&self) -> BoxStream<OrderUpdate> {
352        Box::pin(stream::pending())
353    }
354
355    fn subscribe_funding_payments(&self) -> BoxStream<FundingPayment> {
356        Box::pin(stream::pending())
357    }
358
359    fn subscribe_deposits(&self) -> BoxStream<Deposit> {
360        Box::pin(stream::pending())
361    }
362
363    fn subscribe_withdrawals(&self) -> BoxStream<Withdrawal> {
364        Box::pin(stream::pending())
365    }
366}