Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/gem_hypercore/src/models/candlestick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ impl From<Candlestick> 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),
Expand Down
1 change: 1 addition & 0 deletions crates/gem_hypercore/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ pub mod spot;
pub mod timestamp;
pub mod token;
pub mod user;
pub mod websocket;

pub type UInt64 = u64;
79 changes: 79 additions & 0 deletions crates/gem_hypercore/src/models/websocket.rs
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,
}
1 change: 1 addition & 0 deletions crates/gem_hypercore/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions crates/gem_hypercore/src/provider/perpetual_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
176 changes: 176 additions & 0 deletions crates/gem_hypercore/src/provider/websocket_mapper.rs
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);
}

}
1 change: 1 addition & 0 deletions crates/gem_hypercore/testdata/ws_all_mids.json
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"}}}
1 change: 1 addition & 0 deletions crates/gem_hypercore/testdata/ws_candle.json
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}}
1 change: 1 addition & 0 deletions crates/gem_hypercore/testdata/ws_clearinghouse_state.json
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"}}}
1 change: 1 addition & 0 deletions crates/gem_hypercore/testdata/ws_open_orders.json
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"}]}}
1 change: 1 addition & 0 deletions crates/primitives/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use typeshare::typeshare;
#[serde(rename_all = "camelCase")]
pub struct ChartCandleStick {
pub date: DateTime<Utc>,
pub interval: String,
pub open: f64,
pub high: f64,
pub low: f64,
Expand Down
35 changes: 35 additions & 0 deletions gemstone/src/models/perpetual.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -99,6 +105,7 @@ pub struct GemPerpetualMetadata {
#[uniffi::remote(Record)]
pub struct GemChartCandleStick {
pub date: DateTime<Utc>,
pub interval: String,
pub open: f64,
pub high: f64,
pub low: f64,
Expand All @@ -111,3 +118,31 @@ pub struct GemChartDateValue {
pub date: DateTime<Utc>,
pub value: f64,
}

#[uniffi::remote(Record)]
pub struct GemPositionsDiff {
Comment thread
DRadmir marked this conversation as resolved.
Resolved
pub delete_position_ids: Vec<String>,
pub positions: Vec<PerpetualPosition>,
}

#[uniffi::remote(Record)]
pub struct GemHyperliquidOpenOrder {
pub coin: String,
pub oid: u64,
pub trigger_px: Option<f64>,
pub limit_px: Option<f64>,
pub is_position_tpsl: bool,
pub order_type: String,
}

pub type GemHyperliquidSocketMessage = HyperliquidSocketMessage;

#[uniffi::remote(Enum)]
pub enum GemHyperliquidSocketMessage {
ClearinghouseState { balance: PerpetualBalance, positions: Vec<PerpetualPosition> },
OpenOrders { orders: Vec<GemHyperliquidOpenOrder> },
Candle { candle: ChartCandleStick },
AllMids { prices: HashMap<String, f64> },
SubscriptionResponse { subscription_type: String },
Unknown,
}
Loading
Loading