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/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..9d1d96b4b --- /dev/null +++ b/crates/gem_hypercore/src/models/websocket.rs @@ -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, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebSocketMessage { + pub channel: WebSocketChannel, + 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 { + 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)] +pub struct Subscription { + #[serde(rename = "type")] + pub subscription_type: String, +} + +#[derive(Debug)] +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/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/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 new file mode 100644 index 000000000..28507b11e --- /dev/null +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -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 { + 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 { + 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, + } +} + +#[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/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 8676eab77..5da597695 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -1,10 +1,16 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; +use gem_hypercore::models::order::OpenOrder; +use gem_hypercore::models::websocket::{HyperliquidSocketMessage, PositionsDiff}; 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 GemPositionsDiff = PositionsDiff; pub type GemPerpetualMarginType = PerpetualMarginType; pub type GemPerpetualOrderType = PerpetualOrderType; pub type GemPerpetualPositionsSummary = PerpetualPositionsSummary; @@ -99,6 +105,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, @@ -111,3 +118,31 @@ pub struct GemChartDateValue { pub date: DateTime, pub value: f64, } + +#[uniffi::remote(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, +} + +pub type GemHyperliquidSocketMessage = HyperliquidSocketMessage; + +#[uniffi::remote(Enum)] +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 fcbf0eb3c..972b7d5bd 100644 --- a/gemstone/src/perpetual.rs +++ b/gemstone/src/perpetual.rs @@ -1,5 +1,10 @@ -use gem_hypercore::perpetual_formatter::PerpetualFormatter; -use primitives::PerpetualProvider; +use gem_hypercore::{ + perpetual_formatter::PerpetualFormatter, + provider::websocket_mapper::{diff_clearinghouse_positions, diff_open_orders_positions, parse_websocket_data}, +}; +use primitives::{PerpetualPosition, PerpetualProvider}; + +use crate::models::perpetual::{GemHyperliquidOpenOrder, GemHyperliquidSocketMessage, GemPositionsDiff}; #[derive(Debug, uniffi::Object)] pub struct Perpetual { @@ -31,3 +36,33 @@ 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)?; + Ok(parse_websocket_data(&json)?) + } + + pub fn diff_clearinghouse_positions(&self, new_positions: Vec, existing_positions: Vec) -> GemPositionsDiff { + diff_clearinghouse_positions(new_positions, existing_positions) + } + + pub fn diff_open_orders_positions(&self, orders: Vec, existing_positions: Vec) -> GemPositionsDiff { + diff_open_orders_positions(&orders, existing_positions) + } +}