Skip to main content

rustrade_instrument/
hyperliquid.rs

1//! Hyperliquid coin names, shared by its data and execution integrations (behind the
2//! `hyperliquid` feature).
3//!
4//! Hyperliquid names every market by a *coin*:
5//! - a perpetual by its asset, as Hyperliquid spells it (`BTC`, `kPEPE`);
6//! - a builder-deployed (HIP-3) perpetual by its deployer and asset (`xyz:TSLA`);
7//! - a spot pair by `@{index}` (`@107` is HYPE/USDC), except PURR/USDC, which is named
8//!   `PURR/USDC`.
9//!
10//! [`CoinKind::of`] tells these apart from the name alone. A spot coin such as `@107` does not
11//! name its assets, so [`SpotPairs`], read from Hyperliquid's `spotMeta` info response, resolves
12//! it to its base and quote. [`Perps`], read from its `meta` info responses, lists the
13//! perpetuals and finds one by name whatever its case.
14//!
15//! Every type here is I/O-free: they deserialize from the info responses, which the data and
16//! execution integrations read.
17
18use serde::{Deserialize, Serialize};
19use smol_str::SmolStr;
20use std::collections::HashMap;
21
22/// The kind of market a Hyperliquid coin names, judged from the shape of its name alone.
23///
24/// Non-exhaustive: a kind Hyperliquid adds later may get its own variant.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum CoinKind {
28    /// A perpetual: an asset (`BTC`, `kPEPE`), or a builder-deployed (HIP-3) perpetual named
29    /// `deployer:ASSET` (`xyz:TSLA`).
30    Perp,
31    /// A spot pair: `@{index}` (`@107`), or `BASE/QUOTE` (`PURR/USDC`).
32    Spot,
33    /// Neither, such as an outcome coin (`#12`) or a shape Hyperliquid adds later.
34    ///
35    /// Never taken for a perpetual, so a new kind of market is not reported as one.
36    Unknown,
37}
38
39impl CoinKind {
40    /// The kind of market `coin` names.
41    ///
42    /// An asset, deployer or token name is one or more ASCII letters and digits, as every name
43    /// Hyperliquid lists is; any other name is [`CoinKind::Unknown`].
44    pub fn of(coin: &str) -> Self {
45        fn is_name(name: &str) -> bool {
46            !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphanumeric())
47        }
48
49        let (kind, valid) = if let Some(index) = coin.strip_prefix('@') {
50            (
51                Self::Spot,
52                !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()),
53            )
54        } else if let Some((base, quote)) = coin.split_once('/') {
55            (Self::Spot, is_name(base) && is_name(quote))
56        } else if let Some((deployer, asset)) = coin.split_once(':') {
57            (Self::Perp, is_name(deployer) && is_name(asset))
58        } else {
59            (Self::Perp, is_name(coin))
60        };
61
62        if valid { kind } else { Self::Unknown }
63    }
64}
65
66/// A Hyperliquid network. Serialized in lower case (`"mainnet"`, `"testnet"`).
67///
68/// No default: whether an unset network should mean real funds or test funds is the caller's
69/// choice.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum Network {
73    /// Hyperliquid's mainnet, where real funds trade.
74    Mainnet,
75    /// Hyperliquid's testnet. Its markets, and their indexes, differ from mainnet's.
76    Testnet,
77}
78
79/// Hyperliquid's perpetuals, by coin, as its `meta` info responses list them.
80///
81/// Deserializes from one `meta` response, `{"universe": [...], ...}`, which lists either the
82/// default perpetuals or, read with a `dex` field, one builder deployer's (HIP-3) perpetuals,
83/// named `deployer:ASSET`. Collect several sets, such as the defaults and each deployer's, into
84/// one with [`FromIterator`]. A coin listed twice keeps the later listing; the default and
85/// deployers' sets cannot share a coin, as a deployer's carry its prefix.
86///
87/// Delisted perpetuals are kept, marked by [`PerpCoin::is_delisted`]: their names stay taken.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
89#[serde(from = "PerpMeta")]
90pub struct Perps(HashMap<SmolStr, PerpCoin>);
91
92impl Perps {
93    /// The perpetual named `name`, as Hyperliquid spells it (`kPEPE`, `xyz:TSLA`) or differing
94    /// from that only in ASCII case (`KPEPE`, `XYZ:tsla`).
95    ///
96    /// An exact match wins. Otherwise a name that matches several perpetuals once case is
97    /// ignored matches none of them, rather than one picked arbitrarily. Hyperliquid lists no
98    /// such pair today (checked against mainnet, October 2026).
99    ///
100    /// May return a delisted perpetual: check [`PerpCoin::is_delisted`].
101    pub fn get(&self, name: &str) -> Option<&PerpCoin> {
102        self.0
103            .get(name)
104            .or_else(|| unique(self.0.values(), |perp| perp.coin.eq_ignore_ascii_case(name)))
105    }
106
107    /// The number of perpetuals listed, delisted ones included.
108    pub fn len(&self) -> usize {
109        self.0.len()
110    }
111
112    /// Whether no perpetual is listed.
113    pub fn is_empty(&self) -> bool {
114        self.0.is_empty()
115    }
116
117    /// Every perpetual listed, in no particular order.
118    pub fn iter(&self) -> impl Iterator<Item = &PerpCoin> {
119        self.0.values()
120    }
121}
122
123impl FromIterator<PerpCoin> for Perps {
124    fn from_iter<I: IntoIterator<Item = PerpCoin>>(iter: I) -> Self {
125        Self(
126            iter.into_iter()
127                .map(|perp| (perp.coin.clone(), perp))
128                .collect(),
129        )
130    }
131}
132
133impl FromIterator<Perps> for Perps {
134    fn from_iter<I: IntoIterator<Item = Perps>>(iter: I) -> Self {
135        Self(iter.into_iter().flat_map(|perps| perps.0).collect())
136    }
137}
138
139/// One Hyperliquid perpetual.
140#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141pub struct PerpCoin {
142    coin: SmolStr,
143    delisted: bool,
144}
145
146impl PerpCoin {
147    /// The coin Hyperliquid names the perpetual by, in its spelling (`BTC`, `kPEPE`, `xyz:TSLA`).
148    /// A subscription or order must use exactly this.
149    pub fn coin(&self) -> &str {
150        &self.coin
151    }
152
153    /// The builder deployer of a HIP-3 perpetual (`xyz` for `xyz:TSLA`), or `None` for a
154    /// default perpetual.
155    pub fn deployer(&self) -> Option<&str> {
156        self.coin.split_once(':').map(|(deployer, _)| deployer)
157    }
158
159    /// Whether Hyperliquid has delisted the perpetual. A delisted perpetual does not trade, so
160    /// a subscription to it receives nothing.
161    pub fn is_delisted(&self) -> bool {
162        self.delisted
163    }
164}
165
166/// Hyperliquid's spot pairs, by coin, as its `spotMeta` info response lists them.
167///
168/// Deserializes from that response, `{"universe": [...], "tokens": [...]}`. A pair whose tokens
169/// the response does not list is left out, so looking up its coin returns `None`.
170///
171/// Hyperliquid's token names are unique, and so is each pair's base and quote, so a pair's tokens
172/// name it as well as its coin does (checked against mainnet, October 2026). Hyperliquid's SDK
173/// relies on the same when it addresses an order by `BASE/QUOTE`.
174///
175/// Hyperliquid lists new pairs as they launch, so a set read earlier can lack a pair the account
176/// now trades. A caller that meets a spot coin missing from its set should read `spotMeta` again
177/// before treating the coin as unknown.
178#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
179#[serde(from = "SpotMeta")]
180pub struct SpotPairs(HashMap<SmolStr, SpotPair>);
181
182impl SpotPairs {
183    /// The pair Hyperliquid names `coin` (`@107`, or `PURR/USDC`), if listed.
184    ///
185    /// Exact: Hyperliquid's coin names are case-sensitive.
186    pub fn get(&self, coin: &str) -> Option<&SpotPair> {
187        self.0.get(coin)
188    }
189
190    /// The pair of `base` quoted in `quote`, named by their tokens as Hyperliquid spells them
191    /// (`HYPE`, `USDT0`) or differing from that only in ASCII case (`hype`, `usdt0`).
192    ///
193    /// An exact match wins. Otherwise names that match several pairs once case is ignored match
194    /// none of them, rather than one picked arbitrarily. Hyperliquid lists no such pair today
195    /// (checked against mainnet, October 2026).
196    pub fn find(&self, base: &str, quote: &str) -> Option<&SpotPair> {
197        unique(self.0.values(), |pair| {
198            pair.base == base && pair.quote == quote
199        })
200        .or_else(|| {
201            unique(self.0.values(), |pair| {
202                pair.base.eq_ignore_ascii_case(base) && pair.quote.eq_ignore_ascii_case(quote)
203            })
204        })
205    }
206
207    /// The number of pairs listed.
208    pub fn len(&self) -> usize {
209        self.0.len()
210    }
211
212    /// Whether no pair is listed.
213    pub fn is_empty(&self) -> bool {
214        self.0.is_empty()
215    }
216
217    /// Every pair listed, in no particular order.
218    pub fn iter(&self) -> impl Iterator<Item = &SpotPair> {
219        self.0.values()
220    }
221}
222
223/// One Hyperliquid spot pair.
224#[derive(Debug, Clone, PartialEq, Eq, Hash)]
225pub struct SpotPair {
226    coin: SmolStr,
227    index: u32,
228    base: SmolStr,
229    quote: SmolStr,
230    base_sz_decimals: u32,
231}
232
233impl SpotPair {
234    /// The coin Hyperliquid names the pair by (`@107`, or `PURR/USDC`).
235    pub fn coin(&self) -> &str {
236        &self.coin
237    }
238
239    /// The pair's index in `spotMeta`'s universe. An order addresses it as asset
240    /// `10000 + index`.
241    pub fn index(&self) -> u32 {
242        self.index
243    }
244
245    /// The base token's name, as Hyperliquid spells it (`HYPE`).
246    pub fn base(&self) -> &str {
247        &self.base
248    }
249
250    /// The quote token's name, as Hyperliquid spells it (`USDC`).
251    pub fn quote(&self) -> &str {
252        &self.quote
253    }
254
255    /// The base token's `szDecimals`: the most decimal places an order's size on this pair may
256    /// have. It also caps a price's decimal places, at `8 - szDecimals`.
257    pub fn base_sz_decimals(&self) -> u32 {
258        self.base_sz_decimals
259    }
260}
261
262/// The one item of `items` that `matches`, or `None` if none or several do.
263fn unique<'a, T>(
264    items: impl IntoIterator<Item = &'a T>,
265    matches: impl Fn(&T) -> bool,
266) -> Option<&'a T> {
267    let mut found = items.into_iter().filter(|item| matches(item));
268    let first = found.next()?;
269    found.next().is_none().then_some(first)
270}
271
272/// A `meta` info response, as far as [`Perps`] reads it.
273#[derive(Deserialize)]
274struct PerpMeta {
275    universe: Vec<PerpMetaAsset>,
276}
277
278#[derive(Deserialize)]
279struct PerpMetaAsset {
280    name: SmolStr,
281    #[serde(default, rename = "isDelisted")]
282    delisted: bool,
283}
284
285impl From<PerpMeta> for Perps {
286    fn from(meta: PerpMeta) -> Self {
287        meta.universe
288            .into_iter()
289            .map(|asset| PerpCoin {
290                coin: asset.name,
291                delisted: asset.delisted,
292            })
293            .collect()
294    }
295}
296
297/// The `spotMeta` info response, as far as [`SpotPairs`] reads it.
298#[derive(Deserialize)]
299struct SpotMeta {
300    universe: Vec<SpotMetaPair>,
301    tokens: Vec<SpotMetaToken>,
302}
303
304#[derive(Deserialize)]
305struct SpotMetaPair {
306    name: SmolStr,
307    index: u32,
308    /// The base and quote tokens' `index`es. These are not positions in `tokens`, which skips
309    /// indexes.
310    tokens: [u32; 2],
311}
312
313#[derive(Deserialize)]
314#[serde(rename_all = "camelCase")]
315struct SpotMetaToken {
316    name: SmolStr,
317    index: u32,
318    sz_decimals: u32,
319}
320
321impl From<SpotMeta> for SpotPairs {
322    fn from(meta: SpotMeta) -> Self {
323        let tokens: HashMap<u32, (SmolStr, u32)> = meta
324            .tokens
325            .into_iter()
326            .map(|token| (token.index, (token.name, token.sz_decimals)))
327            .collect();
328
329        Self(
330            meta.universe
331                .into_iter()
332                .filter_map(|pair| {
333                    let [base, quote] = pair.tokens;
334                    let (base, base_sz_decimals) = tokens.get(&base)?.clone();
335                    let pair = SpotPair {
336                        index: pair.index,
337                        base,
338                        quote: tokens.get(&quote)?.0.clone(),
339                        coin: pair.name,
340                        base_sz_decimals,
341                    };
342                    Some((pair.coin.clone(), pair))
343                })
344                .collect(),
345        )
346    }
347}
348
349#[cfg(test)]
350// Test code: panics on bad input are acceptable
351#[allow(clippy::unwrap_used)]
352mod tests {
353    use super::*;
354
355    /// A `spotMeta` response in Hyperliquid's shape, with synthetic values. Token indexes skip
356    /// numbers, so they are not positions in `tokens`; one pair is quoted in a token other than
357    /// USDC, and one names a token the response does not list.
358    const SPOT_META: &str = r#"{
359        "universe": [
360            {"name": "PURR/USDC", "tokens": [1, 0], "index": 0, "isCanonical": true},
361            {"name": "@107", "tokens": [150, 0], "index": 107, "isCanonical": false},
362            {"name": "@207", "tokens": [150, 268], "index": 207, "isCanonical": false},
363            {"name": "@300", "tokens": [999, 0], "index": 300, "isCanonical": false}
364        ],
365        "tokens": [
366            {"name": "USDC", "szDecimals": 8, "weiDecimals": 8, "index": 0, "isCanonical": true},
367            {"name": "PURR", "szDecimals": 0, "weiDecimals": 5, "index": 1, "isCanonical": true},
368            {"name": "HYPE", "szDecimals": 2, "weiDecimals": 8, "index": 150, "isCanonical": false},
369            {"name": "USDT0", "szDecimals": 2, "weiDecimals": 8, "index": 268, "isCanonical": false}
370        ]
371    }"#;
372
373    fn pairs() -> SpotPairs {
374        serde_json::from_str(SPOT_META).unwrap()
375    }
376
377    #[test]
378    fn spot_pairs_resolve_a_coin_to_its_tokens_by_index() {
379        let pairs = pairs();
380
381        let hype = pairs.get("@107").unwrap();
382        assert_eq!(
383            (hype.coin(), hype.index(), hype.base(), hype.quote()),
384            ("@107", 107, "HYPE", "USDC")
385        );
386        let hype_usdt0 = pairs.get("@207").unwrap();
387        assert_eq!((hype_usdt0.base(), hype_usdt0.quote()), ("HYPE", "USDT0"));
388        let purr = pairs.get("PURR/USDC").unwrap();
389        assert_eq!(
390            (purr.index(), purr.base(), purr.quote()),
391            (0, "PURR", "USDC")
392        );
393    }
394
395    #[test]
396    fn a_spot_pair_carries_its_base_tokens_sz_decimals() {
397        let pairs = pairs();
398
399        assert_eq!(pairs.get("@107").unwrap().base_sz_decimals(), 2, "HYPE");
400        assert_eq!(
401            pairs.get("@207").unwrap().base_sz_decimals(),
402            2,
403            "HYPE, not USDT0"
404        );
405        assert_eq!(
406            pairs.get("PURR/USDC").unwrap().base_sz_decimals(),
407            0,
408            "PURR"
409        );
410    }
411
412    #[test]
413    fn spot_pairs_leave_out_a_pair_with_an_unlisted_token() {
414        let pairs = pairs();
415
416        assert_eq!(pairs.get("@300"), None);
417        assert_eq!(pairs.len(), 3);
418        assert_eq!(pairs.iter().count(), 3);
419    }
420
421    #[test]
422    fn spot_pairs_match_a_coin_exactly() {
423        let pairs = pairs();
424
425        assert_eq!(pairs.get("purr/usdc"), None);
426        assert_eq!(pairs.get("@0"), None, "PURR/USDC is named only PURR/USDC");
427        assert_eq!(pairs.get("@999"), None);
428        assert!(SpotPairs::default().is_empty());
429    }
430
431    #[test]
432    fn spot_pairs_find_a_pair_by_its_tokens_ignoring_case() {
433        let pairs = pairs();
434
435        assert_eq!(pairs.find("HYPE", "USDT0").unwrap().coin(), "@207");
436        assert_eq!(pairs.find("hype", "usdc").unwrap().coin(), "@107");
437        assert_eq!(pairs.find("Purr", "Usdc").unwrap().coin(), "PURR/USDC");
438        assert_eq!(
439            pairs.find("USDC", "HYPE"),
440            None,
441            "base and quote are not interchangeable"
442        );
443        assert_eq!(pairs.find("HYPE", "USDH"), None);
444    }
445
446    #[test]
447    fn spot_pairs_find_no_pair_when_ignoring_case_matches_several() {
448        let pairs: SpotPairs = serde_json::from_str(
449            r#"{
450                "universe": [
451                    {"name": "@1", "tokens": [1, 0], "index": 1},
452                    {"name": "@2", "tokens": [2, 0], "index": 2}
453                ],
454                "tokens": [
455                    {"name": "USDC", "szDecimals": 8, "index": 0},
456                    {"name": "ABC", "szDecimals": 2, "index": 1},
457                    {"name": "abc", "szDecimals": 2, "index": 2}
458                ]
459            }"#,
460        )
461        .unwrap();
462
463        assert_eq!(pairs.find("ABC", "USDC").unwrap().coin(), "@1");
464        assert_eq!(pairs.find("abc", "USDC").unwrap().coin(), "@2");
465        assert_eq!(pairs.find("Abc", "USDC"), None);
466    }
467
468    /// A default `meta` response in Hyperliquid's shape, with synthetic values: a mixed-case
469    /// coin and a delisted one.
470    const META: &str = r#"{
471        "universe": [
472            {"szDecimals": 5, "name": "BTC", "maxLeverage": 40, "marginTableId": 56},
473            {"szDecimals": 0, "name": "kPEPE", "maxLeverage": 10, "marginTableId": 10},
474            {"szDecimals": 1, "name": "MATIC", "maxLeverage": 20, "marginTableId": 20,
475             "isDelisted": true}
476        ],
477        "marginTables": [],
478        "collateralToken": 0
479    }"#;
480
481    /// A builder deployer's `meta` response, read with `"dex": "xyz"`, with synthetic values.
482    const META_XYZ: &str = r#"{
483        "universe": [
484            {"szDecimals": 3, "name": "xyz:TSLA", "maxLeverage": 20, "marginTableId": 20,
485             "growthMode": "enabled", "deployerFeeScale": "1.0"}
486        ],
487        "marginTables": [],
488        "collateralToken": 0
489    }"#;
490
491    fn perps() -> Perps {
492        [META, META_XYZ]
493            .into_iter()
494            .map(|meta| serde_json::from_str::<Perps>(meta).unwrap())
495            .collect()
496    }
497
498    #[test]
499    fn perps_collect_the_default_and_a_deployers_perpetuals() {
500        let perps = perps();
501
502        assert_eq!(perps.len(), 4);
503        assert_eq!(perps.iter().count(), 4);
504        let tsla = perps.get("xyz:TSLA").unwrap();
505        assert_eq!((tsla.coin(), tsla.deployer()), ("xyz:TSLA", Some("xyz")));
506        let btc = perps.get("BTC").unwrap();
507        assert_eq!((btc.deployer(), btc.is_delisted()), (None, false));
508        assert!(perps.get("MATIC").unwrap().is_delisted());
509        assert!(Perps::default().is_empty());
510    }
511
512    #[test]
513    fn perps_get_returns_the_venue_spelling_whatever_the_case_asked() {
514        let perps = perps();
515
516        for name in ["kPEPE", "KPEPE", "kpepe"] {
517            assert_eq!(perps.get(name).unwrap().coin(), "kPEPE", "{name:?}");
518        }
519        assert_eq!(perps.get("XYZ:tsla").unwrap().coin(), "xyz:TSLA");
520        assert_eq!(
521            perps.get("TSLA"),
522            None,
523            "a HIP-3 perpetual is named with its deployer"
524        );
525        assert_eq!(perps.get("ETH"), None);
526    }
527
528    #[test]
529    fn perps_get_prefers_an_exact_match_and_refuses_an_ambiguous_one() {
530        let perps: Perps =
531            serde_json::from_str(r#"{"universe": [{"name": "ABC"}, {"name": "abc"}]}"#).unwrap();
532
533        assert_eq!(perps.get("ABC").unwrap().coin(), "ABC");
534        assert_eq!(perps.get("abc").unwrap().coin(), "abc");
535        assert_eq!(perps.get("Abc"), None);
536    }
537
538    #[test]
539    fn network_serializes_in_lower_case() {
540        assert_eq!(
541            serde_json::to_string(&Network::Mainnet).unwrap(),
542            r#""mainnet""#
543        );
544        assert_eq!(
545            serde_json::from_str::<Network>(r#""testnet""#).unwrap(),
546            Network::Testnet
547        );
548    }
549
550    #[test]
551    fn coin_kind_judges_a_coin_by_its_shape() {
552        for (coin, kind) in [
553            ("BTC", CoinKind::Perp),
554            ("kPEPE", CoinKind::Perp),
555            ("xyz:TSLA", CoinKind::Perp),
556            ("xyz:XYZ100", CoinKind::Perp),
557            ("@107", CoinKind::Spot),
558            ("@0", CoinKind::Spot),
559            ("PURR/USDC", CoinKind::Spot),
560            ("#12", CoinKind::Unknown),
561            ("+12", CoinKind::Unknown),
562            ("", CoinKind::Unknown),
563            ("@", CoinKind::Unknown),
564            ("@1x", CoinKind::Unknown),
565            ("PURR/", CoinKind::Unknown),
566            ("/USDC", CoinKind::Unknown),
567            ("A/B/C", CoinKind::Unknown),
568            (":TSLA", CoinKind::Unknown),
569            ("xyz:", CoinKind::Unknown),
570            ("a:b:c", CoinKind::Unknown),
571            ("BTC-USD", CoinKind::Unknown),
572        ] {
573            assert_eq!(CoinKind::of(coin), kind, "{coin:?}");
574        }
575    }
576}