guilder_client_hyperliquid/
client.rs1use 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#[derive(Deserialize)]
27struct MetaResponse {
28 universe: Vec<AssetInfo>,
29}
30
31#[derive(Deserialize)]
32struct AssetInfo {
33 name: String,
34}
35
36type 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#[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#[allow(async_fn_in_trait)]
115impl guilder_abstraction::TestServer for HyperliquidClient {
116 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 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 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 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 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 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 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 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 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 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}