rustrade_data/exchange/hyperliquid/
trade.rs1use super::HyperliquidChannel;
2use crate::{
3 Identifier,
4 error::DataError,
5 event::{MarketEvent, MarketIter},
6 exchange::ExchangeSub,
7 subscription::trade::PublicTrade,
8};
9use chrono::{TimeZone, Utc};
10use rust_decimal::Decimal;
11use rustrade_instrument::{Side, exchange::ExchangeId};
12use rustrade_integration::subscription::SubscriptionId;
13use serde::{Deserialize, Serialize};
14use smol_str::{SmolStr, format_smolstr};
15
16#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
36#[serde(tag = "channel", rename_all = "camelCase")]
37pub enum HyperliquidMessage {
38 Trades {
39 data: Vec<HyperliquidTradeData>,
40 },
41 #[serde(other)]
42 Other,
43}
44
45#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
47pub struct HyperliquidTradeData {
48 pub coin: SmolStr,
49 pub side: SmolStr,
50 #[serde(deserialize_with = "rustrade_integration::serde::de::de_str")]
51 pub px: Decimal,
52 #[serde(deserialize_with = "rustrade_integration::serde::de::de_str")]
53 pub sz: Decimal,
54 pub time: u64,
55 pub tid: u64,
56}
57
58#[derive(Clone, PartialEq, Debug)]
60pub struct HyperliquidTrade {
61 pub subscription_id: SubscriptionId,
62 pub trades: Vec<HyperliquidTradeData>,
63}
64
65impl Identifier<Option<SubscriptionId>> for HyperliquidTrade {
66 fn id(&self) -> Option<SubscriptionId> {
67 if self.subscription_id.as_ref().is_empty() {
71 None
72 } else {
73 Some(self.subscription_id.clone())
74 }
75 }
76}
77
78impl<'de> Deserialize<'de> for HyperliquidTrade {
79 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80 where
81 D: serde::Deserializer<'de>,
82 {
83 let message = HyperliquidMessage::deserialize(deserializer)?;
84
85 match message {
86 HyperliquidMessage::Trades { data } => {
87 if data.is_empty() {
88 return Ok(Self {
89 subscription_id: SubscriptionId::from(""),
90 trades: vec![],
91 });
92 }
93 let coin = &data[0].coin;
94 let subscription_id =
95 ExchangeSub::from((HyperliquidChannel::Trades, coin.as_str())).id();
96 Ok(Self {
97 subscription_id,
98 trades: data,
99 })
100 }
101 HyperliquidMessage::Other => Ok(Self {
102 subscription_id: SubscriptionId::from(""),
103 trades: vec![],
104 }),
105 }
106 }
107}
108
109impl<InstrumentKey> From<(ExchangeId, InstrumentKey, HyperliquidTrade)>
110 for MarketIter<InstrumentKey, PublicTrade>
111where
112 InstrumentKey: Clone,
113{
114 fn from(
115 (exchange_id, instrument, trade_msg): (ExchangeId, InstrumentKey, HyperliquidTrade),
116 ) -> Self {
117 let time_received = Utc::now();
118 let events = trade_msg
119 .trades
120 .into_iter()
121 .map(|trade| {
122 let side = match trade.side.as_str() {
126 "A" => Side::Sell,
127 "B" => Side::Buy,
128 other => {
129 return Err(DataError::Socket(format!(
130 "unexpected Hyperliquid trade side: {other}"
131 )));
132 }
133 };
134
135 let time_exchange = Utc
136 .timestamp_millis_opt(trade.time as i64)
137 .single()
138 .ok_or_else(|| {
139 DataError::Socket(format!(
140 "Hyperliquid trade timestamp {} out of range",
141 trade.time
142 ))
143 })?;
144
145 Ok(MarketEvent {
146 time_exchange,
147 time_received,
148 exchange: exchange_id,
149 instrument: instrument.clone(),
150 kind: PublicTrade {
151 id: format_smolstr!("{}", trade.tid),
152 price: trade.px,
153 amount: trade.sz,
154 side: Some(side),
155 },
156 })
157 })
158 .collect();
159
160 Self(events)
161 }
162}
163
164#[cfg(test)]
165#[allow(clippy::unwrap_used)] mod tests {
167 use super::*;
168 use rust_decimal_macros::dec;
169
170 #[test]
171 fn test_de_hyperliquid_trade_message() {
172 let input = r#"
173 {
174 "channel": "trades",
175 "data": [
176 {
177 "coin": "BTC",
178 "side": "A",
179 "px": "45250.5",
180 "sz": "0.5",
181 "time": 1704067200000,
182 "hash": "0xabc123",
183 "tid": 12345
184 }
185 ]
186 }
187 "#;
188
189 let trade: HyperliquidTrade = serde_json::from_str(input).unwrap();
190 assert_eq!(trade.trades.len(), 1);
191 assert_eq!(trade.trades[0].coin, "BTC");
192 assert_eq!(trade.trades[0].px, dec!(45250.5));
193 assert_eq!(trade.trades[0].sz, dec!(0.5));
194 assert_eq!(trade.trades[0].side, "A");
195 }
196
197 #[test]
198 fn test_de_hyperliquid_trade_multiple() {
199 let input = r#"
200 {
201 "channel": "trades",
202 "data": [
203 {"coin": "ETH", "side": "B", "px": "3000.0", "sz": "1.0", "time": 1704067200000, "hash": "0x1", "tid": 1},
204 {"coin": "ETH", "side": "A", "px": "3001.0", "sz": "2.0", "time": 1704067200001, "hash": "0x2", "tid": 2}
205 ]
206 }
207 "#;
208
209 let trade: HyperliquidTrade = serde_json::from_str(input).unwrap();
210 assert_eq!(trade.trades.len(), 2);
211 }
212
213 fn make_trade(side: &str) -> HyperliquidTradeData {
214 HyperliquidTradeData {
215 coin: SmolStr::new_static("BTC"),
216 side: SmolStr::new(side),
217 px: dec!(100),
218 sz: dec!(1),
219 time: 1704067200000,
220 tid: 1,
221 }
222 }
223
224 #[test]
225 fn test_hyperliquid_side_mapping_sell() {
226 let hl_trade = HyperliquidTrade {
227 subscription_id: SubscriptionId::from("trades|BTC"),
228 trades: vec![make_trade("A")],
229 };
230
231 let market_iter: MarketIter<&str, PublicTrade> =
232 (ExchangeId::HyperliquidPerp, "BTC", hl_trade).into();
233 let event = market_iter.0[0].as_ref().unwrap();
234 assert_eq!(event.kind.side, Some(Side::Sell));
235 }
236
237 #[test]
238 fn test_hyperliquid_side_mapping_buy() {
239 let hl_trade = HyperliquidTrade {
240 subscription_id: SubscriptionId::from("trades|BTC"),
241 trades: vec![make_trade("B")],
242 };
243
244 let market_iter: MarketIter<&str, PublicTrade> =
245 (ExchangeId::HyperliquidPerp, "BTC", hl_trade).into();
246 let event = market_iter.0[0].as_ref().unwrap();
247 assert_eq!(event.kind.side, Some(Side::Buy));
248 }
249
250 #[test]
251 fn test_hyperliquid_side_mapping_unknown_errors() {
252 let hl_trade = HyperliquidTrade {
253 subscription_id: SubscriptionId::from("trades|BTC"),
254 trades: vec![make_trade("X")],
255 };
256
257 let market_iter: MarketIter<&str, PublicTrade> =
258 (ExchangeId::HyperliquidPerp, "BTC", hl_trade).into();
259 assert!(matches!(market_iter.0[0], Err(DataError::Socket(_))));
260 }
261
262 #[test]
263 fn test_de_hyperliquid_trade_empty_data() {
264 let input = r#"{"channel": "trades", "data": []}"#;
265 let trade: HyperliquidTrade = serde_json::from_str(input).unwrap();
266 assert!(trade.trades.is_empty());
267 assert!(trade.id().is_none());
268 }
269}