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_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 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 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 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 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}