Skip to main content

rustrade_data/exchange/hyperliquid/
book.rs

1use super::HyperliquidChannel;
2use crate::{
3    Identifier,
4    books::{Level, OrderBook, OrderBookTimes},
5    error::DataError,
6    event::{MarketEvent, MarketIter},
7    exchange::ExchangeSub,
8    subscription::book::OrderBookEvent,
9};
10use chrono::{TimeZone, Utc};
11use rust_decimal::Decimal;
12use rustrade_integration::{serde::de::de_str, subscription::SubscriptionId};
13use serde::{Deserialize, Serialize};
14use smol_str::SmolStr;
15
16/// Hyperliquid L2 order book WebSocket message.
17///
18/// ### Raw Payload Example
19/// ```json
20/// {
21///     "channel": "l2Book",
22///     "data": {
23///         "coin": "BTC",
24///         "time": 1704067200000,
25///         "levels": [
26///             [{"px": "45000.0", "sz": "1.5", "n": 3}],
27///             [{"px": "45100.0", "sz": "2.0", "n": 5}]
28///         ]
29///     }
30/// }
31/// ```
32#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
33#[serde(tag = "channel", rename_all = "camelCase")]
34pub enum HyperliquidBookMessage {
35    L2Book {
36        data: HyperliquidL2BookData,
37    },
38    #[serde(other)]
39    Other,
40}
41
42/// L2 order book data from Hyperliquid.
43#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
44pub struct HyperliquidL2BookData {
45    pub coin: SmolStr,
46    pub time: u64,
47    pub levels: Vec<Vec<HyperliquidBookLevel>>,
48}
49
50/// Single price level in the order book.
51#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
52pub struct HyperliquidBookLevel {
53    #[serde(deserialize_with = "de_str")]
54    pub px: Decimal,
55    #[serde(deserialize_with = "de_str")]
56    pub sz: Decimal,
57    pub n: u64,
58}
59
60/// Wrapper for Hyperliquid L2 book used by the transformer.
61#[derive(Clone, PartialEq, Debug)]
62pub struct HyperliquidL2Book {
63    pub subscription_id: SubscriptionId,
64    pub data: Option<HyperliquidL2BookData>,
65}
66
67impl Identifier<Option<SubscriptionId>> for HyperliquidL2Book {
68    fn id(&self) -> Option<SubscriptionId> {
69        // Pong/unknown channel messages deserialize with an empty subscription_id;
70        // returning None lets the StatelessTransformer drop them silently instead of
71        // emitting a SocketError::Unidentifiable("") on every keepalive cycle.
72        if self.subscription_id.as_ref().is_empty() {
73            None
74        } else {
75            Some(self.subscription_id.clone())
76        }
77    }
78}
79
80impl<'de> Deserialize<'de> for HyperliquidL2Book {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: serde::Deserializer<'de>,
84    {
85        let message = HyperliquidBookMessage::deserialize(deserializer)?;
86
87        match message {
88            HyperliquidBookMessage::L2Book { data } => {
89                let subscription_id =
90                    ExchangeSub::from((HyperliquidChannel::L2Book, data.coin.as_str())).id();
91                Ok(Self {
92                    subscription_id,
93                    data: Some(data),
94                })
95            }
96            HyperliquidBookMessage::Other => Ok(Self {
97                subscription_id: SubscriptionId::from(""),
98                data: None,
99            }),
100        }
101    }
102}
103
104impl<InstrumentKey>
105    From<(
106        rustrade_instrument::exchange::ExchangeId,
107        InstrumentKey,
108        HyperliquidL2Book,
109    )> for MarketIter<InstrumentKey, OrderBookEvent>
110where
111    InstrumentKey: Clone,
112{
113    fn from(
114        (exchange_id, instrument, book_msg): (
115            rustrade_instrument::exchange::ExchangeId,
116            InstrumentKey,
117            HyperliquidL2Book,
118        ),
119    ) -> Self {
120        let Some(data) = book_msg.data else {
121            return Self(vec![]);
122        };
123
124        let time_exchange = match Utc.timestamp_millis_opt(data.time as i64).single() {
125            Some(time) => time,
126            None => {
127                return Self(vec![Err(DataError::Socket(format!(
128                    "Hyperliquid book timestamp {} out of range",
129                    data.time
130                )))]);
131            }
132        };
133
134        let (bids, asks) = if data.levels.len() >= 2 {
135            let bids = data.levels[0].iter().map(level_from).collect::<Vec<_>>();
136            let asks = data.levels[1].iter().map(level_from).collect::<Vec<_>>();
137            (bids, asks)
138        } else {
139            (vec![], vec![])
140        };
141
142        let time_received = Utc::now();
143
144        // Hyperliquid L2 book messages carry no sequence number; use the message
145        // timestamp (millis) as a monotonic proxy for snapshot ordering.
146        let order_book = OrderBook::new(
147            data.time,
148            // `time` is Hyperliquid's broadcast time → `time_exchange` only.
149            // Hyperliquid sends no matching-engine time, so `time_engine` is `None`.
150            OrderBookTimes {
151                time_engine: None,
152                time_exchange: Some(time_exchange),
153                time_received,
154            },
155            bids,
156            asks,
157        );
158
159        Self(vec![Ok(MarketEvent {
160            time_exchange,
161            time_received,
162            exchange: exchange_id,
163            instrument,
164            kind: OrderBookEvent::Snapshot(order_book),
165        })])
166    }
167}
168
169fn level_from(level: &HyperliquidBookLevel) -> Level {
170    Level::new(level.px, level.sz)
171}
172
173#[cfg(test)]
174#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
175mod tests {
176    use super::*;
177    use rust_decimal_macros::dec;
178    use rustrade_instrument::exchange::ExchangeId;
179
180    #[test]
181    fn test_de_hyperliquid_l2_book() {
182        let input = r#"
183        {
184            "channel": "l2Book",
185            "data": {
186                "coin": "BTC",
187                "time": 1704067200000,
188                "levels": [
189                    [{"px": "45000.0", "sz": "1.5", "n": 3}, {"px": "44900.0", "sz": "2.0", "n": 5}],
190                    [{"px": "45100.0", "sz": "1.0", "n": 2}, {"px": "45200.0", "sz": "3.0", "n": 4}]
191                ]
192            }
193        }
194        "#;
195
196        let book: HyperliquidL2Book = serde_json::from_str(input).unwrap();
197        assert!(book.data.is_some());
198        let data = book.data.unwrap();
199        assert_eq!(data.coin, "BTC");
200        assert_eq!(data.levels.len(), 2);
201        assert_eq!(data.levels[0].len(), 2);
202        assert_eq!(data.levels[1].len(), 2);
203    }
204
205    #[test]
206    fn test_l2_book_to_market_event() {
207        let book = HyperliquidL2Book {
208            subscription_id: SubscriptionId::from("l2Book|BTC"),
209            data: Some(HyperliquidL2BookData {
210                coin: SmolStr::new_static("BTC"),
211                time: 1704067200000,
212                levels: vec![
213                    vec![HyperliquidBookLevel {
214                        px: dec!(45000.0),
215                        sz: dec!(1.5),
216                        n: 3,
217                    }],
218                    vec![HyperliquidBookLevel {
219                        px: dec!(45100.0),
220                        sz: dec!(2.0),
221                        n: 5,
222                    }],
223                ],
224            }),
225        };
226
227        let market_iter: MarketIter<&str, OrderBookEvent> =
228            (ExchangeId::HyperliquidPerp, "BTC", book).into();
229        let events: Vec<_> = market_iter.0;
230        assert_eq!(events.len(), 1);
231
232        let event = events[0].as_ref().unwrap();
233        match &event.kind {
234            OrderBookEvent::Snapshot(order_book) => {
235                assert_eq!(order_book.bids().levels().len(), 1);
236                assert_eq!(order_book.asks().levels().len(), 1);
237                assert_eq!(order_book.bids().best().unwrap().price, dec!(45000.0));
238                assert_eq!(order_book.asks().best().unwrap().price, dec!(45100.0));
239            }
240            _ => panic!("Expected Snapshot"),
241        }
242    }
243
244    #[test]
245    fn test_level_from() {
246        let level = HyperliquidBookLevel {
247            px: dec!(45000.5),
248            sz: dec!(1.25),
249            n: 3,
250        };
251
252        let parsed = level_from(&level);
253        assert_eq!(parsed.price, dec!(45000.5));
254        assert_eq!(parsed.amount, dec!(1.25));
255    }
256}