Skip to main content

df_hyperliquid_core/live/
subscription.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
5use std::fmt;
6
7use crate::utils::{Address, Json};
8
9/// A candle interval, every one the docs list; `candleSnapshot` takes the same.
10pub use crate::markets::candle::Interval;
11
12/// One feed. A coin is spelled as the venue spells it: `BTC`, `xyz:SP500` (HIP-3), `@107` or
13/// `PURR/USDC` (spot), `#14720` (an outcome side). A `dex` of `""` is the first perp dex.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub enum Subscription {
16    /// Every coin's mid on one perp dex. The first dex's also carries spot and outcome mids.
17    AllMids {
18        dex: String,
19    },
20    /// Short texts for the account: fills, liquidations.
21    Notification {
22        user: Address,
23    },
24    /// The account as Hyperliquid's own frontend reads it.
25    WebData3 {
26        user: Address,
27    },
28    TwapStates {
29        user: Address,
30        dex: String,
31    },
32    ClearinghouseState {
33        user: Address,
34        dex: String,
35    },
36    OpenOrders {
37        user: Address,
38        dex: String,
39    },
40    Candle {
41        coin: String,
42        interval: Interval,
43    },
44    /// `n_sig_figs` groups levels to 2 to 5 significant figures; `mantissa` (1, 2 or 5) groups
45    /// further, with 5 figures only. `fast` gives 5 levels a side more often, not 20.
46    L2Book {
47        coin: String,
48        n_sig_figs: Option<u8>,
49        mantissa: Option<u8>,
50        fast: bool,
51    },
52    Trades {
53        coin: String,
54    },
55    OrderUpdates {
56        user: Address,
57    },
58    /// Its data comes on the channel `user`.
59    UserEvents {
60        user: Address,
61    },
62    /// `aggregate_by_time` merges the partial fills of one crossing order.
63    UserFills {
64        user: Address,
65        aggregate_by_time: bool,
66    },
67    UserFundings {
68        user: Address,
69    },
70    UserNonFundingLedgerUpdates {
71        user: Address,
72    },
73    /// A perp's context; a spot pair's or an outcome side's comes on `activeSpotAssetCtx`.
74    ActiveAssetCtx {
75        coin: String,
76    },
77    /// What the account may trade on a perp. Perps only.
78    ActiveAssetData {
79        user: Address,
80        coin: String,
81    },
82    UserTwapSliceFills {
83        user: Address,
84    },
85    UserTwapHistory {
86        user: Address,
87    },
88    /// The best bid and offer, sent only when one of them moves.
89    Bbo {
90        coin: String,
91    },
92    /// The docs name the option `isPortfolioMargin`; the venue ignores that and echoes
93    /// `ignorePortfolioMargin`, which is what it reads.
94    SpotState {
95        user: Address,
96        ignore_portfolio_margin: bool,
97    },
98    AllDexsClearinghouseState {
99        user: Address,
100    },
101    /// Every perp dex's contexts in one frame.
102    AllDexsAssetCtxs,
103    /// Changes to `outcomeMeta`, sent only when something changes.
104    OutcomeMetaUpdates,
105    /// Mark and mid changes, base64 of raw DEFLATE.
106    FastAssetCtxs,
107    /// One perp dex's contexts. In `@nktkas/hyperliquid`, not on the docs' list.
108    AssetCtxs {
109        dex: String,
110    },
111    /// Every spot and outcome context. In `@nktkas/hyperliquid`, not on the docs' list.
112    SpotAssetCtxs,
113    /// Orders and what became of them. In `@nktkas/hyperliquid`, not on the docs' list.
114    UserHistoricalOrders {
115        user: Address,
116    },
117}
118
119/// Why an object is not a subscription, or a subscription is not one the venue would take.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121pub enum BadSubscription {
122    #[error("a subscription is an object")]
123    NotAnObject,
124    #[error("`{0}` is not a subscription the venue serves")]
125    UnknownType(String),
126    #[error("`{kind}` needs `{field}`")]
127    Missing { kind: &'static str, field: &'static str },
128    #[error("`{field}` of `{kind}` must be {expected}")]
129    Wrong { kind: &'static str, field: &'static str, expected: &'static str },
130    #[error("a coin is never an empty string")]
131    EmptyCoin,
132    #[error("nSigFigs is 2, 3, 4 or 5, not {0}")]
133    SigFigs(u8),
134    #[error("mantissa is 1, 2 or 5, and only with nSigFigs 5; {0} is refused here")]
135    Mantissa(u8),
136}
137
138/// Every `type`, in the docs' order and then the SDK's.
139const KINDS: [&str; 27] = [
140    "allMids",
141    "notification",
142    "webData3",
143    "twapStates",
144    "clearinghouseState",
145    "openOrders",
146    "candle",
147    "l2Book",
148    "trades",
149    "orderUpdates",
150    "userEvents",
151    "userFills",
152    "userFundings",
153    "userNonFundingLedgerUpdates",
154    "activeAssetCtx",
155    "activeAssetData",
156    "userTwapSliceFills",
157    "userTwapHistory",
158    "bbo",
159    "spotState",
160    "allDexsClearinghouseState",
161    "allDexsAssetCtxs",
162    "outcomeMetaUpdates",
163    "fastAssetCtxs",
164    "assetCtxs",
165    "spotAssetCtxs",
166    "userHistoricalOrders",
167];
168
169impl Subscription {
170    /// Its `type`.
171    #[must_use]
172    pub const fn kind(&self) -> &'static str {
173        match self {
174            Subscription::AllMids { .. } => "allMids",
175            Subscription::Notification { .. } => "notification",
176            Subscription::WebData3 { .. } => "webData3",
177            Subscription::TwapStates { .. } => "twapStates",
178            Subscription::ClearinghouseState { .. } => "clearinghouseState",
179            Subscription::OpenOrders { .. } => "openOrders",
180            Subscription::Candle { .. } => "candle",
181            Subscription::L2Book { .. } => "l2Book",
182            Subscription::Trades { .. } => "trades",
183            Subscription::OrderUpdates { .. } => "orderUpdates",
184            Subscription::UserEvents { .. } => "userEvents",
185            Subscription::UserFills { .. } => "userFills",
186            Subscription::UserFundings { .. } => "userFundings",
187            Subscription::UserNonFundingLedgerUpdates { .. } => "userNonFundingLedgerUpdates",
188            Subscription::ActiveAssetCtx { .. } => "activeAssetCtx",
189            Subscription::ActiveAssetData { .. } => "activeAssetData",
190            Subscription::UserTwapSliceFills { .. } => "userTwapSliceFills",
191            Subscription::UserTwapHistory { .. } => "userTwapHistory",
192            Subscription::Bbo { .. } => "bbo",
193            Subscription::SpotState { .. } => "spotState",
194            Subscription::AllDexsClearinghouseState { .. } => "allDexsClearinghouseState",
195            Subscription::AllDexsAssetCtxs => "allDexsAssetCtxs",
196            Subscription::OutcomeMetaUpdates => "outcomeMetaUpdates",
197            Subscription::FastAssetCtxs => "fastAssetCtxs",
198            Subscription::AssetCtxs { .. } => "assetCtxs",
199            Subscription::SpotAssetCtxs => "spotAssetCtxs",
200            Subscription::UserHistoricalOrders { .. } => "userHistoricalOrders",
201        }
202    }
203
204    /// The channel its data comes on: its `type`, except `userEvents` (on `user`) and a spot or
205    /// outcome coin's `activeAssetCtx` (on `activeSpotAssetCtx`), so matching on the name sent
206    /// would never fire.
207    #[must_use]
208    pub fn channel(&self) -> &'static str {
209        match self {
210            Subscription::UserEvents { .. } => "user",
211            Subscription::ActiveAssetCtx { coin } if is_spot(coin) => "activeSpotAssetCtx",
212            other => other.kind(),
213        }
214    }
215
216    /// The account a user subscription follows. The venue allows ten across an IP's
217    /// subscriptions.
218    #[must_use]
219    pub const fn user(&self) -> Option<Address> {
220        match self {
221            Subscription::Notification { user }
222            | Subscription::WebData3 { user }
223            | Subscription::TwapStates { user, .. }
224            | Subscription::ClearinghouseState { user, .. }
225            | Subscription::OpenOrders { user, .. }
226            | Subscription::OrderUpdates { user }
227            | Subscription::UserEvents { user }
228            | Subscription::UserFills { user, .. }
229            | Subscription::UserFundings { user }
230            | Subscription::UserNonFundingLedgerUpdates { user }
231            | Subscription::ActiveAssetData { user, .. }
232            | Subscription::UserTwapSliceFills { user }
233            | Subscription::UserTwapHistory { user }
234            | Subscription::SpotState { user, .. }
235            | Subscription::AllDexsClearinghouseState { user }
236            | Subscription::UserHistoricalOrders { user } => Some(*user),
237            _ => None,
238        }
239    }
240
241    /// The coin it follows, for the subscriptions that follow one.
242    #[must_use]
243    pub fn coin(&self) -> Option<&str> {
244        match self {
245            Subscription::Candle { coin, .. }
246            | Subscription::L2Book { coin, .. }
247            | Subscription::Trades { coin }
248            | Subscription::ActiveAssetCtx { coin }
249            | Subscription::ActiveAssetData { coin, .. }
250            | Subscription::Bbo { coin } => Some(coin),
251            _ => None,
252        }
253    }
254
255    /// The object a subscribe or unsubscribe frame carries: keys in the docs' order, optional ones
256    /// last and only when set, as the Python SDK sends them. The venue echoes left out options
257    /// filled in (`fast: false`, `dex: ""`), and [`Subscription::parse`] reads that echo back to
258    /// the same subscription.
259    #[must_use]
260    pub fn to_json(&self) -> Json {
261        let mut fields = vec![("type", text(self.kind()))];
262        match self {
263            Subscription::AllMids { dex } if !dex.is_empty() => fields.push(("dex", text(dex))),
264            Subscription::TwapStates { user, dex }
265            | Subscription::ClearinghouseState { user, dex }
266            | Subscription::OpenOrders { user, dex } => {
267                fields.push(("user", text(&user.to_hex())));
268                fields.push(("dex", text(dex)));
269            }
270            Subscription::Candle { coin, interval } => {
271                fields.push(("coin", text(coin)));
272                fields.push(("interval", text(interval.wire())));
273            }
274            Subscription::L2Book { coin, n_sig_figs, mantissa, fast } => {
275                fields.push(("coin", text(coin)));
276                if let Some(figures) = n_sig_figs {
277                    fields.push(("nSigFigs", Json::Number(figures.to_string())));
278                }
279                if let Some(mantissa) = mantissa {
280                    fields.push(("mantissa", Json::Number(mantissa.to_string())));
281                }
282                if *fast {
283                    fields.push(("fast", Json::Bool(true)));
284                }
285            }
286            Subscription::Trades { coin } | Subscription::ActiveAssetCtx { coin } | Subscription::Bbo { coin } => {
287                fields.push(("coin", text(coin)));
288            }
289            Subscription::UserFills { user, aggregate_by_time } => {
290                fields.push(("user", text(&user.to_hex())));
291                if *aggregate_by_time {
292                    fields.push(("aggregateByTime", Json::Bool(true)));
293                }
294            }
295            Subscription::ActiveAssetData { user, coin } => {
296                fields.push(("user", text(&user.to_hex())));
297                fields.push(("coin", text(coin)));
298            }
299            Subscription::SpotState { user, ignore_portfolio_margin } => {
300                fields.push(("user", text(&user.to_hex())));
301                if *ignore_portfolio_margin {
302                    fields.push(("ignorePortfolioMargin", Json::Bool(true)));
303                }
304            }
305            Subscription::AssetCtxs { dex } => fields.push(("dex", text(dex))),
306            Subscription::Notification { user }
307            | Subscription::WebData3 { user }
308            | Subscription::OrderUpdates { user }
309            | Subscription::UserEvents { user }
310            | Subscription::UserFundings { user }
311            | Subscription::UserNonFundingLedgerUpdates { user }
312            | Subscription::UserTwapSliceFills { user }
313            | Subscription::UserTwapHistory { user }
314            | Subscription::AllDexsClearinghouseState { user }
315            | Subscription::UserHistoricalOrders { user } => fields.push(("user", text(&user.to_hex()))),
316            Subscription::AllMids { .. }
317            | Subscription::AllDexsAssetCtxs
318            | Subscription::OutcomeMetaUpdates
319            | Subscription::FastAssetCtxs
320            | Subscription::SpotAssetCtxs => {}
321        }
322        Json::Object(fields.into_iter().map(|(key, value)| (key.to_owned(), value)).collect())
323    }
324
325    /// Whether the venue would take it: a coin is named, and a book's grouping is one the info
326    /// docs allow. The venue drops the whole socket for some subscriptions it cannot serve (a
327    /// `bbo` of an unknown coin), so a coin should come from the venue's own lists.
328    pub fn check(&self) -> Result<(), BadSubscription> {
329        if self.coin().is_some_and(str::is_empty) {
330            return Err(BadSubscription::EmptyCoin);
331        }
332        if let Subscription::L2Book { n_sig_figs, mantissa, .. } = self {
333            if let Some(figures) = n_sig_figs.filter(|figures| !(2..=5).contains(figures)) {
334                return Err(BadSubscription::SigFigs(figures));
335            }
336            if let Some(mantissa) = mantissa.filter(|mantissa| *n_sig_figs != Some(5) || ![1, 2, 5].contains(mantissa))
337            {
338                return Err(BadSubscription::Mantissa(mantissa));
339            }
340        }
341        Ok(())
342    }
343
344    /// Reads a subscription object, in any key order, as the venue echoes it or as it was sent.
345    /// Keys it does not know are passed over; `null`, `false` and `""` read as left out.
346    pub fn parse(json: &Json) -> Result<Subscription, BadSubscription> {
347        if !matches!(json, Json::Object(_)) {
348            return Err(BadSubscription::NotAnObject);
349        }
350        let named = json.get("type").ok_or(BadSubscription::Missing { kind: "subscription", field: "type" })?;
351        let named = named.as_str().ok_or(BadSubscription::Wrong {
352            kind: "subscription",
353            field: "type",
354            expected: "a string",
355        })?;
356        let kind = KINDS
357            .into_iter()
358            .find(|kind| *kind == named)
359            .ok_or_else(|| BadSubscription::UnknownType(named.to_owned()))?;
360        let fields = Fields { json, kind };
361        Ok(match kind {
362            "allMids" => Subscription::AllMids { dex: fields.dex()? },
363            "notification" => Subscription::Notification { user: fields.user()? },
364            "webData3" => Subscription::WebData3 { user: fields.user()? },
365            "twapStates" => Subscription::TwapStates { user: fields.user()?, dex: fields.dex()? },
366            "clearinghouseState" => Subscription::ClearinghouseState { user: fields.user()?, dex: fields.dex()? },
367            "openOrders" => Subscription::OpenOrders { user: fields.user()?, dex: fields.dex()? },
368            "candle" => Subscription::Candle { coin: fields.coin()?, interval: fields.interval()? },
369            "l2Book" => Subscription::L2Book {
370                coin: fields.coin()?,
371                n_sig_figs: fields.small("nSigFigs")?,
372                mantissa: fields.small("mantissa")?,
373                fast: fields.flag("fast")?,
374            },
375            "trades" => Subscription::Trades { coin: fields.coin()? },
376            "orderUpdates" => Subscription::OrderUpdates { user: fields.user()? },
377            "userEvents" => Subscription::UserEvents { user: fields.user()? },
378            "userFills" => {
379                Subscription::UserFills { user: fields.user()?, aggregate_by_time: fields.flag("aggregateByTime")? }
380            }
381            "userFundings" => Subscription::UserFundings { user: fields.user()? },
382            "userNonFundingLedgerUpdates" => Subscription::UserNonFundingLedgerUpdates { user: fields.user()? },
383            "activeAssetCtx" => Subscription::ActiveAssetCtx { coin: fields.coin()? },
384            "activeAssetData" => Subscription::ActiveAssetData { user: fields.user()?, coin: fields.coin()? },
385            "userTwapSliceFills" => Subscription::UserTwapSliceFills { user: fields.user()? },
386            "userTwapHistory" => Subscription::UserTwapHistory { user: fields.user()? },
387            "bbo" => Subscription::Bbo { coin: fields.coin()? },
388            "spotState" => Subscription::SpotState {
389                user: fields.user()?,
390                ignore_portfolio_margin: fields.flag("ignorePortfolioMargin")?,
391            },
392            "allDexsClearinghouseState" => Subscription::AllDexsClearinghouseState { user: fields.user()? },
393            "allDexsAssetCtxs" => Subscription::AllDexsAssetCtxs,
394            "outcomeMetaUpdates" => Subscription::OutcomeMetaUpdates,
395            "fastAssetCtxs" => Subscription::FastAssetCtxs,
396            "assetCtxs" => Subscription::AssetCtxs { dex: fields.dex()? },
397            "spotAssetCtxs" => Subscription::SpotAssetCtxs,
398            "userHistoricalOrders" => Subscription::UserHistoricalOrders { user: fields.user()? },
399            other => return Err(BadSubscription::UnknownType(other.to_owned())),
400        })
401    }
402}
403
404impl fmt::Display for Subscription {
405    /// The object as one line of JSON.
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        f.write_str(&self.to_json().compact())
408    }
409}
410
411fn text(value: &str) -> Json {
412    Json::String(value.to_owned())
413}
414
415/// A spot pair (`@107`, `PURR/USDC`) or an outcome side (`#14720`, `+14720`), by its spelling.
416fn is_spot(coin: &str) -> bool {
417    coin.starts_with(['@', '#', '+']) || coin.contains('/')
418}
419
420/// The fields of one subscription object, read for the `kind` it names.
421struct Fields<'a> {
422    json: &'a Json,
423    kind: &'static str,
424}
425
426impl Fields<'_> {
427    fn wrong(&self, field: &'static str, expected: &'static str) -> BadSubscription {
428        BadSubscription::Wrong { kind: self.kind, field, expected }
429    }
430
431    /// A field that is there and not `null`.
432    fn present(&self, field: &str) -> Option<&Json> {
433        self.json.get(field).filter(|value| !matches!(value, Json::Null))
434    }
435
436    fn string(&self, field: &'static str) -> Result<&str, BadSubscription> {
437        let value = self.present(field).ok_or(BadSubscription::Missing { kind: self.kind, field })?;
438        value.as_str().ok_or_else(|| self.wrong(field, "a string"))
439    }
440
441    fn user(&self) -> Result<Address, BadSubscription> {
442        self.string("user")?.parse().map_err(|_| self.wrong("user", "an address"))
443    }
444
445    fn coin(&self) -> Result<String, BadSubscription> {
446        self.string("coin").map(ToOwned::to_owned)
447    }
448
449    fn interval(&self) -> Result<Interval, BadSubscription> {
450        Interval::parse(self.string("interval")?).ok_or_else(|| self.wrong("interval", "an interval the docs list"))
451    }
452
453    fn dex(&self) -> Result<String, BadSubscription> {
454        match self.present("dex") {
455            None => Ok(String::new()),
456            Some(value) => value.as_str().map(ToOwned::to_owned).ok_or_else(|| self.wrong("dex", "a string")),
457        }
458    }
459
460    fn flag(&self, field: &'static str) -> Result<bool, BadSubscription> {
461        match self.present(field) {
462            None => Ok(false),
463            Some(value) => value.as_bool().ok_or_else(|| self.wrong(field, "true or false")),
464        }
465    }
466
467    fn small(&self, field: &'static str) -> Result<Option<u8>, BadSubscription> {
468        match self.present(field) {
469            None => Ok(None),
470            Some(value) => value
471                .as_u64()
472                .and_then(|number| u8::try_from(number).ok())
473                .map(Some)
474                .ok_or_else(|| self.wrong(field, "a small whole number")),
475        }
476    }
477}
478
479#[cfg(test)]
480#[path = "tests/subscription.rs"]
481mod tests;