Skip to main content

hl_types/
market.rs

1use rust_decimal::Decimal;
2use serde::{Deserialize, Serialize};
3
4/// Level-2 orderbook snapshot.
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[non_exhaustive]
7pub struct HlOrderbook {
8    /// Coin/asset symbol.
9    pub coin: String,
10    /// Bid levels (price, size).
11    pub bids: Vec<(Decimal, Decimal)>,
12    /// Ask levels (price, size).
13    pub asks: Vec<(Decimal, Decimal)>,
14    /// Timestamp in milliseconds.
15    pub timestamp: u64,
16}
17
18impl HlOrderbook {
19    /// Creates a new `HlOrderbook`.
20    pub fn new(
21        coin: String,
22        bids: Vec<(Decimal, Decimal)>,
23        asks: Vec<(Decimal, Decimal)>,
24        timestamp: u64,
25    ) -> Self {
26        Self {
27            coin,
28            bids,
29            asks,
30            timestamp,
31        }
32    }
33}
34
35/// Static metadata for an asset listed on Hyperliquid.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38#[non_exhaustive]
39pub struct HlAssetInfo {
40    /// Asset symbol (e.g. "BTC").
41    pub coin: String,
42    /// Asset index used in wire messages.
43    pub asset_id: u32,
44    /// Minimum order size.
45    pub min_size: Decimal,
46    /// Size decimal places.
47    pub sz_decimals: u32,
48    /// Price decimal places.
49    pub px_decimals: u32,
50}
51
52impl HlAssetInfo {
53    /// Creates a new `HlAssetInfo`.
54    pub fn new(
55        coin: String,
56        asset_id: u32,
57        min_size: Decimal,
58        sz_decimals: u32,
59        px_decimals: u32,
60    ) -> Self {
61        Self {
62            coin,
63            asset_id,
64            min_size,
65            sz_decimals,
66            px_decimals,
67        }
68    }
69}
70
71/// Current funding rate for a perpetual.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74#[non_exhaustive]
75pub struct HlFundingRate {
76    /// Coin/asset symbol.
77    pub coin: String,
78    /// Current funding rate.
79    pub funding_rate: Decimal,
80    /// Next funding time (ms since epoch).
81    pub next_funding_time: u64,
82}
83
84impl HlFundingRate {
85    /// Creates a new `HlFundingRate`.
86    pub fn new(coin: String, funding_rate: Decimal, next_funding_time: u64) -> Self {
87        Self {
88            coin,
89            funding_rate,
90            next_funding_time,
91        }
92    }
93}
94
95/// Metadata for a spot token listed on Hyperliquid.
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98#[non_exhaustive]
99pub struct HlSpotAssetInfo {
100    /// Token name (e.g. "PURR").
101    pub name: String,
102    /// Token index.
103    pub index: u32,
104    /// Size decimal places.
105    pub sz_decimals: u32,
106    /// Wei decimals for on-chain representation.
107    pub wei_decimals: u32,
108}
109
110impl HlSpotAssetInfo {
111    /// Creates a new `HlSpotAssetInfo`.
112    pub fn new(name: String, index: u32, sz_decimals: u32, wei_decimals: u32) -> Self {
113        Self {
114            name,
115            index,
116            sz_decimals,
117            wei_decimals,
118        }
119    }
120}
121
122/// Spot universe metadata.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[non_exhaustive]
125pub struct HlSpotMeta {
126    /// All spot tokens.
127    pub tokens: Vec<HlSpotAssetInfo>,
128}
129
130impl HlSpotMeta {
131    /// Creates a new `HlSpotMeta`.
132    pub fn new(tokens: Vec<HlSpotAssetInfo>) -> Self {
133        Self { tokens }
134    }
135}
136
137/// Real-time context for a perpetual asset (from `metaAndAssetCtxs`).
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140#[non_exhaustive]
141pub struct AssetContext {
142    /// Current funding rate.
143    pub funding: Decimal,
144    /// Total open interest.
145    pub open_interest: Decimal,
146    /// Mark price.
147    pub mark_px: Decimal,
148}
149
150impl AssetContext {
151    /// Creates a new `AssetContext`.
152    pub fn new(funding: Decimal, open_interest: Decimal, mark_px: Decimal) -> Self {
153        Self {
154            funding,
155            open_interest,
156            mark_px,
157        }
158    }
159}
160
161/// Real-time context for a spot asset (from `spotMetaAndAssetCtxs`).
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164#[non_exhaustive]
165pub struct SpotAssetContext {
166    /// Mark price.
167    pub mark_px: Decimal,
168    /// Mid price.
169    pub mid_px: Decimal,
170}
171
172impl SpotAssetContext {
173    /// Creates a new `SpotAssetContext`.
174    pub fn new(mark_px: Decimal, mid_px: Decimal) -> Self {
175        Self { mark_px, mid_px }
176    }
177}
178
179/// Side of a trade.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
181pub enum TradeSide {
182    /// Buy / bid side.
183    #[serde(rename = "B")]
184    Buy,
185    /// Sell / ask side.
186    #[serde(rename = "A")]
187    Sell,
188}
189
190impl std::fmt::Display for TradeSide {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            TradeSide::Buy => write!(f, "B"),
194            TradeSide::Sell => write!(f, "A"),
195        }
196    }
197}
198
199/// A single recent trade.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202#[non_exhaustive]
203pub struct HlTrade {
204    /// Coin symbol.
205    pub coin: String,
206    /// Trade side.
207    pub side: TradeSide,
208    /// Trade price.
209    pub px: Decimal,
210    /// Trade size.
211    pub sz: Decimal,
212    /// Timestamp in milliseconds.
213    pub time: u64,
214}
215
216impl HlTrade {
217    /// Creates a new `HlTrade`.
218    pub fn new(coin: String, side: TradeSide, px: Decimal, sz: Decimal, time: u64) -> Self {
219        Self {
220            coin,
221            side,
222            px,
223            sz,
224            time,
225        }
226    }
227}
228
229/// A spot token balance.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232#[non_exhaustive]
233pub struct HlSpotBalance {
234    /// Token name.
235    pub coin: String,
236    /// Token index or identifier.
237    pub token: u32,
238    /// Hold amount (available balance).
239    pub hold: Decimal,
240    /// Total amount.
241    pub total: Decimal,
242}
243
244impl HlSpotBalance {
245    /// Creates a new `HlSpotBalance`.
246    pub fn new(coin: String, token: u32, hold: Decimal, total: Decimal) -> Self {
247        Self {
248            coin,
249            token,
250            hold,
251            total,
252        }
253    }
254}
255
256/// Status of a builder-deployed perpetual DEX (HIP-3).
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259#[non_exhaustive]
260pub struct HlPerpDexStatus {
261    /// DEX name/identifier.
262    pub name: String,
263    /// Whether the DEX is active.
264    pub is_active: bool,
265    /// Number of listed assets.
266    pub num_assets: u32,
267    /// Total open interest in USD.
268    pub total_oi: Decimal,
269}
270
271impl HlPerpDexStatus {
272    /// Creates a new `HlPerpDexStatus`.
273    pub fn new(name: String, is_active: bool, num_assets: u32, total_oi: Decimal) -> Self {
274        Self {
275            name,
276            is_active,
277            num_assets,
278            total_oi,
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::str::FromStr;
287
288    #[test]
289    fn orderbook_serde_roundtrip() {
290        let ob = HlOrderbook {
291            coin: "BTC".into(),
292            bids: vec![
293                (
294                    Decimal::from_str("50000.0").unwrap(),
295                    Decimal::from_str("1.5").unwrap(),
296                ),
297                (
298                    Decimal::from_str("49999.0").unwrap(),
299                    Decimal::from_str("2.0").unwrap(),
300                ),
301            ],
302            asks: vec![
303                (
304                    Decimal::from_str("50001.0").unwrap(),
305                    Decimal::from_str("0.5").unwrap(),
306                ),
307                (
308                    Decimal::from_str("50002.0").unwrap(),
309                    Decimal::from_str("3.0").unwrap(),
310                ),
311            ],
312            timestamp: 1700000000000,
313        };
314        let json = serde_json::to_string(&ob).unwrap();
315        let parsed: HlOrderbook = serde_json::from_str(&json).unwrap();
316        assert_eq!(parsed.coin, "BTC");
317        assert_eq!(parsed.bids.len(), 2);
318        assert_eq!(parsed.asks.len(), 2);
319        assert_eq!(parsed.bids[0].0, Decimal::from_str("50000.0").unwrap());
320        assert_eq!(parsed.bids[0].1, Decimal::from_str("1.5").unwrap());
321        assert_eq!(parsed.timestamp, 1700000000000);
322    }
323
324    #[test]
325    fn orderbook_empty_levels_roundtrip() {
326        let ob = HlOrderbook {
327            coin: "SOL".into(),
328            bids: vec![],
329            asks: vec![],
330            timestamp: 0,
331        };
332        let json = serde_json::to_string(&ob).unwrap();
333        let parsed: HlOrderbook = serde_json::from_str(&json).unwrap();
334        assert!(parsed.bids.is_empty());
335        assert!(parsed.asks.is_empty());
336    }
337
338    #[test]
339    fn asset_info_serde_roundtrip() {
340        let info = HlAssetInfo {
341            coin: "BTC".into(),
342            asset_id: 0,
343            min_size: Decimal::from_str("0.001").unwrap(),
344            sz_decimals: 5,
345            px_decimals: 1,
346        };
347        let json = serde_json::to_string(&info).unwrap();
348        let parsed: HlAssetInfo = serde_json::from_str(&json).unwrap();
349        assert_eq!(parsed.coin, "BTC");
350        assert_eq!(parsed.asset_id, 0);
351        assert_eq!(parsed.min_size, Decimal::from_str("0.001").unwrap());
352        assert_eq!(parsed.sz_decimals, 5);
353        assert_eq!(parsed.px_decimals, 1);
354    }
355
356    #[test]
357    fn asset_info_camel_case_keys() {
358        let info = HlAssetInfo {
359            coin: "X".into(),
360            asset_id: 0,
361            min_size: Decimal::ZERO,
362            sz_decimals: 0,
363            px_decimals: 0,
364        };
365        let json = serde_json::to_string(&info).unwrap();
366        assert!(json.contains("assetId"));
367        assert!(json.contains("minSize"));
368        assert!(json.contains("szDecimals"));
369        assert!(json.contains("pxDecimals"));
370    }
371
372    #[test]
373    fn funding_rate_serde_roundtrip() {
374        let fr = HlFundingRate {
375            coin: "ETH".into(),
376            funding_rate: Decimal::from_str("0.0001").unwrap(),
377            next_funding_time: 1700003600000,
378        };
379        let json = serde_json::to_string(&fr).unwrap();
380        let parsed: HlFundingRate = serde_json::from_str(&json).unwrap();
381        assert_eq!(parsed.coin, "ETH");
382        assert_eq!(parsed.funding_rate, Decimal::from_str("0.0001").unwrap());
383        assert_eq!(parsed.next_funding_time, 1700003600000);
384    }
385
386    #[test]
387    fn funding_rate_camel_case_keys() {
388        let fr = HlFundingRate {
389            coin: "X".into(),
390            funding_rate: Decimal::ZERO,
391            next_funding_time: 0,
392        };
393        let json = serde_json::to_string(&fr).unwrap();
394        assert!(json.contains("fundingRate"));
395        assert!(json.contains("nextFundingTime"));
396    }
397
398    #[test]
399    fn spot_asset_info_serde_roundtrip() {
400        let info = HlSpotAssetInfo {
401            name: "PURR".into(),
402            index: 1,
403            sz_decimals: 0,
404            wei_decimals: 18,
405        };
406        let json = serde_json::to_string(&info).unwrap();
407        let parsed: HlSpotAssetInfo = serde_json::from_str(&json).unwrap();
408        assert_eq!(parsed.name, "PURR");
409        assert_eq!(parsed.index, 1);
410        assert_eq!(parsed.sz_decimals, 0);
411        assert_eq!(parsed.wei_decimals, 18);
412    }
413
414    #[test]
415    fn spot_asset_info_camel_case_keys() {
416        let info = HlSpotAssetInfo {
417            name: "X".into(),
418            index: 0,
419            sz_decimals: 0,
420            wei_decimals: 0,
421        };
422        let json = serde_json::to_string(&info).unwrap();
423        assert!(json.contains("szDecimals"));
424        assert!(json.contains("weiDecimals"));
425    }
426
427    #[test]
428    fn spot_meta_serde_roundtrip() {
429        let meta = HlSpotMeta {
430            tokens: vec![HlSpotAssetInfo::new("PURR".into(), 1, 0, 18)],
431        };
432        let json = serde_json::to_string(&meta).unwrap();
433        let parsed: HlSpotMeta = serde_json::from_str(&json).unwrap();
434        assert_eq!(parsed.tokens.len(), 1);
435        assert_eq!(parsed.tokens[0].name, "PURR");
436    }
437
438    #[test]
439    fn spot_balance_serde_roundtrip() {
440        let bal = HlSpotBalance {
441            coin: "PURR".into(),
442            token: 1,
443            hold: Decimal::ZERO,
444            total: Decimal::from_str("1000.0").unwrap(),
445        };
446        let json = serde_json::to_string(&bal).unwrap();
447        let parsed: HlSpotBalance = serde_json::from_str(&json).unwrap();
448        assert_eq!(parsed.coin, "PURR");
449        assert_eq!(parsed.token, 1);
450        assert_eq!(parsed.hold, Decimal::ZERO);
451        assert_eq!(parsed.total, Decimal::from_str("1000.0").unwrap());
452    }
453
454    #[test]
455    fn spot_balance_camel_case_deserialize() {
456        let json = r#"{"coin":"PURR","token":1,"hold":"0","total":"500.0"}"#;
457        let parsed: HlSpotBalance = serde_json::from_str(json).unwrap();
458        assert_eq!(parsed.coin, "PURR");
459        assert_eq!(parsed.total, Decimal::from_str("500.0").unwrap());
460    }
461
462    #[test]
463    fn asset_context_serde_roundtrip() {
464        let ctx = AssetContext {
465            funding: Decimal::from_str("0.0001").unwrap(),
466            open_interest: Decimal::from_str("50000.0").unwrap(),
467            mark_px: Decimal::from_str("94000.0").unwrap(),
468        };
469        let json = serde_json::to_string(&ctx).unwrap();
470        let parsed: AssetContext = serde_json::from_str(&json).unwrap();
471        assert_eq!(parsed, ctx);
472    }
473
474    #[test]
475    fn asset_context_camel_case_keys() {
476        let ctx = AssetContext::new(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
477        let json = serde_json::to_string(&ctx).unwrap();
478        assert!(json.contains("openInterest"));
479        assert!(json.contains("markPx"));
480    }
481
482    #[test]
483    fn asset_context_camel_case_deserialize() {
484        let json = r#"{"funding":"0.0001","openInterest":"50000.0","markPx":"94000.0"}"#;
485        let parsed: AssetContext = serde_json::from_str(json).unwrap();
486        assert_eq!(parsed.funding, Decimal::from_str("0.0001").unwrap());
487        assert_eq!(parsed.open_interest, Decimal::from_str("50000.0").unwrap());
488        assert_eq!(parsed.mark_px, Decimal::from_str("94000.0").unwrap());
489    }
490
491    #[test]
492    fn spot_asset_context_serde_roundtrip() {
493        let ctx = SpotAssetContext {
494            mark_px: Decimal::from_str("1.05").unwrap(),
495            mid_px: Decimal::from_str("1.04").unwrap(),
496        };
497        let json = serde_json::to_string(&ctx).unwrap();
498        let parsed: SpotAssetContext = serde_json::from_str(&json).unwrap();
499        assert_eq!(parsed, ctx);
500    }
501
502    #[test]
503    fn spot_asset_context_camel_case_keys() {
504        let ctx = SpotAssetContext::new(Decimal::ZERO, Decimal::ZERO);
505        let json = serde_json::to_string(&ctx).unwrap();
506        assert!(json.contains("markPx"));
507        assert!(json.contains("midPx"));
508    }
509
510    #[test]
511    fn spot_asset_context_camel_case_deserialize() {
512        let json = r#"{"markPx":"1.05","midPx":"1.04"}"#;
513        let parsed: SpotAssetContext = serde_json::from_str(json).unwrap();
514        assert_eq!(parsed.mark_px, Decimal::from_str("1.05").unwrap());
515        assert_eq!(parsed.mid_px, Decimal::from_str("1.04").unwrap());
516    }
517
518    #[test]
519    fn perp_dex_status_serde_roundtrip() {
520        let status = HlPerpDexStatus {
521            name: "HyperBTC".into(),
522            is_active: true,
523            num_assets: 5,
524            total_oi: Decimal::from_str("1000000.0").unwrap(),
525        };
526        let json = serde_json::to_string(&status).unwrap();
527        let parsed: HlPerpDexStatus = serde_json::from_str(&json).unwrap();
528        assert_eq!(parsed.name, "HyperBTC");
529        assert!(parsed.is_active);
530        assert_eq!(parsed.num_assets, 5);
531        assert_eq!(parsed.total_oi, Decimal::from_str("1000000.0").unwrap());
532    }
533
534    #[test]
535    fn perp_dex_status_camel_case_keys() {
536        let status = HlPerpDexStatus {
537            name: "X".into(),
538            is_active: false,
539            num_assets: 0,
540            total_oi: Decimal::ZERO,
541        };
542        let json = serde_json::to_string(&status).unwrap();
543        assert!(json.contains("isActive"));
544        assert!(json.contains("numAssets"));
545        assert!(json.contains("totalOi"));
546    }
547
548    #[test]
549    fn perp_dex_status_camel_case_deserialize() {
550        let json = r#"{"name":"TestDex","isActive":true,"numAssets":3,"totalOi":"500000.0"}"#;
551        let parsed: HlPerpDexStatus = serde_json::from_str(json).unwrap();
552        assert_eq!(parsed.name, "TestDex");
553        assert!(parsed.is_active);
554        assert_eq!(parsed.num_assets, 3);
555        assert_eq!(parsed.total_oi, Decimal::from_str("500000.0").unwrap());
556    }
557}