Skip to main content

rustrade_execution/client/hyperliquid/
precision.rs

1//! Hyperliquid's precision rules for order sizes and prices, and the check an order passes before
2//! it is sent.
3//!
4//! From Hyperliquid's [tick and lot size rules]:
5//! - A **size** has at most the asset's `szDecimals` decimal places.
6//! - A **price** has at most 5 significant figures, and at most `MAX_DECIMALS - szDecimals`
7//!   decimal places, where `MAX_DECIMALS` is 6 for perpetuals and 8 for spot. An integer price is
8//!   always valid, whatever its number of significant figures.
9//!
10//! A spot pair's `szDecimals` is its base token's.
11//!
12//! [tick and lot size rules]: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
13
14use crate::{
15    error::{OrderField, PrecisionLimit, PrecisionViolation},
16    order::OrderKind,
17};
18use hyperliquid_rust_sdk::{ClientLimit, ClientOrder, ClientTrigger};
19use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
20use std::str::FromStr;
21
22/// The most decimal places a perpetual's price may have, before its `szDecimals` are taken off.
23const PERP_MAX_DECIMALS: u32 = 6;
24
25/// The most decimal places a spot pair's price may have, before its `szDecimals` are taken off.
26const SPOT_MAX_DECIMALS: u32 = 8;
27
28/// The decimal places Hyperliquid's SDK writes a number to before trimming trailing zeros. It
29/// takes every size and price as an `f64`, so a value is sent exactly only if formatting that
30/// `f64` to this many places gives the value back.
31const SDK_WIRE_DECIMALS: usize = 8;
32
33/// Hyperliquid's precision rules for one market's orders. See the [module docs](self).
34///
35/// Read it from [`HyperliquidClient::order_precision`](super::HyperliquidClient::order_precision)
36/// or [`HyperliquidSpotClient::order_precision`](super::spot::HyperliquidSpotClient::order_precision),
37/// or build it from metadata with [`perp`](Self::perp) or [`spot`](Self::spot).
38///
39/// The clients refuse an order whose quantity, price or trigger price breaks these rules with
40/// [`OrderError::InvalidPrecision`](crate::error::OrderError::InvalidPrecision), and never round
41/// it themselves. Round with [`round_quantity`](Self::round_quantity) and
42/// [`round_price`](Self::round_price) first, in the direction your strategy needs.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct OrderPrecision {
45    sz_decimals: u32,
46    max_decimals: u32,
47}
48
49impl OrderPrecision {
50    /// The most significant figures a price that is not an integer may have.
51    pub const MAX_SIGNIFICANT_FIGURES: u32 = 5;
52
53    /// The rules for a perpetual whose `szDecimals` is `sz_decimals`.
54    pub const fn perp(sz_decimals: u32) -> Self {
55        Self {
56            sz_decimals,
57            max_decimals: PERP_MAX_DECIMALS,
58        }
59    }
60
61    /// The rules for a spot pair whose base token's `szDecimals` is `base_sz_decimals`.
62    pub const fn spot(base_sz_decimals: u32) -> Self {
63        Self {
64            sz_decimals: base_sz_decimals,
65            max_decimals: SPOT_MAX_DECIMALS,
66        }
67    }
68
69    /// The most decimal places a quantity may have: the asset's `szDecimals`.
70    pub const fn quantity_decimals(&self) -> u32 {
71        self.sz_decimals
72    }
73
74    /// The most decimal places a price may have: `MAX_DECIMALS - szDecimals`, and never fewer
75    /// than zero.
76    pub const fn price_decimals(&self) -> u32 {
77        self.max_decimals.saturating_sub(self.sz_decimals)
78    }
79
80    /// Check that `quantity` is positive, meets the rules, and can be sent exactly.
81    ///
82    /// # Errors
83    ///
84    /// The rule `quantity` breaks, as a [`PrecisionViolation`] naming
85    /// [`OrderField::Quantity`].
86    pub fn check_quantity(&self, quantity: Decimal) -> Result<(), PrecisionViolation> {
87        self.wire_quantity(quantity).map(drop)
88    }
89
90    /// Check that the limit price `price` is positive, meets the rules, and can be sent exactly.
91    ///
92    /// # Errors
93    ///
94    /// The rule `price` breaks, as a [`PrecisionViolation`] naming [`OrderField::Price`].
95    pub fn check_price(&self, price: Decimal) -> Result<(), PrecisionViolation> {
96        self.wire_price(OrderField::Price, price).map(drop)
97    }
98
99    /// Check a stop or take-profit order's `trigger_price` as [`check_price`](Self::check_price)
100    /// checks a limit price: it follows the same rules.
101    ///
102    /// # Errors
103    ///
104    /// The rule `trigger_price` breaks, as a [`PrecisionViolation`] naming
105    /// [`OrderField::TriggerPrice`].
106    pub fn check_trigger_price(&self, trigger_price: Decimal) -> Result<(), PrecisionViolation> {
107        self.wire_price(OrderField::TriggerPrice, trigger_price)
108            .map(drop)
109    }
110
111    /// `quantity` rounded to the decimal places the rules allow, by `strategy`.
112    ///
113    /// A quantity smaller than the smallest step can round to zero, which
114    /// [`check_quantity`](Self::check_quantity) refuses.
115    pub fn round_quantity(&self, quantity: Decimal, strategy: RoundingStrategy) -> Decimal {
116        quantity
117            .round_dp_with_strategy(self.quantity_decimals(), strategy)
118            .normalize()
119    }
120
121    /// `price` rounded by `strategy` to the decimal places and significant figures the rules
122    /// allow. An integer price is returned unchanged.
123    ///
124    /// A price smaller than the smallest step the decimal places allow can round to zero, which
125    /// [`check_price`](Self::check_price) refuses.
126    pub fn round_price(&self, price: Decimal, strategy: RoundingStrategy) -> Decimal {
127        let price = price.normalize();
128        if price.scale() == 0 {
129            return price;
130        }
131        // The decimal places that keep `MAX_SIGNIFICANT_FIGURES`: a value with `digits` digits
132        // and `scale` decimal places has `digits - scale` digits before the point.
133        let significant = (price.scale() + Self::MAX_SIGNIFICANT_FIGURES)
134            .saturating_sub(significant_digits(price));
135        price
136            .round_dp_with_strategy(significant.min(self.price_decimals()), strategy)
137            .normalize()
138    }
139
140    /// `quantity` as the `f64` the SDK takes, once checked.
141    fn wire_quantity(&self, quantity: Decimal) -> Result<f64, PrecisionViolation> {
142        let violation = |limit| PrecisionViolation::new(OrderField::Quantity, quantity, limit);
143        if quantity <= Decimal::ZERO {
144            return Err(violation(PrecisionLimit::NotPositive));
145        }
146        if quantity.normalize().scale() > self.quantity_decimals() {
147            return Err(violation(PrecisionLimit::DecimalPlaces {
148                max: self.quantity_decimals(),
149            }));
150        }
151        to_wire(quantity).ok_or_else(|| violation(PrecisionLimit::NotRepresentable))
152    }
153
154    /// `price`, the request's `field`, as the `f64` the SDK takes, once checked.
155    fn wire_price(&self, field: OrderField, price: Decimal) -> Result<f64, PrecisionViolation> {
156        let violation = |limit| PrecisionViolation::new(field, price, limit);
157        if price <= Decimal::ZERO {
158            return Err(violation(PrecisionLimit::NotPositive));
159        }
160        let normalized = price.normalize();
161        if normalized.scale() > 0 {
162            if normalized.scale() > self.price_decimals() {
163                return Err(violation(PrecisionLimit::DecimalPlaces {
164                    max: self.price_decimals(),
165                }));
166            }
167            if significant_digits(normalized) > Self::MAX_SIGNIFICANT_FIGURES {
168                return Err(violation(PrecisionLimit::SignificantFigures {
169                    max: Self::MAX_SIGNIFICANT_FIGURES,
170                }));
171            }
172        }
173        to_wire(price).ok_or_else(|| violation(PrecisionLimit::NotRepresentable))
174    }
175}
176
177/// The digits of `value`'s mantissa: its significant figures once normalized, for a value that
178/// is not an integer.
179fn significant_digits(value: Decimal) -> u32 {
180    value
181        .mantissa()
182        .unsigned_abs()
183        .checked_ilog10()
184        .map_or(1, |log| log + 1)
185}
186
187/// `value` as an `f64`, if the SDK would send it unchanged.
188fn to_wire(value: Decimal) -> Option<f64> {
189    let float = value.to_f64()?;
190    let sent = Decimal::from_str(&format!("{float:.SDK_WIRE_DECIMALS$}")).ok()?;
191    (sent == value).then_some(float)
192}
193
194/// An order's values in the form the SDK places it in.
195#[derive(Debug)]
196pub(super) struct WireOrder {
197    pub(super) limit_px: f64,
198    pub(super) sz: f64,
199    pub(super) order_type: ClientOrder,
200}
201
202/// Why an order cannot be put in the form the SDK places it in.
203#[derive(Debug, PartialEq)]
204pub(super) enum WireOrderError {
205    /// A limit order, or a limit order a trigger places, with no limit price.
206    MissingPrice,
207    /// A kind Hyperliquid does not support. The clients refuse these before building the order.
208    UnsupportedKind,
209    /// A value breaks the market's precision rules.
210    Precision(PrecisionViolation),
211}
212
213impl From<PrecisionViolation> for WireOrderError {
214    fn from(violation: PrecisionViolation) -> Self {
215        Self::Precision(violation)
216    }
217}
218
219/// Check an order's values against `precision`, and put them in the form the SDK places them
220/// in, unchanged.
221///
222/// A trigger order that places a market order (`Stop`, `TakeProfit`) sends its trigger price as
223/// its limit price, as the SDK requires.
224pub(super) fn wire_order(
225    precision: &OrderPrecision,
226    kind: OrderKind,
227    tif: String,
228    price: Option<Decimal>,
229    quantity: Decimal,
230) -> Result<WireOrder, WireOrderError> {
231    let sz = precision.wire_quantity(quantity)?;
232    let limit_price = || -> Result<f64, WireOrderError> {
233        let price = price.ok_or(WireOrderError::MissingPrice)?;
234        Ok(precision.wire_price(OrderField::Price, price)?)
235    };
236    let trigger = |trigger_price, is_market, tpsl: &str| -> Result<_, WireOrderError> {
237        let trigger_px = precision.wire_price(OrderField::TriggerPrice, trigger_price)?;
238        let order_type = ClientOrder::Trigger(ClientTrigger {
239            is_market,
240            trigger_px,
241            tpsl: tpsl.to_owned(),
242        });
243        let limit_px = if is_market {
244            trigger_px
245        } else {
246            limit_price()?
247        };
248        Ok(WireOrder {
249            limit_px,
250            sz,
251            order_type,
252        })
253    };
254
255    match kind {
256        OrderKind::Limit => Ok(WireOrder {
257            limit_px: limit_price()?,
258            sz,
259            order_type: ClientOrder::Limit(ClientLimit { tif }),
260        }),
261        OrderKind::Stop { trigger_price } => trigger(trigger_price, true, "sl"),
262        OrderKind::StopLimit { trigger_price } => trigger(trigger_price, false, "sl"),
263        OrderKind::TakeProfit { trigger_price } => trigger(trigger_price, true, "tp"),
264        OrderKind::TakeProfitLimit { trigger_price } => trigger(trigger_price, false, "tp"),
265        OrderKind::Market
266        | OrderKind::TrailingStop { .. }
267        | OrderKind::TrailingStopLimit { .. }
268        | OrderKind::TrailingStopFixedLimit { .. }
269        | OrderKind::ConditionalUnresolved { .. } => Err(WireOrderError::UnsupportedKind),
270    }
271}
272
273#[cfg(test)]
274// Test code: panics on bad input are acceptable
275#[allow(clippy::unwrap_used)]
276mod tests {
277    use super::*;
278    use rust_decimal_macros::dec;
279
280    fn violation(field: OrderField, value: Decimal, limit: PrecisionLimit) -> PrecisionViolation {
281        PrecisionViolation {
282            field,
283            value,
284            limit,
285        }
286    }
287
288    #[test]
289    fn a_quantity_may_have_at_most_sz_decimals_places() {
290        let three = OrderPrecision::perp(3);
291        assert_eq!(three.check_quantity(dec!(1.001)), Ok(()));
292        assert_eq!(
293            three.check_quantity(dec!(1.00100)),
294            Ok(()),
295            "trailing zeros do not count"
296        );
297        assert_eq!(
298            three.check_quantity(dec!(1.0001)),
299            Err(violation(
300                OrderField::Quantity,
301                dec!(1.0001),
302                PrecisionLimit::DecimalPlaces { max: 3 }
303            ))
304        );
305
306        let zero = OrderPrecision::perp(0);
307        assert_eq!(
308            zero.check_quantity(dec!(123456)),
309            Ok(()),
310            "an integer quantity is not rounded to significant figures"
311        );
312        assert!(zero.check_quantity(dec!(1.5)).is_err());
313    }
314
315    #[test]
316    fn an_integer_price_is_valid_whatever_its_significant_figures() {
317        let perp = OrderPrecision::perp(5);
318        assert_eq!(perp.check_price(dec!(123456)), Ok(()));
319        assert_eq!(perp.check_price(dec!(123456.0)), Ok(()));
320        assert_eq!(
321            perp.check_price(dec!(12345.6)),
322            Err(violation(
323                OrderField::Price,
324                dec!(12345.6),
325                PrecisionLimit::SignificantFigures { max: 5 }
326            ))
327        );
328    }
329
330    /// The examples in Hyperliquid's tick and lot size documentation.
331    #[test]
332    fn a_price_follows_the_documented_examples() {
333        let perp = OrderPrecision::perp(0);
334        assert_eq!(perp.check_price(dec!(1234.5)), Ok(()));
335        assert_eq!(
336            perp.check_price(dec!(1234.56)),
337            Err(violation(
338                OrderField::Price,
339                dec!(1234.56),
340                PrecisionLimit::SignificantFigures { max: 5 }
341            ))
342        );
343        assert_eq!(perp.check_price(dec!(0.001234)), Ok(()));
344        assert_eq!(
345            perp.check_price(dec!(0.0012345)),
346            Err(violation(
347                OrderField::Price,
348                dec!(0.0012345),
349                PrecisionLimit::DecimalPlaces { max: 6 }
350            ))
351        );
352
353        let perp_one = OrderPrecision::perp(1);
354        assert_eq!(perp_one.check_price(dec!(0.01234)), Ok(()));
355        assert_eq!(
356            perp_one.check_price(dec!(0.012345)),
357            Err(violation(
358                OrderField::Price,
359                dec!(0.012345),
360                PrecisionLimit::DecimalPlaces { max: 5 }
361            ))
362        );
363
364        // 0.0001234 is valid on spot with szDecimals 0 or 1, and not above 2.
365        assert_eq!(OrderPrecision::spot(0).check_price(dec!(0.0001234)), Ok(()));
366        assert_eq!(OrderPrecision::spot(1).check_price(dec!(0.0001234)), Ok(()));
367        assert!(
368            OrderPrecision::spot(3)
369                .check_price(dec!(0.0001234))
370                .is_err()
371        );
372    }
373
374    #[test]
375    fn a_value_that_is_not_positive_is_refused() {
376        let perp = OrderPrecision::perp(2);
377        for value in [dec!(0), dec!(0.00), dec!(-1), dec!(-0.5)] {
378            assert_eq!(
379                perp.check_quantity(value),
380                Err(violation(
381                    OrderField::Quantity,
382                    value,
383                    PrecisionLimit::NotPositive
384                ))
385            );
386            assert_eq!(
387                perp.check_price(value),
388                Err(violation(
389                    OrderField::Price,
390                    value,
391                    PrecisionLimit::NotPositive
392                ))
393            );
394            assert_eq!(
395                perp.check_trigger_price(value),
396                Err(violation(
397                    OrderField::TriggerPrice,
398                    value,
399                    PrecisionLimit::NotPositive
400                ))
401            );
402        }
403    }
404
405    #[test]
406    fn a_value_rounded_to_zero_is_refused() {
407        let quantity =
408            OrderPrecision::perp(2).round_quantity(dec!(0.004), RoundingStrategy::ToZero);
409        assert_eq!(quantity, Decimal::ZERO);
410        assert!(OrderPrecision::perp(2).check_quantity(quantity).is_err());
411
412        // A perpetual with szDecimals 6 allows no decimal places in a price.
413        let precision = OrderPrecision::perp(6);
414        let price = precision.round_price(dec!(0.4), RoundingStrategy::ToZero);
415        assert_eq!(price, Decimal::ZERO);
416        assert_eq!(
417            precision.check_price(price),
418            Err(violation(
419                OrderField::Price,
420                price,
421                PrecisionLimit::NotPositive
422            ))
423        );
424    }
425
426    #[test]
427    fn a_trigger_price_follows_the_price_rules_under_its_own_name() {
428        let perp = OrderPrecision::perp(0);
429        assert_eq!(perp.check_trigger_price(dec!(1234.5)), Ok(()));
430        assert_eq!(
431            perp.check_trigger_price(dec!(1234.56)),
432            Err(violation(
433                OrderField::TriggerPrice,
434                dec!(1234.56),
435                PrecisionLimit::SignificantFigures { max: 5 }
436            ))
437        );
438    }
439
440    #[test]
441    fn the_price_cap_never_goes_below_zero_decimal_places() {
442        let precision = OrderPrecision::perp(8);
443        assert_eq!(precision.price_decimals(), 0);
444        assert_eq!(precision.check_price(dec!(42)), Ok(()));
445        assert!(precision.check_price(dec!(42.5)).is_err());
446    }
447
448    #[test]
449    fn rounding_meets_the_rules_in_the_direction_asked() {
450        let perp = OrderPrecision::perp(2);
451        assert_eq!(
452            perp.round_quantity(dec!(1.239), RoundingStrategy::ToZero),
453            dec!(1.23)
454        );
455        assert_eq!(
456            perp.round_quantity(dec!(1.231), RoundingStrategy::AwayFromZero),
457            dec!(1.24)
458        );
459
460        // Significant figures bind: 5 of them leave 1 decimal place.
461        assert_eq!(
462            perp.round_price(dec!(1234.56), RoundingStrategy::ToZero),
463            dec!(1234.5)
464        );
465        // Decimal places bind: perp with szDecimals 2 allows 4.
466        assert_eq!(
467            perp.round_price(dec!(0.0012345), RoundingStrategy::MidpointNearestEven),
468            dec!(0.0012)
469        );
470        assert_eq!(
471            perp.round_price(dec!(123456.7), RoundingStrategy::ToZero),
472            dec!(123456)
473        );
474        assert_eq!(
475            perp.round_price(dec!(123456), RoundingStrategy::ToZero),
476            dec!(123456),
477            "an integer price is returned unchanged"
478        );
479        assert_eq!(
480            perp.round_price(dec!(9.99999), RoundingStrategy::AwayFromZero),
481            dec!(10)
482        );
483
484        for value in [
485            dec!(1234.56),
486            dec!(0.0012345),
487            dec!(9.99999),
488            dec!(0.123456789),
489        ] {
490            for strategy in [RoundingStrategy::ToZero, RoundingStrategy::AwayFromZero] {
491                let rounded = perp.round_price(value, strategy);
492                assert_eq!(perp.check_price(rounded), Ok(()), "{value} -> {rounded}");
493            }
494        }
495    }
496
497    #[test]
498    fn a_value_the_sdk_would_change_is_not_representable() {
499        // Valid by the rules (an integer), but past what an f64 holds exactly.
500        let huge = dec!(12345678901234567890123);
501        assert_eq!(
502            OrderPrecision::perp(0).check_price(huge),
503            Err(violation(
504                OrderField::Price,
505                huge,
506                PrecisionLimit::NotRepresentable
507            ))
508        );
509        // Within the decimal places allowed, but with more digits than an f64 holds.
510        let long = dec!(12345678901234.12345678);
511        assert_eq!(
512            OrderPrecision::spot(8).check_quantity(long),
513            Err(violation(
514                OrderField::Quantity,
515                long,
516                PrecisionLimit::NotRepresentable
517            ))
518        );
519        assert_eq!(to_wire(dec!(0.00000001)), Some(0.00000001));
520        assert_eq!(to_wire(dec!(123456.12345678)), Some(123456.12345678));
521    }
522
523    #[test]
524    fn a_wire_order_carries_the_values_unchanged() {
525        let precision = OrderPrecision::perp(2);
526        let order = wire_order(
527            &precision,
528            OrderKind::StopLimit {
529                trigger_price: dec!(95),
530            },
531            "Gtc".to_owned(),
532            Some(dec!(94.5)),
533            dec!(1.25),
534        )
535        .unwrap();
536        assert_eq!((order.limit_px, order.sz), (94.5, 1.25));
537        let ClientOrder::Trigger(trigger) = order.order_type else {
538            panic!("expected a trigger order, got {:?}", order.order_type);
539        };
540        assert_eq!(
541            (trigger.trigger_px, trigger.is_market, trigger.tpsl.as_str()),
542            (95.0, false, "sl")
543        );
544
545        let market_trigger = wire_order(
546            &precision,
547            OrderKind::TakeProfit {
548                trigger_price: dec!(120),
549            },
550            "Gtc".to_owned(),
551            None,
552            dec!(1),
553        )
554        .unwrap();
555        assert_eq!(
556            market_trigger.limit_px, 120.0,
557            "a market trigger's limit price is its trigger price"
558        );
559    }
560
561    #[test]
562    fn a_wire_order_refuses_each_value_that_breaks_the_rules() {
563        let precision = OrderPrecision::perp(2);
564        let refused = |kind, price, quantity| {
565            wire_order(&precision, kind, "Gtc".to_owned(), price, quantity).unwrap_err()
566        };
567
568        assert_eq!(
569            refused(OrderKind::Limit, Some(dec!(100)), dec!(1.234)),
570            WireOrderError::Precision(violation(
571                OrderField::Quantity,
572                dec!(1.234),
573                PrecisionLimit::DecimalPlaces { max: 2 }
574            ))
575        );
576        assert_eq!(
577            refused(OrderKind::Limit, Some(dec!(100.123456)), dec!(1)),
578            WireOrderError::Precision(violation(
579                OrderField::Price,
580                dec!(100.123456),
581                PrecisionLimit::DecimalPlaces { max: 4 }
582            ))
583        );
584        assert_eq!(
585            refused(
586                OrderKind::Stop {
587                    trigger_price: dec!(1234.56)
588                },
589                None,
590                dec!(1)
591            ),
592            WireOrderError::Precision(violation(
593                OrderField::TriggerPrice,
594                dec!(1234.56),
595                PrecisionLimit::SignificantFigures { max: 5 }
596            ))
597        );
598        assert_eq!(
599            refused(OrderKind::Limit, None, dec!(1)),
600            WireOrderError::MissingPrice
601        );
602        assert_eq!(
603            refused(OrderKind::Market, None, dec!(1)),
604            WireOrderError::UnsupportedKind
605        );
606        assert_eq!(
607            refused(
608                OrderKind::TrailingStopFixedLimit {
609                    offset: dec!(1),
610                    offset_type: crate::order::TrailingOffsetType::Percentage,
611                    activation_price: None,
612                },
613                Some(dec!(100)),
614                dec!(1)
615            ),
616            WireOrderError::UnsupportedKind
617        );
618        assert_eq!(
619            refused(
620                OrderKind::ConditionalUnresolved {
621                    family: crate::order::ConditionalFamily::StopLimit,
622                    trigger_price: Some(dec!(95)),
623                },
624                Some(dec!(100)),
625                dec!(1)
626            ),
627            WireOrderError::UnsupportedKind
628        );
629    }
630}