Skip to main content

rustrade_data/exchange/hyperliquid/
subscription.rs

1use rustrade_integration::{Validator, error::SocketError, subscription::SubscriptionId};
2use serde::{Deserialize, Serialize};
3use smol_str::format_smolstr;
4
5/// Hyperliquid WebSocket subscription response.
6///
7/// ### Raw Payload Examples
8///
9/// #### Subscription Success
10/// ```json
11/// {
12///     "channel": "subscriptionResponse",
13///     "data": {
14///         "method": "subscribe",
15///         "subscription": {"type": "trades", "coin": "BTC"}
16///     }
17/// }
18/// ```
19///
20/// #### Pong Response (out of sequence during subscription validation)
21/// ```json
22/// {"channel": "pong"}
23/// ```
24#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
25#[serde(tag = "channel", rename_all = "camelCase")]
26pub enum HyperliquidSubResponse {
27    /// Successful subscription confirmation.
28    SubscriptionResponse { data: HyperliquidSubResponseData },
29    /// Pong response to ping; not counted as a subscription confirmation.
30    Pong,
31    /// Error response.
32    Error { data: String },
33}
34
35/// Data payload for subscription response.
36#[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    /// The subscription a confirmation acknowledges, as `{type}|{coin}` — the identifier the
44    /// subscription was keyed under.
45    ///
46    /// The venue echoes the subscription it accepted, with any optional fields filled in
47    /// (`l2Book` adds `nSigFigs`, `mantissa` and `fast`), so only `type` and `coin` are read.
48    /// `None` for anything but a confirmation carrying both.
49    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            // Pong is not a subscription confirmation; matches Bybit's pattern of returning
69            // Err so the validator does not increment its success counter for keepalive replies.
70            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)] // Test code: panics on bad input are acceptable
82mod 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            // Payload as served by mainnet: `l2Book` echoes its optional fields.
111            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            /// Echo the subscription the request carries back as the venue's confirmation, and
149            /// check it names the id the mapper keyed the subscription under.
150            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}