Skip to main content

rustrade_data/exchange/hyperliquid/
trade.rs

1use 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/// Hyperliquid trades WebSocket message.
17///
18/// ### Raw Payload Example
19/// ```json
20/// {
21///     "channel": "trades",
22///     "data": [
23///         {
24///             "coin": "BTC",
25///             "side": "A",
26///             "px": "45250.5",
27///             "sz": "0.5",
28///             "hash": "0x...",
29///             "time": 1704067200000,
30///             "tid": 12345
31///         }
32///     ]
33/// }
34/// ```
35#[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/// Single trade data from Hyperliquid.
46#[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/// Wrapper for Hyperliquid trade used by the transformer.
59#[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        // Pong/unknown channel messages deserialize with an empty subscription_id;
68        // returning None lets the StatelessTransformer drop them silently instead of
69        // emitting a SocketError::Unidentifiable("") on every keepalive cycle.
70        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                // Hyperliquid encodes the aggressor side: "A" = ask-side aggressor (taker
123                // sell), "B" = bid-side aggressor (taker buy). Surface unknown values as
124                // an error rather than silently misclassifying trade direction.
125                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)] // Test code: panics on bad input are acceptable
166mod 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}