Skip to main content

df_hyperliquid_core/live/subscription/
mod.rs

1//! Every subscription the venue serves (the docs' list, plus `assetCtxs`, `spotAssetCtxs` and
2//! `userHistoricalOrders`), as the object a subscribe or unsubscribe frame carries. `webData2` is
3//! gone: the venue refuses it.
4
5mod parse;
6mod wire;
7
8use crate::markets::Interval;
9use crate::utils::Address;
10
11/// One feed. A coin is spelled as the venue spells it: `BTC`, `xyz:SP500` (HIP-3), `@107` or
12/// `PURR/USDC` (spot), `#14720` (an outcome side). A `dex` of `""` is the first perp dex.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum Subscription {
15    /// Every coin's mid on one perp dex. The first dex's also carries spot and outcome mids.
16    AllMids {
17        dex: String,
18    },
19    /// Short texts for the account: fills, liquidations.
20    Notification {
21        user: Address,
22    },
23    /// The account as Hyperliquid's own frontend reads it.
24    WebData3 {
25        user: Address,
26    },
27    TwapStates {
28        user: Address,
29        dex: String,
30    },
31    ClearinghouseState {
32        user: Address,
33        dex: String,
34    },
35    OpenOrders {
36        user: Address,
37        dex: String,
38    },
39    Candle {
40        coin: String,
41        interval: Interval,
42    },
43    /// `n_sig_figs` groups levels to 2 to 5 significant figures; `mantissa` (1, 2 or 5) groups
44    /// further, with 5 figures only. `fast` gives 5 levels a side more often, not 20.
45    L2Book {
46        coin: String,
47        n_sig_figs: Option<u8>,
48        mantissa: Option<u8>,
49        fast: bool,
50    },
51    Trades {
52        coin: String,
53    },
54    OrderUpdates {
55        user: Address,
56    },
57    /// Its data comes on the channel `user`.
58    UserEvents {
59        user: Address,
60    },
61    /// `aggregate_by_time` merges the partial fills of one crossing order.
62    UserFills {
63        user: Address,
64        aggregate_by_time: bool,
65    },
66    UserFundings {
67        user: Address,
68    },
69    UserNonFundingLedgerUpdates {
70        user: Address,
71    },
72    /// A perp's context; a spot pair's or an outcome side's comes on `activeSpotAssetCtx`.
73    ActiveAssetCtx {
74        coin: String,
75    },
76    /// What the account may trade on a perp. Perps only.
77    ActiveAssetData {
78        user: Address,
79        coin: String,
80    },
81    UserTwapSliceFills {
82        user: Address,
83    },
84    UserTwapHistory {
85        user: Address,
86    },
87    /// The best bid and offer, sent only when one of them moves.
88    Bbo {
89        coin: String,
90    },
91    /// The docs name the option `isPortfolioMargin`; the venue ignores that and echoes
92    /// `ignorePortfolioMargin`, which is what it reads.
93    SpotState {
94        user: Address,
95        ignore_portfolio_margin: bool,
96    },
97    AllDexsClearinghouseState {
98        user: Address,
99    },
100    /// Every perp dex's contexts in one frame.
101    AllDexsAssetCtxs,
102    /// Changes to `outcomeMeta`, sent only when something changes.
103    OutcomeMetaUpdates,
104    /// Mark and mid changes, base64 of raw DEFLATE.
105    FastAssetCtxs,
106    /// One perp dex's contexts. In `@nktkas/hyperliquid`, not on the docs' list.
107    AssetCtxs {
108        dex: String,
109    },
110    /// Every spot and outcome context. In `@nktkas/hyperliquid`, not on the docs' list.
111    SpotAssetCtxs,
112    /// Orders and what became of them. In `@nktkas/hyperliquid`, not on the docs' list.
113    UserHistoricalOrders {
114        user: Address,
115    },
116}
117
118/// Why an object is not a subscription, or a subscription is not one the venue would take.
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum BadSubscription {
121    #[error("a subscription is an object")]
122    NotAnObject,
123    #[error("`{0}` is not a subscription the venue serves")]
124    UnknownType(String),
125    #[error("`{kind}` needs `{field}`")]
126    Missing { kind: &'static str, field: &'static str },
127    #[error("`{field}` of `{kind}` must be {expected}")]
128    Wrong { kind: &'static str, field: &'static str, expected: &'static str },
129    #[error("a coin is never an empty string")]
130    EmptyCoin,
131    #[error("nSigFigs is 2, 3, 4 or 5, not {0}")]
132    SigFigs(u8),
133    #[error("mantissa is 1, 2 or 5, and only with nSigFigs 5; {0} is refused here")]
134    Mantissa(u8),
135}
136
137impl Subscription {
138    /// Its `type`.
139    #[must_use]
140    pub const fn kind(&self) -> &'static str {
141        match self {
142            Subscription::AllMids { .. } => "allMids",
143            Subscription::Notification { .. } => "notification",
144            Subscription::WebData3 { .. } => "webData3",
145            Subscription::TwapStates { .. } => "twapStates",
146            Subscription::ClearinghouseState { .. } => "clearinghouseState",
147            Subscription::OpenOrders { .. } => "openOrders",
148            Subscription::Candle { .. } => "candle",
149            Subscription::L2Book { .. } => "l2Book",
150            Subscription::Trades { .. } => "trades",
151            Subscription::OrderUpdates { .. } => "orderUpdates",
152            Subscription::UserEvents { .. } => "userEvents",
153            Subscription::UserFills { .. } => "userFills",
154            Subscription::UserFundings { .. } => "userFundings",
155            Subscription::UserNonFundingLedgerUpdates { .. } => "userNonFundingLedgerUpdates",
156            Subscription::ActiveAssetCtx { .. } => "activeAssetCtx",
157            Subscription::ActiveAssetData { .. } => "activeAssetData",
158            Subscription::UserTwapSliceFills { .. } => "userTwapSliceFills",
159            Subscription::UserTwapHistory { .. } => "userTwapHistory",
160            Subscription::Bbo { .. } => "bbo",
161            Subscription::SpotState { .. } => "spotState",
162            Subscription::AllDexsClearinghouseState { .. } => "allDexsClearinghouseState",
163            Subscription::AllDexsAssetCtxs => "allDexsAssetCtxs",
164            Subscription::OutcomeMetaUpdates => "outcomeMetaUpdates",
165            Subscription::FastAssetCtxs => "fastAssetCtxs",
166            Subscription::AssetCtxs { .. } => "assetCtxs",
167            Subscription::SpotAssetCtxs => "spotAssetCtxs",
168            Subscription::UserHistoricalOrders { .. } => "userHistoricalOrders",
169        }
170    }
171
172    /// The channel its data comes on: its `type`, except `userEvents` (on `user`) and a spot or
173    /// outcome coin's `activeAssetCtx` (on `activeSpotAssetCtx`), so matching on the name sent
174    /// would never fire.
175    #[must_use]
176    pub fn channel(&self) -> &'static str {
177        match self {
178            Subscription::UserEvents { .. } => "user",
179            Subscription::ActiveAssetCtx { coin } if is_spot(coin) => "activeSpotAssetCtx",
180            other => other.kind(),
181        }
182    }
183
184    /// The account a user subscription follows. The venue allows ten across an IP's
185    /// subscriptions.
186    #[must_use]
187    pub const fn user(&self) -> Option<Address> {
188        match self {
189            Subscription::Notification { user }
190            | Subscription::WebData3 { user }
191            | Subscription::TwapStates { user, .. }
192            | Subscription::ClearinghouseState { user, .. }
193            | Subscription::OpenOrders { user, .. }
194            | Subscription::OrderUpdates { user }
195            | Subscription::UserEvents { user }
196            | Subscription::UserFills { user, .. }
197            | Subscription::UserFundings { user }
198            | Subscription::UserNonFundingLedgerUpdates { user }
199            | Subscription::ActiveAssetData { user, .. }
200            | Subscription::UserTwapSliceFills { user }
201            | Subscription::UserTwapHistory { user }
202            | Subscription::SpotState { user, .. }
203            | Subscription::AllDexsClearinghouseState { user }
204            | Subscription::UserHistoricalOrders { user } => Some(*user),
205            _ => None,
206        }
207    }
208
209    /// The coin it follows, for the subscriptions that follow one.
210    #[must_use]
211    pub fn coin(&self) -> Option<&str> {
212        match self {
213            Subscription::Candle { coin, .. }
214            | Subscription::L2Book { coin, .. }
215            | Subscription::Trades { coin }
216            | Subscription::ActiveAssetCtx { coin }
217            | Subscription::ActiveAssetData { coin, .. }
218            | Subscription::Bbo { coin } => Some(coin),
219            _ => None,
220        }
221    }
222
223    /// Whether the venue would take it: a coin is named, and a book's grouping is one the info
224    /// docs allow. The venue drops the whole socket for some subscriptions it cannot serve (a
225    /// `bbo` of an unknown coin), so a coin should come from the venue's own lists.
226    pub fn check(&self) -> Result<(), BadSubscription> {
227        if self.coin().is_some_and(str::is_empty) {
228            return Err(BadSubscription::EmptyCoin);
229        }
230        if let Subscription::L2Book { n_sig_figs, mantissa, .. } = self {
231            if let Some(figures) = n_sig_figs.filter(|figures| !(2..=5).contains(figures)) {
232                return Err(BadSubscription::SigFigs(figures));
233            }
234            if let Some(mantissa) = mantissa.filter(|mantissa| *n_sig_figs != Some(5) || ![1, 2, 5].contains(mantissa))
235            {
236                return Err(BadSubscription::Mantissa(mantissa));
237            }
238        }
239        Ok(())
240    }
241}
242
243/// A spot pair (`@107`, `PURR/USDC`) or an outcome side (`#14720`, `+14720`), by its spelling.
244fn is_spot(coin: &str) -> bool {
245    coin.starts_with(['@', '#', '+']) || coin.contains('/')
246}
247
248#[cfg(test)]
249#[path = "tests/fixtures.rs"]
250mod fixtures;
251
252#[cfg(test)]
253#[path = "tests/subscription.rs"]
254mod tests;