Skip to main content

df_hyperliquid_core/utils/
rounding.rs

1//! Where prices and sizes may sit on a perp or a spot pair: a price keeps at most
2//! `6 - szDecimals` places on a perp and `8 - szDecimals` on spot, then five significant figures
3//! unless it is whole; a size keeps `szDecimals`. A market order's bound is the reference moved by
4//! the slippage. HIP-4 outcomes have their own grid ([`crate::markets::outcomes::px`]).
5
6use crate::markets::Side;
7use crate::trade::error::TradeError;
8use crate::utils::{Dec, Round};
9
10/// A price keeps at most this many significant figures, unless it is whole.
11pub const SIGNIFICANT_FIGURES: u32 = 5;
12
13/// Places a perp price may use before its size decimals are taken off.
14pub const PERP_PRICE_PLACES: u32 = 6;
15
16/// Places a spot price may use before its size decimals are taken off.
17pub const SPOT_PRICE_PLACES: u32 = 8;
18
19/// The venue refuses an order worth less than this, in the quote.
20pub const MINIMUM_ORDER_VALUE: Dec = Dec::from_int(10);
21
22/// The most slippage a market order is let take: half the price.
23pub const MAX_SLIPPAGE: Dec = Dec::lit::<1>(5);
24
25/// One asset's grid: its size decimals, and whether its prices take the perp
26/// or the spot ceiling.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct Grid {
29    sz_decimals: u32,
30    ceiling: u32,
31}
32
33impl Grid {
34    /// A perp, main dex or HIP-3, by its `szDecimals` in `meta`.
35    #[must_use]
36    pub const fn perp(sz_decimals: u32) -> Grid {
37        Grid { sz_decimals, ceiling: PERP_PRICE_PLACES }
38    }
39
40    /// A spot pair, by its base token's `szDecimals` in `spotMeta`.
41    #[must_use]
42    pub const fn spot(sz_decimals: u32) -> Grid {
43        Grid { sz_decimals, ceiling: SPOT_PRICE_PLACES }
44    }
45
46    #[must_use]
47    pub const fn sz_decimals(self) -> u32 {
48        self.sz_decimals
49    }
50
51    /// Places a price may use: six (perp) or eight (spot), less the size
52    /// decimals.
53    #[must_use]
54    pub const fn price_places(self) -> u32 {
55        self.ceiling.saturating_sub(self.sz_decimals)
56    }
57
58    /// `price` on the grid, rounded as asked, once: at the coarser of the
59    /// places allowed and five significant figures, and never past the whole
60    /// part (a whole price is always taken). Rounding to the places and then
61    /// to the figures would round twice: to the nearest, `81800.45` would
62    /// become `81801`, not `81800`. `None` when nothing positive is left.
63    #[must_use]
64    pub fn tick(self, price: Dec, round: Round) -> Option<Dec> {
65        if !price.is_positive() {
66            return None;
67        }
68        let places = self.price_places().min(price.sig_places(SIGNIFICANT_FIGURES));
69        let kept = price.round(places, round);
70        kept.is_positive().then_some(kept)
71    }
72
73    /// A limit price for `side`, never worse for the trader than asked: a buy
74    /// rounds down and a sell up.
75    #[must_use]
76    pub fn price(self, price: Dec, side: Side) -> Option<Dec> {
77        self.tick(price, side.price_rounding())
78    }
79
80    /// Whether the venue takes `price` exactly as written.
81    #[must_use]
82    pub fn is_on_grid(self, price: Dec) -> bool {
83        self.tick(price, Round::Down) == Some(price)
84    }
85
86    /// A size cut toward zero to the size decimals. `None` when nothing is
87    /// left.
88    #[must_use]
89    pub fn size(self, size: Dec) -> Option<Dec> {
90        if !size.is_positive() {
91            return None;
92        }
93        let kept = size.round(self.sz_decimals, Round::Down);
94        kept.is_positive().then_some(kept)
95    }
96
97    /// The worst price a market order takes: `reference` moved by `slippage`
98    /// (a fraction, 0.01 for 1%) on the side that costs more, then rounded
99    /// back toward `reference`, so the bound is never looser than asked.
100    pub fn market_bound(self, reference: Dec, slippage: Dec, side: Side) -> Result<Dec, TradeError> {
101        if slippage.is_negative() || slippage > MAX_SLIPPAGE {
102            return Err(TradeError::Slippage(slippage));
103        }
104        let factor = match side {
105            Side::Buy => Dec::ONE.checked_add(slippage),
106            Side::Sell => Dec::ONE.checked_sub(slippage),
107        };
108        factor
109            .and_then(|factor| reference.checked_mul(factor))
110            .and_then(|moved| self.price(moved, side))
111            .ok_or(TradeError::NoPrice(reference))
112    }
113
114    /// The smallest size worth [`MINIMUM_ORDER_VALUE`] at `price`, rounded up
115    /// to the size decimals.
116    #[must_use]
117    pub fn least_size(self, price: Dec) -> Option<Dec> {
118        let exact = MINIMUM_ORDER_VALUE.checked_div(price, 12, Round::Up)?;
119        let size = exact.round(self.sz_decimals, Round::Up);
120        size.is_positive().then_some(size)
121    }
122
123    /// Whether `size` at `price` is worth the venue's minimum.
124    #[must_use]
125    pub fn meets_minimum(price: Dec, size: Dec) -> bool {
126        price.checked_mul(size).is_some_and(|worth| worth >= MINIMUM_ORDER_VALUE)
127    }
128}
129
130#[cfg(test)]
131#[path = "tests/rounding.rs"]
132mod tests;