Skip to main content

nautilus_hyperliquid/common/
types.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::Display;
17
18use serde::{Deserialize, Serialize};
19
20/// Represents an asset ID for Hyperliquid.
21///
22/// Asset IDs follow Hyperliquid's convention:
23/// - Perps: raw index into meta.universe (`0..10_000`)
24/// - Spot: `10_000 + index` in spotMeta.universe (`10_000..100_000`)
25/// - Builder perps: `100_000 + dex_index * 10_000 + meta_index` (`100_000..100_000_000`)
26/// - Outcomes (HIP-4): `100_000_000 + 10 * outcome + side` where side is `0` or `1`
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct HyperliquidAssetId(pub u32);
29
30const HIP_1_SPOT_BASE: u32 = 10_000;
31const HIP_3_BUILDER_PERP_BASE: u32 = 100_000;
32const HIP_4_OUTCOME_BASE: u32 = 100_000_000;
33
34impl HyperliquidAssetId {
35    /// Creates a perpetual asset ID from raw index.
36    pub fn perp(index: u32) -> Self {
37        Self(index)
38    }
39
40    /// Creates a spot asset ID (`10_000 + index`).
41    pub fn spot(index: u32) -> Self {
42        Self(HIP_1_SPOT_BASE + index)
43    }
44
45    /// Creates a builder perpetual asset ID.
46    pub fn builder_perp(dex_index: u32, meta_index: u32) -> Self {
47        Self(HIP_3_BUILDER_PERP_BASE + dex_index * 10_000 + meta_index)
48    }
49
50    /// Creates an outcome (HIP-4) asset ID from `outcome` and `side`.
51    ///
52    /// Encoding: `100_000_000 + 10 * outcome + side`. Only sides `0` and `1`
53    /// are valid for binary outcomes.
54    ///
55    /// # Panics
56    ///
57    /// Panics if `side` is not `0` or `1`.
58    pub fn outcome(outcome: u32, side: u8) -> Self {
59        assert!(side <= 1, "outcome side must be 0 or 1, received {side}");
60        Self(HIP_4_OUTCOME_BASE + 10 * outcome + u32::from(side))
61    }
62
63    /// Creates an outcome (HIP-4) asset ID from an encoded `10 * outcome + side` value.
64    pub fn from_outcome_encoding(encoding: u32) -> Option<Self> {
65        let raw = HIP_4_OUTCOME_BASE.checked_add(encoding)?;
66        let asset_id = Self(raw);
67        asset_id.is_outcome().then_some(asset_id)
68    }
69
70    /// Checks if this is a perp asset (raw index, `< 10_000`).
71    pub fn is_perp(self) -> bool {
72        self.0 < HIP_1_SPOT_BASE
73    }
74
75    /// Checks if this is a spot asset (`10_000..100_000`).
76    pub fn is_spot(self) -> bool {
77        self.0 >= HIP_1_SPOT_BASE && self.0 < HIP_3_BUILDER_PERP_BASE
78    }
79
80    /// Checks if this is a builder perp (`100_000..100_000_000`).
81    pub fn is_builder_perp(self) -> bool {
82        self.0 >= HIP_3_BUILDER_PERP_BASE && self.0 < HIP_4_OUTCOME_BASE
83    }
84
85    /// Checks if this is a valid outcome (HIP-4) asset.
86    ///
87    /// Requires the id to be in the outcome range and have a valid side
88    /// digit (`0` or `1`). Ids in the range with side digits `2..=9` are
89    /// not valid HIP-4 outcomes per the protocol.
90    pub fn is_outcome(self) -> bool {
91        self.0 >= HIP_4_OUTCOME_BASE && (self.0 - HIP_4_OUTCOME_BASE) % 10 <= 1
92    }
93
94    /// Gets the base index for the asset.
95    ///
96    /// - Perp: raw index.
97    /// - Spot: `asset_id - 10_000`.
98    /// - Builder perp: meta index within the dex.
99    /// - Outcome: encoding `10 * outcome + side`.
100    pub fn base_index(self) -> u32 {
101        if self.is_outcome() {
102            self.0 - HIP_4_OUTCOME_BASE
103        } else if self.is_builder_perp() {
104            (self.0 - HIP_3_BUILDER_PERP_BASE) % 10_000
105        } else if self.is_spot() {
106            self.0 - HIP_1_SPOT_BASE
107        } else {
108            self.0
109        }
110    }
111
112    /// Returns the outcome number for an outcome asset, otherwise `None`.
113    pub fn outcome_index(self) -> Option<u32> {
114        self.outcome_encoding().map(|encoding| encoding / 10)
115    }
116
117    /// Returns the outcome side (`0` or `1`) for an outcome asset, otherwise `None`.
118    pub fn outcome_side(self) -> Option<u8> {
119        self.outcome_encoding()
120            .map(|encoding| (encoding % 10) as u8)
121    }
122
123    /// Returns the outcome encoding (`10 * outcome + side`) for an outcome asset.
124    pub fn outcome_encoding(self) -> Option<u32> {
125        self.is_outcome().then(|| self.0 - HIP_4_OUTCOME_BASE)
126    }
127
128    /// Gets the raw asset ID value.
129    pub fn to_raw(self) -> u32 {
130        self.0
131    }
132}
133
134impl Display for HyperliquidAssetId {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "{}", self.0)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use rstest::rstest;
143
144    use super::*;
145
146    #[rstest]
147    fn test_asset_id_perp() {
148        let asset_id = HyperliquidAssetId::perp(7);
149        assert_eq!(asset_id.to_raw(), 7);
150        assert!(asset_id.is_perp());
151        assert!(!asset_id.is_spot());
152        assert!(!asset_id.is_builder_perp());
153        assert!(!asset_id.is_outcome());
154        assert_eq!(asset_id.base_index(), 7);
155    }
156
157    #[rstest]
158    fn test_asset_id_spot() {
159        let asset_id = HyperliquidAssetId::spot(7);
160        assert_eq!(asset_id.to_raw(), 10_007);
161        assert!(!asset_id.is_perp());
162        assert!(asset_id.is_spot());
163        assert!(!asset_id.is_builder_perp());
164        assert!(!asset_id.is_outcome());
165        assert_eq!(asset_id.base_index(), 7);
166    }
167
168    #[rstest]
169    fn test_asset_id_builder_perp() {
170        let asset_id = HyperliquidAssetId::builder_perp(1, 7);
171        assert_eq!(asset_id.to_raw(), 110_007);
172        assert!(!asset_id.is_perp());
173        assert!(!asset_id.is_spot());
174        assert!(asset_id.is_builder_perp());
175        assert!(!asset_id.is_outcome());
176        assert_eq!(asset_id.base_index(), 7);
177    }
178
179    #[rstest]
180    fn test_asset_id_outcome() {
181        let asset_id = HyperliquidAssetId::outcome(1, 0);
182        assert_eq!(asset_id.to_raw(), 100_000_010);
183        assert!(!asset_id.is_perp());
184        assert!(!asset_id.is_spot());
185        assert!(!asset_id.is_builder_perp());
186        assert!(asset_id.is_outcome());
187        assert_eq!(asset_id.base_index(), 10);
188        assert_eq!(asset_id.outcome_encoding(), Some(10));
189        assert_eq!(asset_id.outcome_index(), Some(1));
190        assert_eq!(asset_id.outcome_side(), Some(0));
191    }
192
193    #[rstest]
194    fn test_asset_id_outcome_side_one() {
195        let asset_id = HyperliquidAssetId::outcome(3, 1);
196        assert_eq!(asset_id.to_raw(), 100_000_031);
197        assert!(asset_id.is_outcome());
198        assert_eq!(asset_id.outcome_encoding(), Some(31));
199        assert_eq!(asset_id.outcome_index(), Some(3));
200        assert_eq!(asset_id.outcome_side(), Some(1));
201    }
202
203    #[rstest]
204    fn test_asset_id_from_outcome_encoding() {
205        let asset_id = HyperliquidAssetId::from_outcome_encoding(10).unwrap();
206        assert_eq!(asset_id.to_raw(), 100_000_010);
207        assert_eq!(asset_id.outcome_index(), Some(1));
208        assert_eq!(asset_id.outcome_side(), Some(0));
209    }
210
211    #[rstest]
212    fn test_asset_id_from_outcome_encoding_rejects_invalid_side() {
213        assert_eq!(HyperliquidAssetId::from_outcome_encoding(12), None);
214    }
215
216    #[rstest]
217    fn test_asset_id_from_outcome_encoding_rejects_overflow() {
218        assert_eq!(HyperliquidAssetId::from_outcome_encoding(u32::MAX), None);
219    }
220
221    #[rstest]
222    #[should_panic(expected = "outcome side must be 0 or 1")]
223    fn test_asset_id_outcome_invalid_side() {
224        let _ = HyperliquidAssetId::outcome(0, 2);
225    }
226
227    #[rstest]
228    fn test_asset_id_outcome_accessors_non_outcome() {
229        let perp = HyperliquidAssetId::perp(7);
230        assert_eq!(perp.outcome_index(), None);
231        assert_eq!(perp.outcome_side(), None);
232
233        let spot = HyperliquidAssetId::spot(7);
234        assert_eq!(spot.outcome_index(), None);
235        assert_eq!(spot.outcome_side(), None);
236
237        let builder = HyperliquidAssetId::builder_perp(1, 7);
238        assert_eq!(builder.outcome_index(), None);
239        assert_eq!(builder.outcome_side(), None);
240    }
241
242    #[rstest]
243    fn test_asset_id_outcome_invalid_side_digit_not_outcome() {
244        // HIP-4 only defines sides 0 and 1. An id constructed via the public
245        // tuple field or deserialized from JSON could carry an invalid side
246        // digit (2..=9); these must not classify as a valid outcome and the
247        // accessors must not return them.
248        for side_digit in 2..=9u32 {
249            let raw = HyperliquidAssetId(100_000_000 + side_digit);
250            assert!(!raw.is_outcome(), "side digit {side_digit} must reject");
251            assert_eq!(raw.outcome_index(), None);
252            assert_eq!(raw.outcome_side(), None);
253        }
254    }
255
256    #[rstest]
257    fn test_asset_id_ranges_mutually_exclusive() {
258        // Boundary check: a builder-perp id at the high end of its range
259        // (just under 100_000_000) must not register as an outcome, and
260        // an outcome id at the base (100_000_000) must not register as a
261        // builder perp. This guards against the previous open-ended ranges.
262        let high_builder = HyperliquidAssetId(99_999_999);
263        assert!(high_builder.is_builder_perp());
264        assert!(!high_builder.is_outcome());
265
266        let low_outcome = HyperliquidAssetId(100_000_000);
267        assert!(!low_outcome.is_builder_perp());
268        assert!(low_outcome.is_outcome());
269    }
270}