This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Repository navigation
Add websocket models and parsing for Hyperliquid #907
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
8b8bd5c
Add websocket models and parsing for Hyperliquid
DRadmir 7d064f8
Add candle interval support and WebSocket candle handling
DRadmir bf273ba
Fix GemHyperliquidOpenOrder type alias and UniFFI binding
DRadmir f706ce2
Merge branch 'main' of github.com:gemwalletcom/core into hyperliquid-…
DRadmir 5e56c83
Merge branch 'main' of github.com:gemwalletcom/core into hyperliquid-…
DRadmir 5779ed9
Add support for AllMids WebSocket channel
DRadmir 91a56b5
Refactor position diff logic to provider module
DRadmir 5c85f33
Merge branch 'main' into hyperliquid-socket
DRadmir 5e7601c
Refactor WebSocketMessage parsing logic
DRadmir a542006
Merge branch 'main' of github.com:gemwalletcom/core into hyperliquid-…
DRadmir d38746a
Merge branch 'main' of github.com:gemwalletcom/core into hyperliquid-…
DRadmir 11d8d1f
Refactor websocket message parsing and add tests
DRadmir a827369
Merge branch 'main' of github.com:gemwalletcom/core into hyperliquid-…
DRadmir File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,5 +11,6 @@ pub mod spot; | |
| pub mod timestamp; | ||
| pub mod token; | ||
| pub mod user; | ||
| pub mod websocket; | ||
|
|
||
| pub type UInt64 = u64; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| use std::collections::HashMap; | ||
|
|
||
| use primitives::PerpetualPosition; | ||
| use primitives::chart::ChartCandleStick; | ||
| use primitives::perpetual::PerpetualBalance; | ||
| use serde::Deserialize; | ||
| use serde::de::DeserializeOwned; | ||
|
|
||
| use super::order::OpenOrder; | ||
| use super::position::AssetPositions; | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub enum WebSocketChannel { | ||
| ClearinghouseState, | ||
| OpenOrders, | ||
| Candle, | ||
| AllMids, | ||
| SubscriptionResponse, | ||
| #[serde(other)] | ||
| Unknown, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct AllMidsData { | ||
| pub mids: HashMap<String, String>, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct WebSocketMessage<T> { | ||
| pub channel: WebSocketChannel, | ||
| pub data: T, | ||
| } | ||
|
|
||
| impl<T: DeserializeOwned> WebSocketMessage<T> { | ||
| pub fn parse(json: &str) -> Result<Self, serde_json::Error> { | ||
| serde_json::from_str(json) | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub struct ClearinghouseStateData { | ||
| pub clearinghouse_state: AssetPositions, | ||
| pub user: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct OpenOrdersData { | ||
| pub user: String, | ||
| pub orders: Vec<OpenOrder>, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct SubscriptionResponseData { | ||
| pub subscription: Subscription, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct Subscription { | ||
| #[serde(rename = "type")] | ||
| pub subscription_type: String, | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct PositionsDiff { | ||
| pub delete_position_ids: Vec<String>, | ||
| pub positions: Vec<PerpetualPosition>, | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub enum HyperliquidSocketMessage { | ||
| ClearinghouseState { balance: PerpetualBalance, positions: Vec<PerpetualPosition> }, | ||
| OpenOrders { orders: Vec<OpenOrder> }, | ||
| Candle { candle: ChartCandleStick }, | ||
| AllMids { prices: HashMap<String, f64> }, | ||
| SubscriptionResponse { subscription_type: String }, | ||
| Unknown, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| use std::collections::{HashMap, HashSet}; | ||
|
|
||
| use primitives::{AssetId, PerpetualPosition}; | ||
|
|
||
| use crate::models::{ | ||
| candlestick::Candlestick, | ||
| order::OpenOrder, | ||
| websocket::{AllMidsData, ClearinghouseStateData, HyperliquidSocketMessage, OpenOrdersData, PositionsDiff, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, | ||
| }; | ||
|
|
||
| use super::perpetual_mapper::{map_positions, map_tp_sl_from_orders}; | ||
|
|
||
| pub fn parse_websocket_data(json: &str) -> Result<HyperliquidSocketMessage, serde_json::Error> { | ||
| let channel = WebSocketMessage::<serde_json::Value>::parse(json)?.channel; | ||
|
|
||
| match channel { | ||
| WebSocketChannel::ClearinghouseState => { | ||
| let data = WebSocketMessage::<ClearinghouseStateData>::parse(json)?.data; | ||
| let summary = map_positions(data.clearinghouse_state, data.user, &[]); | ||
| Ok(HyperliquidSocketMessage::ClearinghouseState { | ||
| balance: summary.balance, | ||
| positions: summary.positions, | ||
| }) | ||
| } | ||
| WebSocketChannel::OpenOrders => { | ||
| let data = WebSocketMessage::<OpenOrdersData>::parse(json)?.data; | ||
| Ok(HyperliquidSocketMessage::OpenOrders { orders: data.orders }) | ||
| } | ||
| WebSocketChannel::Candle => { | ||
| let candle = WebSocketMessage::<Candlestick>::parse(json)?.data.into(); | ||
| Ok(HyperliquidSocketMessage::Candle { candle }) | ||
| } | ||
| WebSocketChannel::AllMids => { | ||
| let data = WebSocketMessage::<AllMidsData>::parse(json)?.data; | ||
| let prices = data.mids.into_iter().filter_map(|(k, v)| v.parse::<f64>().ok().map(|p| (k, p))).collect(); | ||
| Ok(HyperliquidSocketMessage::AllMids { prices }) | ||
| } | ||
| WebSocketChannel::SubscriptionResponse => { | ||
| let data = WebSocketMessage::<SubscriptionResponseData>::parse(json)?.data; | ||
| Ok(HyperliquidSocketMessage::SubscriptionResponse { | ||
| subscription_type: data.subscription.subscription_type, | ||
| }) | ||
| } | ||
| WebSocketChannel::Unknown => Ok(HyperliquidSocketMessage::Unknown), | ||
| } | ||
| } | ||
|
|
||
| pub fn diff_clearinghouse_positions(new_positions: Vec<PerpetualPosition>, existing_positions: Vec<PerpetualPosition>) -> PositionsDiff { | ||
| let existing_map: HashMap<&str, &PerpetualPosition> = existing_positions.iter().map(|p| (p.id.as_str(), p)).collect(); | ||
|
|
||
| let positions: Vec<PerpetualPosition> = new_positions | ||
| .into_iter() | ||
| .map(|pos| match existing_map.get(pos.id.as_str()) { | ||
| Some(existing) => PerpetualPosition { | ||
| take_profit: existing.take_profit.clone(), | ||
| stop_loss: existing.stop_loss.clone(), | ||
| ..pos | ||
| }, | ||
| None => pos, | ||
| }) | ||
| .collect(); | ||
|
|
||
| let new_ids: HashSet<&str> = positions.iter().map(|p| p.id.as_str()).collect(); | ||
| let delete_position_ids: Vec<String> = existing_positions.iter().filter(|p| !new_ids.contains(p.id.as_str())).map(|p| p.id.clone()).collect(); | ||
|
|
||
| PositionsDiff { delete_position_ids, positions } | ||
| } | ||
|
|
||
| pub fn diff_open_orders_positions(orders: &[OpenOrder], existing_positions: Vec<PerpetualPosition>) -> PositionsDiff { | ||
| let positions: Vec<PerpetualPosition> = existing_positions | ||
| .into_iter() | ||
| .filter_map(|pos| { | ||
| let coin = pos.asset_id.token_id.as_ref().and_then(|t| AssetId::decode_token_id(t).into_iter().nth(1))?; | ||
| let (take_profit, stop_loss) = map_tp_sl_from_orders(orders, &coin); | ||
|
|
||
| if pos.take_profit != take_profit || pos.stop_loss != stop_loss { | ||
| Some(PerpetualPosition { take_profit, stop_loss, ..pos }) | ||
| } else { | ||
| None | ||
| } | ||
| }) | ||
| .collect(); | ||
|
|
||
| PositionsDiff { | ||
| delete_position_ids: vec![], | ||
| positions, | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use primitives::{Chain, PerpetualDirection, PerpetualMarginType}; | ||
|
|
||
| #[test] | ||
| fn test_parse_all_mids() { | ||
| let json = include_str!("../../testdata/ws_all_mids.json"); | ||
| let HyperliquidSocketMessage::AllMids { prices } = parse_websocket_data(json).unwrap() else { | ||
| panic!("expected AllMids"); | ||
| }; | ||
|
|
||
| assert_eq!(prices.len(), 5); | ||
| assert_eq!(prices["BTC"], 104633.0); | ||
| assert_eq!(prices["ETH"], 3321.1); | ||
| assert_eq!(prices["SOL"], 260.48); | ||
| assert_eq!(prices["DOGE"], 0.40381); | ||
| assert_eq!(prices["HYPE"], 26.65); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_candle() { | ||
| let json = include_str!("../../testdata/ws_candle.json"); | ||
| let HyperliquidSocketMessage::Candle { candle } = parse_websocket_data(json).unwrap() else { | ||
| panic!("expected Candle"); | ||
| }; | ||
|
|
||
| assert_eq!(candle.interval, "1h"); | ||
| assert_eq!(candle.open, 3300.5); | ||
| assert_eq!(candle.close, 3321.1); | ||
| assert_eq!(candle.high, 3345.0); | ||
| assert_eq!(candle.low, 3290.2); | ||
| assert_eq!(candle.volume, 12450.8); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_open_orders() { | ||
| let json = include_str!("../../testdata/ws_open_orders.json"); | ||
| let HyperliquidSocketMessage::OpenOrders { orders } = parse_websocket_data(json).unwrap() else { | ||
| panic!("expected OpenOrders"); | ||
| }; | ||
|
|
||
| assert_eq!(orders.len(), 2); | ||
| assert_eq!(orders[0].coin, "BTC"); | ||
| assert_eq!(orders[0].oid, 8804521338); | ||
| assert_eq!(orders[0].trigger_px, Some(110000.0)); | ||
| assert_eq!(orders[0].limit_px, Some(110000.0)); | ||
| assert_eq!(orders[0].is_position_tpsl, true); | ||
| assert_eq!(orders[0].order_type, "Take Profit Market"); | ||
| assert_eq!(orders[1].coin, "BTC"); | ||
| assert_eq!(orders[1].oid, 8804521339); | ||
| assert_eq!(orders[1].trigger_px, Some(95000.0)); | ||
| assert_eq!(orders[1].limit_px, Some(95000.0)); | ||
| assert_eq!(orders[1].is_position_tpsl, true); | ||
| assert_eq!(orders[1].order_type, "Stop Market"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_clearinghouse_state() { | ||
| let json = include_str!("../../testdata/ws_clearinghouse_state.json"); | ||
| let HyperliquidSocketMessage::ClearinghouseState { balance, positions } = parse_websocket_data(json).unwrap() else { | ||
| panic!("expected ClearinghouseState"); | ||
| }; | ||
|
|
||
| assert_eq!(balance.available, 15230.5 - 830.5); | ||
| assert_eq!(balance.reserved, 830.5); | ||
| assert_eq!(balance.withdrawable, 14400.0); | ||
|
|
||
| assert_eq!(positions.len(), 1); | ||
| let pos = &positions[0]; | ||
| assert_eq!(pos.id, "0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4_ETH"); | ||
| assert_eq!(pos.asset_id.chain, Chain::HyperCore); | ||
| assert_eq!(pos.size, 2.5); | ||
| assert_eq!(pos.size_value, 8305.0); | ||
| assert_eq!(pos.leverage, 10); | ||
| assert_eq!(pos.entry_price, 3200.0); | ||
| assert_eq!(pos.liquidation_price, Some(2850.5)); | ||
| assert_eq!(pos.margin_type, PerpetualMarginType::Cross); | ||
| assert_eq!(pos.direction, PerpetualDirection::Long); | ||
| assert_eq!(pos.margin_amount, 830.5); | ||
| assert_eq!(pos.pnl, 305.0); | ||
| assert_eq!(pos.funding, Some(-1.82)); | ||
| assert_eq!(pos.take_profit, None); | ||
| assert_eq!(pos.stop_loss, None); | ||
| } | ||
|
|
||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"channel":"allMids","data":{"mids":{"BTC":"104633.0","ETH":"3321.1","SOL":"260.48","DOGE":"0.40381","HYPE":"26.65"}}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"channel":"candle","data":{"t":1738022400000,"T":1738026000000,"s":"ETH","i":"1h","o":"3300.5","c":"3321.1","h":"3345.0","l":"3290.2","v":"12450.8","n":1842}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"channel":"clearinghouseState","data":{"user":"0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4","clearinghouseState":{"assetPositions":[{"type":"oneWay","position":{"coin":"ETH","szi":"2.5","leverage":{"type":"cross","value":10},"entryPx":"3200.0","positionValue":"8305.0","unrealizedPnl":"305.0","returnOnEquity":"0.0366","liquidationPx":"2850.5","marginUsed":"830.5","maxLeverage":50,"cumFunding":{"allTime":"12.35","sinceOpen":"1.82"}}}],"marginSummary":{"accountValue":"15230.5","totalNtlPos":"8305.0","totalRawUsd":"6925.5","totalMarginUsed":"830.5"},"crossMarginSummary":{"accountValue":"15230.5","totalNtlPos":"8305.0","totalRawUsd":"6925.5","totalMarginUsed":"830.5"},"crossMaintenanceMarginUsed":"415.25","withdrawable":"14400.0"}}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"channel":"openOrders","data":{"user":"0xc64cc00b46150e2681a6c0e57b4b12fd2b68fbc4","orders":[{"coin":"BTC","oid":8804521338,"triggerPx":"110000.0","limitPx":"110000.0","isPositionTpsl":true,"orderType":"Take Profit Market"},{"coin":"BTC","oid":8804521339,"triggerPx":"95000.0","limitPx":"95000.0","isPositionTpsl":true,"orderType":"Stop Market"}]}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.