rustrade_data/exchange/hyperliquid/
book.rs1use 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#[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#[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#[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#[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 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 let order_book = OrderBook::new(
147 data.time,
148 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)] mod 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}