use rustrade_integration::{Validator, error::SocketError, subscription::SubscriptionId};
use serde::{Deserialize, Serialize};
use smol_str::format_smolstr;
#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
#[serde(tag = "channel", rename_all = "camelCase")]
pub enum HyperliquidSubResponse {
SubscriptionResponse { data: HyperliquidSubResponseData },
Pong,
Error { data: String },
}
#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
pub struct HyperliquidSubResponseData {
pub method: String,
pub subscription: serde_json::Value,
}
impl HyperliquidSubResponse {
pub fn subscription_id(&self) -> Option<SubscriptionId> {
let Self::SubscriptionResponse { data } = self else {
return None;
};
let kind = data.subscription.get("type")?.as_str()?;
let coin = data.subscription.get("coin")?.as_str()?;
Some(SubscriptionId(format_smolstr!("{kind}|{coin}")))
}
}
impl Validator for HyperliquidSubResponse {
type Error = SocketError;
fn validate(self) -> Result<Self, SocketError>
where
Self: Sized,
{
match &self {
HyperliquidSubResponse::SubscriptionResponse { .. } => Ok(self),
HyperliquidSubResponse::Pong => Err(SocketError::Subscribe(
"received pong out of sequence during subscription validation".to_owned(),
)),
HyperliquidSubResponse::Error { data } => Err(SocketError::Subscribe(format!(
"received failure subscription response: {data}",
))),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
mod de {
use super::*;
#[test]
fn test_hyperliquid_sub_response_success() {
let input = r#"
{
"channel": "subscriptionResponse",
"data": {
"method": "subscribe",
"subscription": {"type": "trades", "coin": "BTC"}
}
}
"#;
let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
assert!(matches!(
response,
HyperliquidSubResponse::SubscriptionResponse { .. }
));
assert!(response.validate().is_ok());
}
#[test]
fn a_confirmation_names_its_subscription_by_type_and_coin() {
let input = r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"l2Book","coin":"xyz:TSLA","nSigFigs":null,"mantissa":null,"fast":false}}}"#;
let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
assert_eq!(
response.subscription_id(),
Some(SubscriptionId::from("l2Book|xyz:TSLA"))
);
}
#[test]
fn anything_but_a_complete_confirmation_names_no_subscription() {
for input in [
r#"{"channel":"pong"}"#,
r#"{"channel":"error","data":"invalid subscription"}"#,
r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"allMids"}}}"#,
] {
let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
assert_eq!(response.subscription_id(), None, "{input}");
}
}
#[test]
fn an_echoed_subscription_names_the_id_it_was_keyed_under() {
use crate::{
Identifier,
exchange::{
Connector, ExchangeSub,
hyperliquid::{
Hyperliquid, channel::HyperliquidChannel, market::HyperliquidMarket,
},
},
subscription::{Subscription, book::OrderBooksL2, trade::PublicTrades},
};
use rustrade_instrument::instrument::market_data::{
MarketDataInstrument, kind::MarketDataInstrumentKind::Perpetual,
};
fn round_trip(exchange_sub: ExchangeSub<HyperliquidChannel, HyperliquidMarket>) {
let keyed = exchange_sub.id();
let request = Hyperliquid::requests(vec![exchange_sub]).remove(0);
let request: serde_json::Value =
serde_json::from_str(request.to_text().unwrap()).unwrap();
let echoed = serde_json::json!({
"channel": "subscriptionResponse",
"data": {"method": "subscribe", "subscription": request["subscription"]},
});
let response: HyperliquidSubResponse = serde_json::from_value(echoed).unwrap();
assert_eq!(response.subscription_id(), Some(keyed));
}
let trades: Subscription<Hyperliquid, MarketDataInstrument, PublicTrades> =
(Hyperliquid, "btc", "usdc", Perpetual, PublicTrades).into();
let books: Subscription<Hyperliquid, MarketDataInstrument, OrderBooksL2> =
(Hyperliquid, "eth", "usdc", Perpetual, OrderBooksL2).into();
round_trip(ExchangeSub::new(&trades));
round_trip(ExchangeSub::new(&books));
}
#[test]
fn test_hyperliquid_sub_response_pong() {
let input = r#"{"channel": "pong"}"#;
let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
assert!(matches!(response, HyperliquidSubResponse::Pong));
assert!(response.validate().is_err());
}
#[test]
fn test_hyperliquid_sub_response_error() {
let input = r#"{"channel": "error", "data": "invalid subscription"}"#;
let response: HyperliquidSubResponse = serde_json::from_str(input).unwrap();
assert!(matches!(response, HyperliquidSubResponse::Error { .. }));
assert!(response.validate().is_err());
}
}
}