Skip to main content

df_hyperliquid_core/markets/
book.rs

1//! `l2Book`: a market's visible book, the same shape from `/info` and from the socket, and the
2//! precision it is asked at. Prices and sizes are exact [`Dec`] values read from the venue's strings.
3
4use serde::Deserialize;
5
6use crate::client::{ApiError, Transport, info, info_request};
7use crate::utils::Json;
8use crate::utils::dec::{Dec, Round};
9
10/// How finely a book's levels are bucketed: full precision, or 2 to 5
11/// significant figures, and at 5 figures a step of 1, 2 or 5 in the last.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub struct Precision {
14    figures: Option<u32>,
15    step: Option<u32>,
16}
17
18impl Precision {
19    pub const FULL: Precision = Precision { figures: None, step: None };
20
21    /// `nSigFigs`: 2, 3, 4 or 5.
22    #[must_use]
23    pub const fn figures(figures: u32) -> Option<Precision> {
24        match figures {
25            2..=5 => Some(Precision { figures: Some(figures), step: None }),
26            _ => None,
27        }
28    }
29
30    /// `mantissa`: 1, 2 or 5, and only at five figures.
31    #[must_use]
32    pub const fn step(self, step: u32) -> Option<Precision> {
33        match (self.figures, step) {
34            (Some(5), 1 | 2 | 5) => Some(Precision { figures: Some(5), step: Some(step) }),
35            _ => None,
36        }
37    }
38}
39
40/// `{"type":"l2Book","coin":..,"nSigFigs"?,"mantissa"?}`. A coin is `BTC`, `xyz:XYZ100` on a HIP-3
41/// dex, `@107` or `PURR/USDC` for a pair, `#14730` for an outcome side.
42#[must_use]
43pub fn l2_book_request(coin: &str, precision: Precision) -> String {
44    let number = |value: u32| Json::Number(value.to_string());
45    let figures = precision.figures.map(|figures| ("nSigFigs", number(figures)));
46    let step = precision.step.map(|step| ("mantissa", number(step)));
47    let fields: Vec<(&str, Json)> =
48        [Some(("coin", Json::String(coin.to_owned()))), figures, step].into_iter().flatten().collect();
49    info_request("l2Book", &fields)
50}
51
52pub async fn l2_book(transport: &impl Transport, coin: &str, precision: Precision) -> Result<Book, ApiError> {
53    info(transport, l2_book_request(coin, precision)).await
54}
55
56/// One price level: `n` orders resting at `px` for `sz` in all.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
58pub struct Level {
59    pub px: Dec,
60    pub sz: Dec,
61    /// Number of orders at this price, not an amount.
62    pub n: u32,
63}
64
65/// `l2Book`: at most twenty levels a side, best first. There is no sequence number; each read
66/// or frame replaces the last.
67#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
68#[serde(from = "Raw")]
69pub struct Book {
70    pub coin: String,
71    /// Milliseconds.
72    pub time: u64,
73    /// Highest first.
74    pub bids: Vec<Level>,
75    /// Lowest first.
76    pub asks: Vec<Level>,
77    /// Sent with a bucketed book (`nSigFigs`): the gap between the best levels.
78    pub spread: Option<Dec>,
79}
80
81/// `levels` on the wire: bids, then asks.
82#[derive(Deserialize)]
83struct Raw {
84    coin: String,
85    time: u64,
86    levels: (Vec<Level>, Vec<Level>),
87    #[serde(default)]
88    spread: Option<Dec>,
89}
90
91impl From<Raw> for Book {
92    fn from(raw: Raw) -> Self {
93        let (bids, asks) = raw.levels;
94        Book { coin: raw.coin, time: raw.time, bids, asks, spread: raw.spread }
95    }
96}
97
98impl Book {
99    /// The best bid and the best ask.
100    #[must_use]
101    pub fn touch(&self) -> (Option<Dec>, Option<Dec>) {
102        (self.bids.first().map(|level| level.px), self.asks.first().map(|level| level.px))
103    }
104
105    /// Exactly halfway between the best bid and ask, one place finer than either. `None` when a
106    /// side is empty.
107    #[must_use]
108    pub fn mid(&self) -> Option<Dec> {
109        let (Some(bid), Some(ask)) = self.touch() else { return None };
110        halfway(bid, ask)
111    }
112}
113
114/// Exactly halfway between two prices, one place finer than either.
115fn halfway(bid: Dec, ask: Dec) -> Option<Dec> {
116    let places = bid.places().max(ask.places()).saturating_add(1);
117    bid.checked_add(ask)?.checked_div(Dec::from_int(2), places, Round::Down)
118}
119
120#[cfg(test)]
121#[path = "tests/book.rs"]
122mod tests;