Skip to main content

df_hyperliquid_core/utils/
figure.rs

1//! A few reads send a number as a bare JSON float rather than a decimal
2//! string: `vaultDetails` writes `"apr":0.037456007367970744`. serde_json
3//! would put that through an f64 and keep whatever digits came back out; this
4//! reads the text the venue wrote instead.
5
6use std::fmt;
7
8use serde::{Deserialize, Deserializer, de};
9use serde_json::value::RawValue;
10
11use crate::utils::dec::Dec;
12
13/// A number exactly as the venue wrote it, float or string. No f64 holds it.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct Figure(String);
16
17impl Figure {
18    /// The digits as written: `0.037456007367970744`, `1e-7`, `0.0`.
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23
24    /// The value, an exponent applied exactly. Past eighteen places it is the
25    /// nearest at eighteen; `None` past what a [`Dec`] counts.
26    #[must_use]
27    pub fn to_dec(&self) -> Option<Dec> {
28        let (mantissa, exponent) = match self.0.split_once(['e', 'E']) {
29            Some((mantissa, exponent)) => (mantissa, exponent.parse::<i32>().ok()?),
30            None => (self.0.as_str(), 0),
31        };
32        let mut value = Dec::parse(mantissa).ok()?;
33        let mut shift = exponent.unsigned_abs();
34        while shift > 0 {
35            let step = shift.min(18);
36            let factor = match exponent < 0 {
37                true => Dec::new(1, step)?,
38                false => Dec::new(10i128.checked_pow(step)?, 0)?,
39            };
40            value = value.checked_mul(factor)?;
41            shift -= step;
42        }
43        Some(value)
44    }
45
46    fn read(text: &str) -> Option<Figure> {
47        let plain = |text: &str| {
48            !text.is_empty()
49                && text.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E'))
50        };
51        match text.strip_prefix('"') {
52            Some(_) => serde_json::from_str::<String>(text).ok().filter(|inner| plain(inner)).map(Figure),
53            None => plain(text).then(|| Figure(text.to_owned())),
54        }
55    }
56}
57
58impl fmt::Display for Figure {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(&self.0)
61    }
62}
63
64/// From the raw JSON text, so it reads only through `serde_json::from_str`
65/// (which is how `info` reads), not through a `serde_json::Value`.
66impl<'de> Deserialize<'de> for Figure {
67    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
68        let raw = Box::<RawValue>::deserialize(deserializer)?;
69        Figure::read(raw.get().trim()).ok_or_else(|| de::Error::custom(format!("`{}` is not a number", raw.get())))
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use serde::Deserialize;
76
77    use super::*;
78
79    #[derive(Debug, Deserialize)]
80    struct Vault {
81        apr: Figure,
82        #[serde(default)]
83        commission: Option<Figure>,
84    }
85
86    /// Live, mainnet HLP, 2026-10-06: eighteen places, which an f64 cannot
87    /// carry and a `Dec` can.
88    #[test]
89    fn a_float_keeps_every_digit_it_was_written_with() {
90        let vault: Vault = serde_json::from_str(r#"{"apr":0.037569521048262375,"commission":0.0}"#).unwrap();
91        assert_eq!(vault.apr.as_str(), "0.037569521048262375");
92        assert_eq!(vault.apr.to_dec(), Dec::new(37_569_521_048_262_375, 18));
93        assert_eq!(vault.commission.and_then(|figure| figure.to_dec()), Some(Dec::ZERO));
94        let none: Vault = serde_json::from_str(r#"{"apr":"0.05","commission":null}"#).unwrap();
95        assert_eq!(none.apr.to_dec(), Dec::new(5, 2), "a decimal string reads too");
96        assert_eq!(none.commission, None);
97    }
98
99    #[test]
100    fn an_exponent_is_applied_exactly() {
101        let read = |text: &str| serde_json::from_str::<Figure>(text).unwrap().to_dec();
102        assert_eq!(read("1e-7"), Dec::new(1, 7));
103        assert_eq!(read("2.5E3"), Some(Dec::from_int(2500)));
104        assert_eq!(read("-4.2e-1"), Dec::new(-42, 2));
105        assert_eq!(read("1e-30"), Some(Dec::ZERO), "past eighteen places is the nearest at eighteen");
106        assert_eq!(read("41085245.621082"), Dec::new(41_085_245_621_082, 6));
107    }
108
109    #[test]
110    fn anything_but_a_number_is_refused() {
111        for bad in ["true", "\"abc\"", "{}", "[1]", "null", "\"\""] {
112            assert!(serde_json::from_str::<Figure>(bad).is_err(), "{bad}");
113        }
114    }
115}