From 8b8bd5c9b61139ddcc3fddfbdf10957df843914f Mon Sep 17 00:00:00 2001 From: Radmir Date: Mon, 26 Jan 2026 13:47:28 +0500 Subject: [PATCH 1/7] Add websocket models and parsing for Hyperliquid Introduces websocket message models and parsing logic in gem_hypercore for handling Hyperliquid websocket channels, including clearinghouse state, open orders, and subscription responses. Adds corresponding mapping and diffing utilities in gemstone to process and compare positions and orders from websocket data. --- crates/gem_hypercore/src/models/mod.rs | 1 + crates/gem_hypercore/src/models/websocket.rs | 45 ++++++++ crates/gem_hypercore/src/provider/mod.rs | 1 + .../src/provider/websocket_mapper.rs | 50 +++++++++ gemstone/src/models/perpetual.rs | 27 +++++ gemstone/src/perpetual.rs | 100 +++++++++++++++++- 6 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 crates/gem_hypercore/src/models/websocket.rs create mode 100644 crates/gem_hypercore/src/provider/websocket_mapper.rs diff --git a/crates/gem_hypercore/src/models/mod.rs b/crates/gem_hypercore/src/models/mod.rs index 6f0b03bc4..1eeb3ce3d 100644 --- a/crates/gem_hypercore/src/models/mod.rs +++ b/crates/gem_hypercore/src/models/mod.rs @@ -11,5 +11,6 @@ pub mod spot; pub mod timestamp; pub mod token; pub mod user; +pub mod websocket; pub type UInt64 = u64; diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs new file mode 100644 index 000000000..f18db3811 --- /dev/null +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -0,0 +1,45 @@ +use serde::Deserialize; + +use super::order::OpenOrder; +use super::position::AssetPositions; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WebSocketChannel { + ClearinghouseState, + OpenOrders, + SubscriptionResponse, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebSocketMessage { + pub channel: WebSocketChannel, + pub data: serde_json::Value, +} + +#[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, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubscriptionResponseData { + pub subscription: Subscription, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Subscription { + #[serde(rename = "type")] + pub subscription_type: String, +} diff --git a/crates/gem_hypercore/src/provider/mod.rs b/crates/gem_hypercore/src/provider/mod.rs index cfb9480c4..44f203b07 100644 --- a/crates/gem_hypercore/src/provider/mod.rs +++ b/crates/gem_hypercore/src/provider/mod.rs @@ -18,6 +18,7 @@ pub mod transaction_state; pub mod transaction_state_mapper; pub mod transactions; pub mod transactions_mapper; +pub mod websocket_mapper; use crate::rpc::client::HyperCoreClient; diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs new file mode 100644 index 000000000..0d8801430 --- /dev/null +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -0,0 +1,50 @@ +use primitives::perpetual::PerpetualPositionsSummary; + +use crate::models::{ + order::OpenOrder, + websocket::{ClearinghouseStateData, OpenOrdersData, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, +}; + +use super::perpetual_mapper::map_positions; + +#[derive(Debug)] +pub struct ClearinghouseResult { + pub user: String, + pub summary: PerpetualPositionsSummary, +} + +#[derive(Debug)] +pub struct OpenOrdersResult { + pub user: String, + pub orders: Vec, +} + +pub fn parse_channel(json: &str) -> Result { + let message: WebSocketMessage = serde_json::from_str(json)?; + Ok(message.channel) +} + +pub fn parse_clearinghouse_state(json: &str) -> Result { + let message: WebSocketMessage = serde_json::from_str(json)?; + let data: ClearinghouseStateData = serde_json::from_value(message.data)?; + + let summary = map_positions(data.clearinghouse_state, data.user.clone(), &[]); + + Ok(ClearinghouseResult { user: data.user, summary }) +} + +pub fn parse_open_orders(json: &str) -> Result { + let message: WebSocketMessage = serde_json::from_str(json)?; + let data: OpenOrdersData = serde_json::from_value(message.data)?; + + Ok(OpenOrdersResult { + user: data.user, + orders: data.orders, + }) +} + +pub fn parse_subscription_response(json: &str) -> Result { + let message: WebSocketMessage = serde_json::from_str(json)?; + let data: SubscriptionResponseData = serde_json::from_value(message.data)?; + Ok(data.subscription.subscription_type) +} diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index 8676eab77..ef3a95218 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -1,10 +1,13 @@ use chrono::{DateTime, Utc}; +use gem_hypercore::models::order::OpenOrder; use primitives::{ Asset, AssetId, PerpetualDirection, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, chart::{ChartCandleStick, ChartDateValue}, perpetual::{Perpetual, PerpetualBalance, PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, }; +pub type GemHyperliquidOpenOrder = OpenOrder; + pub type GemPerpetualMarginType = PerpetualMarginType; pub type GemPerpetualOrderType = PerpetualOrderType; pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; @@ -111,3 +114,27 @@ pub struct GemChartDateValue { pub date: DateTime, pub value: f64, } + +#[derive(Debug, uniffi::Record)] +pub struct GemPositionsDiff { + pub delete_position_ids: Vec, + pub positions: Vec, +} + +#[uniffi::remote(Record)] +pub struct GemHyperliquidOpenOrder { + pub coin: String, + pub oid: u64, + pub trigger_px: Option, + pub limit_px: Option, + pub is_position_tpsl: bool, + pub order_type: String, +} + +#[derive(Debug, uniffi::Enum)] +pub enum GemHyperliquidSocketMessage { + ClearinghouseState { balance: PerpetualBalance, positions: Vec }, + OpenOrders { orders: Vec }, + SubscriptionResponse { subscription_type: String }, + Unknown, +} diff --git a/gemstone/src/perpetual.rs b/gemstone/src/perpetual.rs index fcbf0eb3c..bea488209 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -1,5 +1,16 @@ -use gem_hypercore::perpetual_formatter::PerpetualFormatter; -use primitives::PerpetualProvider; +use std::collections::{HashMap, HashSet}; + +use gem_hypercore::{ + models::websocket::WebSocketChannel, + perpetual_formatter::PerpetualFormatter, + provider::{ + perpetual_mapper::map_tp_sl_from_orders, + websocket_mapper::{parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, + }, +}; +use primitives::{AssetId, PerpetualPosition, PerpetualProvider}; + +use crate::models::perpetual::{GemHyperliquidOpenOrder, GemHyperliquidSocketMessage, GemPositionsDiff}; #[derive(Debug, uniffi::Object)] pub struct Perpetual { @@ -31,3 +42,88 @@ impl Perpetual { } } } + +#[derive(Debug, uniffi::Object)] +pub struct Hyperliquid {} + +impl Default for Hyperliquid { + fn default() -> Self { + Self::new() + } +} + +#[uniffi::export] +impl Hyperliquid { + #[uniffi::constructor] + pub fn new() -> Self { + Self {} + } + + pub fn parse_websocket_data(&self, data: Vec) -> Result { + let json = String::from_utf8(data).map_err(|e| crate::GemstoneError::AnyError { msg: e.to_string() })?; + let channel = parse_channel(&json).map_err(|e| crate::GemstoneError::AnyError { msg: e.to_string() })?; + + let result = match channel { + WebSocketChannel::ClearinghouseState => { + let result = parse_clearinghouse_state(&json).map_err(|e| e.to_string())?; + Ok(GemHyperliquidSocketMessage::ClearinghouseState { + balance: result.summary.balance, + positions: result.summary.positions, + }) + } + WebSocketChannel::OpenOrders => { + let result = parse_open_orders(&json).map_err(|e| e.to_string())?; + Ok(GemHyperliquidSocketMessage::OpenOrders { orders: result.orders }) + } + WebSocketChannel::SubscriptionResponse => { + let subscription_type = parse_subscription_response(&json).map_err(|e| e.to_string())?; + Ok(GemHyperliquidSocketMessage::SubscriptionResponse { subscription_type }) + } + WebSocketChannel::Unknown => Ok(GemHyperliquidSocketMessage::Unknown), + }; + + result.map_err(|e| crate::GemstoneError::AnyError { msg: e }) + } + + pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff { + let existing_map: HashMap<&str, &PerpetualPosition> = existing_positions.iter().map(|p| (p.id.as_str(), p)).collect(); + + let positions: Vec = 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 = existing_positions.iter().filter(|p| !new_ids.contains(p.id.as_str())).map(|p| p.id.clone()).collect(); + + GemPositionsDiff { delete_position_ids, positions } + } + + pub fn diff_open_orders_positions(&self, orders: Vec, existing_positions: Vec) -> GemPositionsDiff { + let positions: Vec = 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(); + + GemPositionsDiff { + delete_position_ids: vec![], + positions, + } + } +} From 7d064f8bd4cb236e0df5b333152fef165405bd72 Mon Sep 17 00:00:00 2001 From: Radmir Date: Mon, 26 Jan 2026 22:08:00 +0500 Subject: [PATCH 2/7] Add candle interval support and WebSocket candle handling Introduces an interval field to candlestick and chart models, updates related conversions, and adds support for handling candle messages over WebSocket. This enables interval-aware candlestick data processing and transmission throughout the system. --- .../gem_hypercore/src/models/candlestick.rs | 2 ++ crates/gem_hypercore/src/models/websocket.rs | 1 + .../src/provider/perpetual_mapper.rs | 2 ++ .../src/provider/websocket_mapper.rs | 30 +++++++++++-------- crates/primitives/src/chart.rs | 1 + gemstone/src/models/perpetual.rs | 4 +-- gemstone/src/perpetual.rs | 22 +++++++------- 7 files changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/gem_hypercore/src/models/candlestick.rs b/crates/gem_hypercore/src/models/candlestick.rs index d81f6f85c..d7076b6d3 100644 --- a/crates/gem_hypercore/src/models/candlestick.rs +++ b/crates/gem_hypercore/src/models/candlestick.rs @@ -7,6 +7,7 @@ use crate::models::UInt64; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Candlestick { pub t: UInt64, // Open time (timestamp in milliseconds) + pub i: String, // Interval pub o: String, // Open price pub h: String, // High price pub l: String, // Low price @@ -18,6 +19,7 @@ impl From for ChartCandleStick { fn from(candlestick: Candlestick) -> Self { ChartCandleStick { date: DateTime::from_timestamp(candlestick.t as i64 / 1000, 0).unwrap_or(Utc::now()), + interval: candlestick.i, open: candlestick.o.parse().unwrap_or(0.0), high: candlestick.h.parse().unwrap_or(0.0), low: candlestick.l.parse().unwrap_or(0.0), diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs index f18db3811..6e0321e1c 100644 --- a/crates/gem_hypercore/src/models/websocket.rs +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -8,6 +8,7 @@ use super::position::AssetPositions; pub enum WebSocketChannel { ClearinghouseState, OpenOrders, + Candle, SubscriptionResponse, #[serde(other)] Unknown, diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index 5b35bb791..03f0d2abc 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -334,6 +334,7 @@ mod tests { let candlesticks = vec![ Candlestick { t: 1640995200000u64, // 2022-01-01 00:00:00 UTC + i: "1h".to_string(), o: "50000.0".to_string(), h: "51000.0".to_string(), l: "49000.0".to_string(), @@ -342,6 +343,7 @@ mod tests { }, Candlestick { t: 1640998800000u64, // 2022-01-01 01:00:00 UTC + i: "1h".to_string(), o: "50500.0".to_string(), h: "52000.0".to_string(), l: "50000.0".to_string(), diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index 0d8801430..74229f1ef 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -1,6 +1,9 @@ +use primitives::chart::ChartCandleStick; use primitives::perpetual::PerpetualPositionsSummary; +use serde::de::DeserializeOwned; use crate::models::{ + candlestick::Candlestick, order::OpenOrder, websocket::{ClearinghouseStateData, OpenOrdersData, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, }; @@ -19,32 +22,33 @@ pub struct OpenOrdersResult { pub orders: Vec, } +fn parse_data(json: &str) -> Result { + let message: WebSocketMessage = serde_json::from_str(json)?; + serde_json::from_value(message.data) +} + pub fn parse_channel(json: &str) -> Result { let message: WebSocketMessage = serde_json::from_str(json)?; Ok(message.channel) } pub fn parse_clearinghouse_state(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - let data: ClearinghouseStateData = serde_json::from_value(message.data)?; - + let data: ClearinghouseStateData = parse_data(json)?; let summary = map_positions(data.clearinghouse_state, data.user.clone(), &[]); - Ok(ClearinghouseResult { user: data.user, summary }) } pub fn parse_open_orders(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - let data: OpenOrdersData = serde_json::from_value(message.data)?; - - Ok(OpenOrdersResult { - user: data.user, - orders: data.orders, - }) + let data: OpenOrdersData = parse_data(json)?; + Ok(OpenOrdersResult { user: data.user, orders: data.orders }) } pub fn parse_subscription_response(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - let data: SubscriptionResponseData = serde_json::from_value(message.data)?; + let data: SubscriptionResponseData = parse_data(json)?; Ok(data.subscription.subscription_type) } + +pub fn parse_candle(json: &str) -> Result { + let candlestick: Candlestick = parse_data(json)?; + Ok(candlestick.into()) +} diff --git a/crates/primitives/src/chart.rs b/crates/primitives/src/chart.rs index 5b163e59c..074c351f2 100644 --- a/crates/primitives/src/chart.rs +++ b/crates/primitives/src/chart.rs @@ -7,6 +7,7 @@ use typeshare::typeshare; #[serde(rename_all = "camelCase")] pub struct ChartCandleStick { pub date: DateTime, + pub interval: String, pub open: f64, pub high: f64, pub low: f64, diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index ef3a95218..536337af5 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -6,8 +6,6 @@ use primitives::{ perpetual::{Perpetual, PerpetualBalance, PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, }; -pub type GemHyperliquidOpenOrder = OpenOrder; - pub type GemPerpetualMarginType = PerpetualMarginType; pub type GemPerpetualOrderType = PerpetualOrderType; pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; @@ -102,6 +100,7 @@ pub struct GemPerpetualMetadata { #[uniffi::remote(Record)] pub struct GemChartCandleStick { pub date: DateTime, + pub interval: String, pub open: f64, pub high: f64, pub low: f64, @@ -135,6 +134,7 @@ pub struct GemHyperliquidOpenOrder { pub enum GemHyperliquidSocketMessage { ClearinghouseState { balance: PerpetualBalance, positions: Vec }, OpenOrders { orders: Vec }, + Candle { candle: ChartCandleStick }, SubscriptionResponse { subscription_type: String }, Unknown, } diff --git a/gemstone/src/perpetual.rs b/gemstone/src/perpetual.rs index bea488209..c06ab12a8 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -5,7 +5,7 @@ use gem_hypercore::{ perpetual_formatter::PerpetualFormatter, provider::{ perpetual_mapper::map_tp_sl_from_orders, - websocket_mapper::{parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, + websocket_mapper::{parse_candle, parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, }, }; use primitives::{AssetId, PerpetualPosition, PerpetualProvider}; @@ -60,29 +60,31 @@ impl Hyperliquid { } pub fn parse_websocket_data(&self, data: Vec) -> Result { - let json = String::from_utf8(data).map_err(|e| crate::GemstoneError::AnyError { msg: e.to_string() })?; - let channel = parse_channel(&json).map_err(|e| crate::GemstoneError::AnyError { msg: e.to_string() })?; + let json = String::from_utf8(data)?; + let channel = parse_channel(&json)?; - let result = match channel { + match channel { WebSocketChannel::ClearinghouseState => { - let result = parse_clearinghouse_state(&json).map_err(|e| e.to_string())?; + let result = parse_clearinghouse_state(&json)?; Ok(GemHyperliquidSocketMessage::ClearinghouseState { balance: result.summary.balance, positions: result.summary.positions, }) } WebSocketChannel::OpenOrders => { - let result = parse_open_orders(&json).map_err(|e| e.to_string())?; + let result = parse_open_orders(&json)?; Ok(GemHyperliquidSocketMessage::OpenOrders { orders: result.orders }) } + WebSocketChannel::Candle => { + let candle = parse_candle(&json)?; + Ok(GemHyperliquidSocketMessage::Candle { candle }) + } WebSocketChannel::SubscriptionResponse => { - let subscription_type = parse_subscription_response(&json).map_err(|e| e.to_string())?; + let subscription_type = parse_subscription_response(&json)?; Ok(GemHyperliquidSocketMessage::SubscriptionResponse { subscription_type }) } WebSocketChannel::Unknown => Ok(GemHyperliquidSocketMessage::Unknown), - }; - - result.map_err(|e| crate::GemstoneError::AnyError { msg: e }) + } } pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff { From bf273ba14ce85737e44508ec5b4613224755cd77 Mon Sep 17 00:00:00 2001 From: Radmir Date: Mon, 26 Jan 2026 23:01:09 +0500 Subject: [PATCH 3/7] Fix GemHyperliquidOpenOrder type alias and UniFFI binding --- gemstone/src/models/perpetual.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index 536337af5..182e49b85 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -6,6 +6,7 @@ use primitives::{ perpetual::{Perpetual, PerpetualBalance, PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, }; +pub type GemHyperliquidOpenOrder = OpenOrder; pub type GemPerpetualMarginType = PerpetualMarginType; pub type GemPerpetualOrderType = PerpetualOrderType; pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; @@ -133,7 +134,7 @@ pub struct GemHyperliquidOpenOrder { #[derive(Debug, uniffi::Enum)] pub enum GemHyperliquidSocketMessage { ClearinghouseState { balance: PerpetualBalance, positions: Vec }, - OpenOrders { orders: Vec }, + OpenOrders { orders: Vec }, Candle { candle: ChartCandleStick }, SubscriptionResponse { subscription_type: String }, Unknown, From 5779ed974cb6924003d98868e1a8b110fd67d5ec Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 27 Jan 2026 15:09:51 +0500 Subject: [PATCH 4/7] Add support for AllMids WebSocket channel Introduces handling for the AllMids WebSocket channel, including parsing and mapping of mid prices as a HashMap. Updates relevant models and message enums to support this new channel. --- crates/gem_hypercore/src/models/websocket.rs | 8 ++++++++ crates/gem_hypercore/src/provider/websocket_mapper.rs | 9 ++++++++- gemstone/src/models/perpetual.rs | 3 +++ gemstone/src/perpetual.rs | 6 +++++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs index 6e0321e1c..c9a8bd151 100644 --- a/crates/gem_hypercore/src/models/websocket.rs +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::Deserialize; use super::order::OpenOrder; @@ -9,11 +11,17 @@ pub enum WebSocketChannel { ClearinghouseState, OpenOrders, Candle, + AllMids, SubscriptionResponse, #[serde(other)] Unknown, } +#[derive(Debug, Clone, Deserialize)] +pub struct AllMidsData { + pub mids: HashMap, +} + #[derive(Debug, Clone, Deserialize)] pub struct WebSocketMessage { pub channel: WebSocketChannel, diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index 74229f1ef..2f2949ac1 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use primitives::chart::ChartCandleStick; use primitives::perpetual::PerpetualPositionsSummary; use serde::de::DeserializeOwned; @@ -5,7 +7,7 @@ use serde::de::DeserializeOwned; use crate::models::{ candlestick::Candlestick, order::OpenOrder, - websocket::{ClearinghouseStateData, OpenOrdersData, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, + websocket::{AllMidsData, ClearinghouseStateData, OpenOrdersData, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, }; use super::perpetual_mapper::map_positions; @@ -52,3 +54,8 @@ pub fn parse_candle(json: &str) -> Result { let candlestick: Candlestick = parse_data(json)?; Ok(candlestick.into()) } + +pub fn parse_all_mids(json: &str) -> Result, serde_json::Error> { + let data: AllMidsData = parse_data(json)?; + Ok(data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) +} diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index 182e49b85..99fd46e89 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use gem_hypercore::models::order::OpenOrder; use primitives::{ @@ -136,6 +138,7 @@ pub enum GemHyperliquidSocketMessage { ClearinghouseState { balance: PerpetualBalance, positions: Vec }, OpenOrders { orders: Vec }, Candle { candle: ChartCandleStick }, + AllMids { prices: HashMap }, SubscriptionResponse { subscription_type: String }, Unknown, } diff --git a/gemstone/src/perpetual.rs b/gemstone/src/perpetual.rs index c06ab12a8..231ae4155 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -5,7 +5,7 @@ use gem_hypercore::{ perpetual_formatter::PerpetualFormatter, provider::{ perpetual_mapper::map_tp_sl_from_orders, - websocket_mapper::{parse_candle, parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, + websocket_mapper::{parse_all_mids, parse_candle, parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, }, }; use primitives::{AssetId, PerpetualPosition, PerpetualProvider}; @@ -79,6 +79,10 @@ impl Hyperliquid { let candle = parse_candle(&json)?; Ok(GemHyperliquidSocketMessage::Candle { candle }) } + WebSocketChannel::AllMids => { + let prices = parse_all_mids(&json)?; + Ok(GemHyperliquidSocketMessage::AllMids { prices }) + } WebSocketChannel::SubscriptionResponse => { let subscription_type = parse_subscription_response(&json)?; Ok(GemHyperliquidSocketMessage::SubscriptionResponse { subscription_type }) From 91a56b571de3407582de75759099834341500d80 Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 27 Jan 2026 17:26:00 +0500 Subject: [PATCH 5/7] Refactor position diff logic to provider module Moved the logic for diffing clearinghouse and open order positions from the Gemstone layer into the gem_hypercore provider module. Introduced the PositionsDiff struct and related functions in gem_hypercore, updated WebSocketMessage to be generic, and adjusted usages in Gemstone to call the new provider functions. This improves code reuse and separation of concerns. --- crates/gem_hypercore/src/models/websocket.rs | 12 ++- .../src/provider/websocket_mapper.rs | 79 ++++++++++++++----- gemstone/src/models/perpetual.rs | 4 +- gemstone/src/perpetual.rs | 48 ++--------- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs index c9a8bd151..1c751e80d 100644 --- a/crates/gem_hypercore/src/models/websocket.rs +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use primitives::PerpetualPosition; use serde::Deserialize; use super::order::OpenOrder; @@ -23,9 +24,9 @@ pub struct AllMidsData { } #[derive(Debug, Clone, Deserialize)] -pub struct WebSocketMessage { +pub struct WebSocketMessage { pub channel: WebSocketChannel, - pub data: serde_json::Value, + pub data: T, } #[derive(Debug, Clone, Deserialize)] @@ -47,8 +48,13 @@ pub struct SubscriptionResponseData { } #[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] pub struct Subscription { #[serde(rename = "type")] pub subscription_type: String, } + +#[derive(Debug)] +pub struct PositionsDiff { + pub delete_position_ids: Vec, + pub positions: Vec, +} diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index 2f2949ac1..aa9511206 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -1,16 +1,16 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use primitives::chart::ChartCandleStick; use primitives::perpetual::PerpetualPositionsSummary; -use serde::de::DeserializeOwned; +use primitives::{AssetId, PerpetualPosition}; use crate::models::{ candlestick::Candlestick, order::OpenOrder, - websocket::{AllMidsData, ClearinghouseStateData, OpenOrdersData, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, + websocket::{AllMidsData, ClearinghouseStateData, OpenOrdersData, PositionsDiff, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, }; -use super::perpetual_mapper::map_positions; +use super::perpetual_mapper::{map_positions, map_tp_sl_from_orders}; #[derive(Debug)] pub struct ClearinghouseResult { @@ -24,38 +24,75 @@ pub struct OpenOrdersResult { pub orders: Vec, } -fn parse_data(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - serde_json::from_value(message.data) -} - pub fn parse_channel(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; + let message: WebSocketMessage = serde_json::from_str(json)?; Ok(message.channel) } pub fn parse_clearinghouse_state(json: &str) -> Result { - let data: ClearinghouseStateData = parse_data(json)?; - let summary = map_positions(data.clearinghouse_state, data.user.clone(), &[]); - Ok(ClearinghouseResult { user: data.user, summary }) + let message: WebSocketMessage = serde_json::from_str(json)?; + let summary = map_positions(message.data.clearinghouse_state, message.data.user.clone(), &[]); + Ok(ClearinghouseResult { user: message.data.user, summary }) } pub fn parse_open_orders(json: &str) -> Result { - let data: OpenOrdersData = parse_data(json)?; - Ok(OpenOrdersResult { user: data.user, orders: data.orders }) + let message: WebSocketMessage = serde_json::from_str(json)?; + Ok(OpenOrdersResult { user: message.data.user, orders: message.data.orders }) } pub fn parse_subscription_response(json: &str) -> Result { - let data: SubscriptionResponseData = parse_data(json)?; - Ok(data.subscription.subscription_type) + let message: WebSocketMessage = serde_json::from_str(json)?; + Ok(message.data.subscription.subscription_type) } pub fn parse_candle(json: &str) -> Result { - let candlestick: Candlestick = parse_data(json)?; - Ok(candlestick.into()) + let message: WebSocketMessage = serde_json::from_str(json)?; + Ok(message.data.into()) } pub fn parse_all_mids(json: &str) -> Result, serde_json::Error> { - let data: AllMidsData = parse_data(json)?; - Ok(data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) + let message: WebSocketMessage = serde_json::from_str(json)?; + Ok(message.data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) +} + +pub fn diff_clearinghouse_positions(new_positions: Vec, existing_positions: Vec) -> PositionsDiff { + let existing_map: HashMap<&str, &PerpetualPosition> = existing_positions.iter().map(|p| (p.id.as_str(), p)).collect(); + + let positions: Vec = 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 = 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) -> PositionsDiff { + let positions: Vec = 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, + } } diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index 99fd46e89..44a97ce41 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use gem_hypercore::models::order::OpenOrder; +use gem_hypercore::models::websocket::PositionsDiff; use primitives::{ Asset, AssetId, PerpetualDirection, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, chart::{ChartCandleStick, ChartDateValue}, @@ -9,6 +10,7 @@ use primitives::{ }; pub type GemHyperliquidOpenOrder = OpenOrder; +pub type GemPositionsDiff = PositionsDiff; pub type GemPerpetualMarginType = PerpetualMarginType; pub type GemPerpetualOrderType = PerpetualOrderType; pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; @@ -117,7 +119,7 @@ pub struct GemChartDateValue { pub value: f64, } -#[derive(Debug, uniffi::Record)] +#[uniffi::remote(Record)] pub struct GemPositionsDiff { pub delete_position_ids: Vec, pub positions: Vec, diff --git a/gemstone/src/perpetual.rs b/gemstone/src/perpetual.rs index 231ae4155..f60e69a51 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -1,14 +1,12 @@ -use std::collections::{HashMap, HashSet}; - use gem_hypercore::{ models::websocket::WebSocketChannel, perpetual_formatter::PerpetualFormatter, - provider::{ - perpetual_mapper::map_tp_sl_from_orders, - websocket_mapper::{parse_all_mids, parse_candle, parse_channel, parse_clearinghouse_state, parse_open_orders, parse_subscription_response}, + provider::websocket_mapper::{ + diff_clearinghouse_positions, diff_open_orders_positions, parse_all_mids, parse_candle, parse_channel, parse_clearinghouse_state, + parse_open_orders, parse_subscription_response, }, }; -use primitives::{AssetId, PerpetualPosition, PerpetualProvider}; +use primitives::{PerpetualPosition, PerpetualProvider}; use crate::models::perpetual::{GemHyperliquidOpenOrder, GemHyperliquidSocketMessage, GemPositionsDiff}; @@ -92,44 +90,10 @@ impl Hyperliquid { } pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff { - let existing_map: HashMap<&str, &PerpetualPosition> = existing_positions.iter().map(|p| (p.id.as_str(), p)).collect(); - - let positions: Vec = 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 = existing_positions.iter().filter(|p| !new_ids.contains(p.id.as_str())).map(|p| p.id.clone()).collect(); - - GemPositionsDiff { delete_position_ids, positions } + diff_clearinghouse_positions(new_positions, existing_positions) } pub fn diff_open_orders_positions(&self, orders: Vec, existing_positions: Vec) -> GemPositionsDiff { - let positions: Vec = 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(); - - GemPositionsDiff { - delete_position_ids: vec![], - positions, - } + diff_open_orders_positions(&orders, existing_positions) } } From 5e7601c1cc6296129e9e08f65b2e076d2f7ede28 Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 27 Jan 2026 17:45:13 +0500 Subject: [PATCH 6/7] Refactor WebSocketMessage parsing logic Added a generic parse method to WebSocketMessage for deserializing JSON. Updated websocket_mapper functions to use the new parse method, reducing code duplication and improving readability. --- crates/gem_hypercore/src/models/websocket.rs | 7 ++++++ .../src/provider/websocket_mapper.rs | 24 +++++++++---------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs index 1c751e80d..26a4a8bbe 100644 --- a/crates/gem_hypercore/src/models/websocket.rs +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use primitives::PerpetualPosition; +use serde::de::DeserializeOwned; use serde::Deserialize; use super::order::OpenOrder; @@ -29,6 +30,12 @@ pub struct WebSocketMessage { pub data: T, } +impl WebSocketMessage { + pub fn parse(json: &str) -> Result { + serde_json::from_str(json) + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClearinghouseStateData { diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index aa9511206..5aa11bad1 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -25,34 +25,32 @@ pub struct OpenOrdersResult { } pub fn parse_channel(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - Ok(message.channel) + Ok(WebSocketMessage::::parse(json)?.channel) } pub fn parse_clearinghouse_state(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - let summary = map_positions(message.data.clearinghouse_state, message.data.user.clone(), &[]); - Ok(ClearinghouseResult { user: message.data.user, summary }) + let data = WebSocketMessage::::parse(json)?.data; + let summary = map_positions(data.clearinghouse_state, data.user.clone(), &[]); + Ok(ClearinghouseResult { user: data.user, summary }) } pub fn parse_open_orders(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - Ok(OpenOrdersResult { user: message.data.user, orders: message.data.orders }) + let data = WebSocketMessage::::parse(json)?.data; + Ok(OpenOrdersResult { user: data.user, orders: data.orders }) } pub fn parse_subscription_response(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - Ok(message.data.subscription.subscription_type) + let data = WebSocketMessage::::parse(json)?.data; + Ok(data.subscription.subscription_type) } pub fn parse_candle(json: &str) -> Result { - let message: WebSocketMessage = serde_json::from_str(json)?; - Ok(message.data.into()) + Ok(WebSocketMessage::::parse(json)?.data.into()) } pub fn parse_all_mids(json: &str) -> Result, serde_json::Error> { - let message: WebSocketMessage = serde_json::from_str(json)?; - Ok(message.data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) + let data = WebSocketMessage::::parse(json)?.data; + Ok(data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) } pub fn diff_clearinghouse_positions(new_positions: Vec, existing_positions: Vec) -> PositionsDiff { From 11d8d1f0de5fc67fcbf02ffb6ddd7d589e54d984 Mon Sep 17 00:00:00 2001 From: Radmir Date: Wed, 28 Jan 2026 10:27:01 +0500 Subject: [PATCH 7/7] Refactor websocket message parsing and add tests Unified websocket message parsing into a single function returning a typed enum, replacing multiple parsing functions. Added comprehensive tests and JSON fixtures for all supported websocket message types. Updated dependent code to use the new parsing API and type alias for GemHyperliquidSocketMessage. --- crates/gem_hypercore/src/models/websocket.rs | 14 +- .../src/provider/websocket_mapper.rs | 164 +++++++++++++----- .../gem_hypercore/testdata/ws_all_mids.json | 1 + crates/gem_hypercore/testdata/ws_candle.json | 1 + .../testdata/ws_clearinghouse_state.json | 1 + .../testdata/ws_open_orders.json | 1 + gemstone/src/models/perpetual.rs | 6 +- gemstone/src/perpetual.rs | 35 +--- 8 files changed, 145 insertions(+), 78 deletions(-) create mode 100644 crates/gem_hypercore/testdata/ws_all_mids.json create mode 100644 crates/gem_hypercore/testdata/ws_candle.json create mode 100644 crates/gem_hypercore/testdata/ws_clearinghouse_state.json create mode 100644 crates/gem_hypercore/testdata/ws_open_orders.json diff --git a/crates/gem_hypercore/src/models/websocket.rs b/crates/gem_hypercore/src/models/websocket.rs index 26a4a8bbe..9d1d96b4b 100644 --- a/crates/gem_hypercore/src/models/websocket.rs +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; use primitives::PerpetualPosition; -use serde::de::DeserializeOwned; +use primitives::chart::ChartCandleStick; +use primitives::perpetual::PerpetualBalance; use serde::Deserialize; +use serde::de::DeserializeOwned; use super::order::OpenOrder; use super::position::AssetPositions; @@ -65,3 +67,13 @@ pub struct PositionsDiff { pub delete_position_ids: Vec, pub positions: Vec, } + +#[derive(Debug)] +pub enum HyperliquidSocketMessage { + ClearinghouseState { balance: PerpetualBalance, positions: Vec }, + OpenOrders { orders: Vec }, + Candle { candle: ChartCandleStick }, + AllMids { prices: HashMap }, + SubscriptionResponse { subscription_type: String }, + Unknown, +} diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index 5aa11bad1..28507b11e 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -1,56 +1,48 @@ use std::collections::{HashMap, HashSet}; -use primitives::chart::ChartCandleStick; -use primitives::perpetual::PerpetualPositionsSummary; use primitives::{AssetId, PerpetualPosition}; use crate::models::{ candlestick::Candlestick, order::OpenOrder, - websocket::{AllMidsData, ClearinghouseStateData, OpenOrdersData, PositionsDiff, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, + websocket::{AllMidsData, ClearinghouseStateData, HyperliquidSocketMessage, OpenOrdersData, PositionsDiff, SubscriptionResponseData, WebSocketChannel, WebSocketMessage}, }; use super::perpetual_mapper::{map_positions, map_tp_sl_from_orders}; -#[derive(Debug)] -pub struct ClearinghouseResult { - pub user: String, - pub summary: PerpetualPositionsSummary, -} - -#[derive(Debug)] -pub struct OpenOrdersResult { - pub user: String, - pub orders: Vec, -} - -pub fn parse_channel(json: &str) -> Result { - Ok(WebSocketMessage::::parse(json)?.channel) -} - -pub fn parse_clearinghouse_state(json: &str) -> Result { - let data = WebSocketMessage::::parse(json)?.data; - let summary = map_positions(data.clearinghouse_state, data.user.clone(), &[]); - Ok(ClearinghouseResult { user: data.user, summary }) -} - -pub fn parse_open_orders(json: &str) -> Result { - let data = WebSocketMessage::::parse(json)?.data; - Ok(OpenOrdersResult { user: data.user, orders: data.orders }) -} - -pub fn parse_subscription_response(json: &str) -> Result { - let data = WebSocketMessage::::parse(json)?.data; - Ok(data.subscription.subscription_type) -} - -pub fn parse_candle(json: &str) -> Result { - Ok(WebSocketMessage::::parse(json)?.data.into()) -} - -pub fn parse_all_mids(json: &str) -> Result, serde_json::Error> { - let data = WebSocketMessage::::parse(json)?.data; - Ok(data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect()) +pub fn parse_websocket_data(json: &str) -> Result { + let channel = WebSocketMessage::::parse(json)?.channel; + + match channel { + WebSocketChannel::ClearinghouseState => { + let data = WebSocketMessage::::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::::parse(json)?.data; + Ok(HyperliquidSocketMessage::OpenOrders { orders: data.orders }) + } + WebSocketChannel::Candle => { + let candle = WebSocketMessage::::parse(json)?.data.into(); + Ok(HyperliquidSocketMessage::Candle { candle }) + } + WebSocketChannel::AllMids => { + let data = WebSocketMessage::::parse(json)?.data; + let prices = data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect(); + Ok(HyperliquidSocketMessage::AllMids { prices }) + } + WebSocketChannel::SubscriptionResponse => { + let data = WebSocketMessage::::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, existing_positions: Vec) -> PositionsDiff { @@ -94,3 +86,91 @@ pub fn diff_open_orders_positions(orders: &[OpenOrder], existing_positions: 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); + } + +} diff --git a/crates/gem_hypercore/testdata/ws_all_mids.json b/crates/gem_hypercore/testdata/ws_all_mids.json new file mode 100644 index 000000000..825f5a74b --- /dev/null +++ b/crates/gem_hypercore/testdata/ws_all_mids.json @@ -0,0 +1 @@ +{"channel":"allMids","data":{"mids":{"BTC":"104633.0","ETH":"3321.1","SOL":"260.48","DOGE":"0.40381","HYPE":"26.65"}}} \ No newline at end of file diff --git a/crates/gem_hypercore/testdata/ws_candle.json b/crates/gem_hypercore/testdata/ws_candle.json new file mode 100644 index 000000000..cdeb972ed --- /dev/null +++ b/crates/gem_hypercore/testdata/ws_candle.json @@ -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}} \ No newline at end of file diff --git a/crates/gem_hypercore/testdata/ws_clearinghouse_state.json b/crates/gem_hypercore/testdata/ws_clearinghouse_state.json new file mode 100644 index 000000000..9ffee851a --- /dev/null +++ b/crates/gem_hypercore/testdata/ws_clearinghouse_state.json @@ -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"}}} \ No newline at end of file diff --git a/crates/gem_hypercore/testdata/ws_open_orders.json b/crates/gem_hypercore/testdata/ws_open_orders.json new file mode 100644 index 000000000..ade19652b --- /dev/null +++ b/crates/gem_hypercore/testdata/ws_open_orders.json @@ -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"}]}} \ No newline at end of file diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index 44a97ce41..5da597695 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use gem_hypercore::models::order::OpenOrder; -use gem_hypercore::models::websocket::PositionsDiff; +use gem_hypercore::models::websocket::{HyperliquidSocketMessage, PositionsDiff}; use primitives::{ Asset, AssetId, PerpetualDirection, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, chart::{ChartCandleStick, ChartDateValue}, @@ -135,7 +135,9 @@ pub struct GemHyperliquidOpenOrder { pub order_type: String, } -#[derive(Debug, uniffi::Enum)] +pub type GemHyperliquidSocketMessage = HyperliquidSocketMessage; + +#[uniffi::remote(Enum)] pub enum GemHyperliquidSocketMessage { ClearinghouseState { balance: PerpetualBalance, positions: Vec }, OpenOrders { orders: Vec }, diff --git a/gemstone/src/perpetual.rs b/gemstone/src/perpetual.rs index f60e69a51..972b7d5bd 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -1,10 +1,6 @@ use gem_hypercore::{ - models::websocket::WebSocketChannel, perpetual_formatter::PerpetualFormatter, - provider::websocket_mapper::{ - diff_clearinghouse_positions, diff_open_orders_positions, parse_all_mids, parse_candle, parse_channel, parse_clearinghouse_state, - parse_open_orders, parse_subscription_response, - }, + provider::websocket_mapper::{diff_clearinghouse_positions, diff_open_orders_positions, parse_websocket_data}, }; use primitives::{PerpetualPosition, PerpetualProvider}; @@ -59,34 +55,7 @@ impl Hyperliquid { pub fn parse_websocket_data(&self, data: Vec) -> Result { let json = String::from_utf8(data)?; - let channel = parse_channel(&json)?; - - match channel { - WebSocketChannel::ClearinghouseState => { - let result = parse_clearinghouse_state(&json)?; - Ok(GemHyperliquidSocketMessage::ClearinghouseState { - balance: result.summary.balance, - positions: result.summary.positions, - }) - } - WebSocketChannel::OpenOrders => { - let result = parse_open_orders(&json)?; - Ok(GemHyperliquidSocketMessage::OpenOrders { orders: result.orders }) - } - WebSocketChannel::Candle => { - let candle = parse_candle(&json)?; - Ok(GemHyperliquidSocketMessage::Candle { candle }) - } - WebSocketChannel::AllMids => { - let prices = parse_all_mids(&json)?; - Ok(GemHyperliquidSocketMessage::AllMids { prices }) - } - WebSocketChannel::SubscriptionResponse => { - let subscription_type = parse_subscription_response(&json)?; - Ok(GemHyperliquidSocketMessage::SubscriptionResponse { subscription_type }) - } - WebSocketChannel::Unknown => Ok(GemHyperliquidSocketMessage::Unknown), - } + Ok(parse_websocket_data(&json)?) } pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff {