rustrade_data/exchange/hyperliquid/
subscription.rs1use rustrade_integration::{Validator, error::SocketError, subscription::SubscriptionId};
2use serde::{Deserialize, Serialize};
3use smol_str::format_smolstr;
4
5#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
25#[serde(tag = "channel", rename_all = "camelCase")]
26pub enum HyperliquidSubResponse {
27 SubscriptionResponse { data: HyperliquidSubResponseData },
29 Pong,
31 Error { data: String },
33}
34
35#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
37pub struct HyperliquidSubResponseData {
38 pub method: String,
39 pub subscription: serde_json::Value,
40}
41
42impl HyperliquidSubResponse {
43 pub fn subscription_id(&self) -> Option<SubscriptionId> {
50 let Self::SubscriptionResponse { data } = self else {
51 return None;
52 };
53 let kind = data.subscription.get("type")?.as_str()?;
54 let coin = data.subscription.get("coin")?.as_str()?;
55 Some(SubscriptionId(format_smolstr!("{kind}|{coin}")))
56 }
57}
58
59impl Validator for HyperliquidSubResponse {
60 type Error = SocketError;
61
62 fn validate(self) -> Result<Self, SocketError>
63 where
64 Self: Sized,
65 {
66 match &self {
67 HyperliquidSubResponse::SubscriptionResponse { .. } => Ok(self),
68 HyperliquidSubResponse::Pong => Err(SocketError::Subscribe(
71 "received pong out of sequence during subscription validation".to_owned(),
72 )),
73 HyperliquidSubResponse::Error { data } => Err(SocketError::Subscribe(format!(
74 "received failure subscription response: {data}",
75 ))),
76 }
77 }
78}
79
80#[cfg(test)]
81#[allow(clippy::unwrap_used)] mod tests {
83 use super::*;
84
85 mod de {
86 use super::*;
87
88 #[test]
89 fn test_hyperliquid_sub_response_success() {
90 let input = r#"
91 {
92 "channel": "subscriptionResponse",
93 "data": {
94 "method": "subscribe",
95 "subscription": {"type": "trades", "coin": "BTC"}
96 }
97 }
98 "#;
99
100 let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
101 assert!(matches!(
102 response,
103 HyperliquidSubResponse::SubscriptionResponse { .. }
104 ));
105 assert!(response.validate().is_ok());
106 }
107
108 #[test]
109 fn a_confirmation_names_its_subscription_by_type_and_coin() {
110 let input = r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"l2Book","coin":"xyz:TSLA","nSigFigs":null,"mantissa":null,"fast":false}}}"#;
112
113 let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
114 assert_eq!(
115 response.subscription_id(),
116 Some(SubscriptionId::from("l2Book|xyz:TSLA"))
117 );
118 }
119
120 #[test]
121 fn anything_but_a_complete_confirmation_names_no_subscription() {
122 for input in [
123 r#"{"channel":"pong"}"#,
124 r#"{"channel":"error","data":"invalid subscription"}"#,
125 r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"allMids"}}}"#,
126 ] {
127 let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
128 assert_eq!(response.subscription_id(), None, "{input}");
129 }
130 }
131
132 #[test]
133 fn an_echoed_subscription_names_the_id_it_was_keyed_under() {
134 use crate::{
135 Identifier,
136 exchange::{
137 Connector, ExchangeSub,
138 hyperliquid::{
139 Hyperliquid, channel::HyperliquidChannel, market::HyperliquidMarket,
140 },
141 },
142 subscription::{Subscription, book::OrderBooksL2, trade::PublicTrades},
143 };
144 use rustrade_instrument::instrument::market_data::{
145 MarketDataInstrument, kind::MarketDataInstrumentKind::Perpetual,
146 };
147
148 fn round_trip(exchange_sub: ExchangeSub<HyperliquidChannel, HyperliquidMarket>) {
151 let keyed = exchange_sub.id();
152 let request = Hyperliquid::requests(vec![exchange_sub]).remove(0);
153 let request: serde_json::Value =
154 serde_json::from_str(request.to_text().unwrap()).unwrap();
155 let echoed = serde_json::json!({
156 "channel": "subscriptionResponse",
157 "data": {"method": "subscribe", "subscription": request["subscription"]},
158 });
159
160 let response: HyperliquidSubResponse = serde_json::from_value(echoed).unwrap();
161 assert_eq!(response.subscription_id(), Some(keyed));
162 }
163
164 let trades: Subscription<Hyperliquid, MarketDataInstrument, PublicTrades> =
165 (Hyperliquid, "btc", "usdc", Perpetual, PublicTrades).into();
166 let books: Subscription<Hyperliquid, MarketDataInstrument, OrderBooksL2> =
167 (Hyperliquid, "eth", "usdc", Perpetual, OrderBooksL2).into();
168 round_trip(ExchangeSub::new(&trades));
169 round_trip(ExchangeSub::new(&books));
170 }
171
172 #[test]
173 fn test_hyperliquid_sub_response_pong() {
174 let input = r#"{"channel": "pong"}"#;
175
176 let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
177 assert!(matches!(response, HyperliquidSubResponse::Pong));
178 assert!(response.validate().is_err());
179 }
180
181 #[test]
182 fn test_hyperliquid_sub_response_error() {
183 let input = r#"{"channel": "error", "data": "invalid subscription"}"#;
184
185 let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
186 assert!(matches!(response, HyperliquidSubResponse::Error { .. }));
187 assert!(response.validate().is_err());
188 }
189 }
190}