Skip to main content

df_hyperliquid_core/account/
worth.rs

1//! What an account is worth, counted once, and what it holds beside the balances its orders draw
2//! on. A pooled account holds its perp equity inside spot USDC, so its perps are taken out of spot;
3//! lending moves money between slices, never the total.
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use crate::account::state::{Risk, SpotState};
8use crate::account::usable::{Collateral, Standing, USDC};
9use crate::utils::{Address, Dec};
10
11/// What the HYPE staked is staked in.
12const STAKED: &str = "HYPE";
13
14/// Prices in USDC by the name an account's rows carry.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct Prices(BTreeMap<String, Dec>);
17
18impl Prices {
19    #[must_use]
20    pub fn new() -> Prices {
21        Prices::default()
22    }
23
24    /// One price, by a token's name (`HYPE`) or a mid's (`#14730`).
25    #[must_use]
26    pub fn with(mut self, name: &str, price: Dec) -> Prices {
27        self.0.insert(name.to_owned(), price);
28        self
29    }
30
31    /// From each token's USDC pair (`HYPE` is `@107`, as `spotMeta` lists the
32    /// pairs) and the first dex's mids, exact (`allMids`, read or sent): a
33    /// token at its pair's mid, and an outcome side's mid (`#14730`) as it is
34    /// quoted. A mid of nothing is no price.
35    #[must_use]
36    pub fn of(pairs: &BTreeMap<String, String>, mids: &BTreeMap<String, Dec>) -> Prices {
37        let priced = |mid: &Dec| Some(*mid).filter(|price| price.is_positive());
38        let mut prices: BTreeMap<String, Dec> = mids
39            .iter()
40            .filter(|(name, _)| name.starts_with('#'))
41            .filter_map(|(name, mid)| Some((name.clone(), priced(mid)?)))
42            .collect();
43        for (token, pair) in pairs {
44            if let Some(price) = mids.get(pair).and_then(priced) {
45                prices.insert(token.clone(), price);
46            }
47        }
48        Prices(prices)
49    }
50
51    /// A row's price: one for USDC, an outcome side `+N` at the mid quoted as
52    /// `#N`, any other token at its own.
53    #[must_use]
54    pub fn price(&self, coin: &str) -> Option<Dec> {
55        if coin == "USDC" {
56            return Some(Dec::ONE);
57        }
58        match coin.strip_prefix('+') {
59            Some(side) => self.0.get(&format!("#{side}")).copied(),
60            None => self.0.get(coin).copied(),
61        }
62    }
63}
64
65/// What an account holds in staking, vaults and lending, as [`crate::earn`] reads it.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct Beside {
68    /// HYPE staked: delegated, not delegated, and on its way out (`delegatorSummary`).
69    pub staked: Dec,
70    /// Each vault deposit (`userVaultEquities`).
71    pub vaults: Vec<VaultShare>,
72    /// Each token lent and borrowed (`borrowLendUserState`).
73    pub lending: Vec<Reserve>,
74    pub health: Option<LendingHealth>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct VaultShare {
79    pub vault: Address,
80    /// In USDC.
81    pub equity: Dec,
82}
83
84/// One token's lending.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct Reserve {
87    pub token: u32,
88    pub supplied: Dec,
89    pub borrowed: Dec,
90}
91
92/// The lending health as `borrowLendUserState` reports it.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct LendingHealth {
95    /// `healthy`, `atRisk`, `liquidatable`, as the venue words it.
96    pub word: String,
97    pub factor: Option<Dec>,
98}
99
100impl Beside {
101    /// The health worth showing: while something is borrowed (a factor), or
102    /// in any word but `healthy`. A word not seen yet is a warning.
103    #[must_use]
104    pub fn lending_health(&self) -> Option<Health> {
105        let health = self.health.as_ref()?;
106        if health.factor.is_none() && health.word == "healthy" {
107            return None;
108        }
109        let risk = match health.word.as_str() {
110            "healthy" => Risk::Safe,
111            "liquidatable" => Risk::Liquidatable,
112            _ => Risk::Near,
113        };
114        Some(Health { factor: health.factor, risk, word: capitals(&health.word) })
115    }
116}
117
118/// `atRisk` as `AT RISK`.
119fn capitals(camel: &str) -> String {
120    let mut out = String::new();
121    for letter in camel.chars() {
122        if letter.is_ascii_uppercase() && !out.is_empty() {
123            out.push(' ');
124        }
125        out.push(letter.to_ascii_uppercase());
126    }
127    out
128}
129
130/// What an account is worth, by where it sits.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub struct Worth {
133    /// Every perp dex's account value.
134    pub perps: Dec,
135    /// Spot at mids, less what is lent, and less the perps on a pooled
136    /// account, whose perps are inside it.
137    pub spot: Dec,
138    /// Lent less borrowed.
139    pub lending: Dec,
140    /// HIP-4 sides at their mids.
141    pub outcomes: Dec,
142    pub staking: Dec,
143    pub vaults: Dec,
144    /// Margin in use across the dexes; part of `perps`, not added.
145    pub margin: Dec,
146}
147
148impl Worth {
149    /// Everything but `margin`. `None` only past what a `Dec` counts.
150    #[must_use]
151    pub fn total(&self) -> Option<Dec> {
152        Dec::checked_sum([self.perps, self.spot, self.lending, self.outcomes, self.staking, self.vaults])
153    }
154
155    #[must_use]
156    pub fn plus(&self, other: &Worth) -> Option<Worth> {
157        Some(Worth {
158            perps: self.perps.checked_add(other.perps)?,
159            spot: self.spot.checked_add(other.spot)?,
160            lending: self.lending.checked_add(other.lending)?,
161            outcomes: self.outcomes.checked_add(other.outcomes)?,
162            staking: self.staking.checked_add(other.staking)?,
163            vaults: self.vaults.checked_add(other.vaults)?,
164            margin: self.margin.checked_add(other.margin)?,
165        })
166    }
167}
168
169impl Standing {
170    /// What this account is worth at `prices`, counted once. A row with no
171    /// price is left out. `None` only past what a `Dec` counts.
172    #[must_use]
173    pub fn worth(&self, prices: &Prices, beside: &Beside) -> Option<Worth> {
174        let mut worth = Worth::default();
175        for dex in &self.dexes {
176            worth.perps = worth.perps.checked_add(dex.state.margin_summary.account_value)?;
177            worth.margin = worth.margin.checked_add(dex.state.margin_summary.total_margin_used)?;
178        }
179        let mut spot = Dec::ZERO;
180        for row in self.spot.iter().flat_map(|spot| &spot.balances) {
181            let Some(price) = prices.price(&row.coin) else { continue };
182            let value = row.total.checked_mul(price)?;
183            if row.is_outcome() {
184                worth.outcomes = worth.outcomes.checked_add(value)?;
185                continue;
186            }
187            spot = spot.checked_add(value)?;
188            let lent = row.supplied.unwrap_or(Dec::ZERO).checked_mul(price)?;
189            let owed = row.borrowed.unwrap_or(Dec::ZERO).checked_mul(price)?;
190            worth.lending = worth.lending.checked_add(lent)?.checked_sub(owed)?;
191        }
192        spot = spot.checked_sub(worth.lending)?;
193        if self.pools() {
194            spot = spot.checked_sub(worth.perps)?;
195        }
196        worth.spot = spot;
197        worth.staking = match beside.staked.is_zero() {
198            true => Dec::ZERO,
199            false => beside.staked.checked_mul(prices.price(STAKED).unwrap_or(Dec::ZERO))?,
200        };
201        worth.vaults = Dec::checked_sum(beside.vaults.iter().map(|share| share.equity))?;
202        Some(worth)
203    }
204}
205
206/// Several accounts summed, each counted once: a deposit one of them holds
207/// in a vault that is itself one of them is counted as the vault, and a
208/// deposit in a vault left out as the depositor's.
209#[must_use]
210pub fn combined(accounts: &[(Address, &Standing, &Beside)], prices: &Prices) -> Option<Worth> {
211    let listed: BTreeSet<Address> = accounts.iter().map(|(address, _, _)| *address).collect();
212    let mut worth = Worth::default();
213    for (_, standing, beside) in accounts {
214        worth = worth.plus(&standing.worth(prices, beside)?)?;
215        for share in beside.vaults.iter().filter(|share| listed.contains(&share.vault)) {
216            worth.vaults = worth.vaults.checked_sub(share.equity)?;
217        }
218    }
219    Some(worth)
220}
221
222/// An amount of one asset, and its price in USDC where one is known.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct Holding {
225    pub coin: String,
226    pub token: Option<u32>,
227    pub amount: Dec,
228    pub price: Option<Dec>,
229    /// Where it is held apart from the account: a vault's address.
230    pub place: Option<Address>,
231}
232
233/// How safe what is borrowed stands.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Health {
236    pub factor: Option<Dec>,
237    pub risk: Risk,
238    /// The venue's word in capitals: `AT RISK`.
239    pub word: String,
240}
241
242/// What an account holds beside the balances its orders draw on ([`Standing::balances`]). Its
243/// worth adds `tokens`, `staked` and `vaults`; what is lent or borrowed is already inside the
244/// balances and changes nothing.
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct Holdings {
247    /// Spot tokens no balance counts: not USDC, nor a dex's collateral.
248    pub tokens: Vec<Holding>,
249    pub staked: Vec<Holding>,
250    pub vaults: Vec<Holding>,
251    pub supplied: Vec<Holding>,
252    pub borrowed: Vec<Holding>,
253    pub health: Option<Health>,
254}
255
256impl Holdings {
257    /// Tokens, staked and vaults at their prices; one with no price is left
258    /// out. `None` only past what a `Dec` counts.
259    #[must_use]
260    pub fn worth(&self) -> Option<Dec> {
261        self.tokens.iter().chain(&self.staked).chain(&self.vaults).try_fold(Dec::ZERO, |sum, held| match held.price {
262            Some(price) => sum.checked_add(held.amount.checked_mul(price)?),
263            None => Some(sum),
264        })
265    }
266}
267
268/// What `standing` holds beside its balances, each priced. `names` is each token's name by index,
269/// as `spotMeta` lists them.
270#[must_use]
271pub fn holdings(
272    standing: &Standing,
273    collateral: &Collateral,
274    beside: &Beside,
275    names: &BTreeMap<u32, String>,
276    prices: &Prices,
277) -> Holdings {
278    let counted = collateral.tokens();
279    let held = |coin: &str, token: Option<u32>, amount: Dec| Holding {
280        coin: coin.to_owned(),
281        token,
282        amount,
283        price: prices.price(coin),
284        place: None,
285    };
286    let tokens = standing
287        .spot
288        .iter()
289        .flat_map(|spot: &SpotState| &spot.balances)
290        .filter(|row| !row.is_outcome() && !row.total.is_zero())
291        .filter(|row| row.token.is_some_and(|token| !counted.contains(&token)))
292        .map(|row| held(&row.coin, row.token, row.total))
293        .collect();
294    let index_of = |name: &str| names.iter().find(|(_, known)| known.as_str() == name).map(|(index, _)| *index);
295    let staked = match beside.staked.is_positive() {
296        true => vec![held(STAKED, index_of(STAKED), beside.staked)],
297        false => Vec::new(),
298    };
299    let vaults = beside
300        .vaults
301        .iter()
302        .filter(|share| share.equity.is_positive())
303        .map(|share| Holding { place: Some(share.vault), ..held("USDC", Some(USDC), share.equity) })
304        .collect();
305    let name = |token: u32| names.get(&token).cloned().unwrap_or_else(|| format!("#{token}"));
306    let mut supplied = Vec::new();
307    let mut borrowed = Vec::new();
308    for reserve in &beside.lending {
309        if reserve.supplied.is_positive() {
310            supplied.push(held(&name(reserve.token), Some(reserve.token), reserve.supplied));
311        }
312        if reserve.borrowed.is_positive() {
313            borrowed.push(held(&name(reserve.token), Some(reserve.token), reserve.borrowed));
314        }
315    }
316    Holdings { tokens, staked, vaults, supplied, borrowed, health: beside.lending_health() }
317}
318
319#[cfg(test)]
320#[path = "tests/worth.rs"]
321mod tests;