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