Skip to main content

df_hyperliquid_core/account/holdings/
usable.rs

1//! What an account can put on each kind of market, read the way its mode says, and which balance
2//! pays for an order. A pooled account's per-dex states are not a balance of their own, so they
3//! are never added to spot.
4
5use std::collections::BTreeMap;
6
7use crate::account::holdings::keeping::Keeping;
8use crate::account::holdings::spot::Risk;
9use crate::account::holdings::standing::Standing;
10use crate::client::USDC_INDEX;
11use crate::utils::Dec;
12
13/// What portfolio margin lends against each eligible collateral, in
14/// hundredths, by the spot token's name (BTC is `UBTC` in spot), where its
15/// row carries no `ltv` of its own.
16pub const LTV: [(&str, i128); 2] = [("HYPE", 65), ("UBTC", 50)];
17
18/// Each perp dex's collateral token by the dex's name (`""` the main dex), as
19/// `allPerpMetas` lists `collateralToken`. A dex not listed posts USDC.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct Collateral(BTreeMap<String, u32>);
22
23impl Collateral {
24    #[must_use]
25    pub fn new() -> Collateral {
26        Collateral::default()
27    }
28
29    #[must_use]
30    pub fn with(mut self, dex: &str, token: u32) -> Collateral {
31        self.0.insert(dex.to_owned(), token);
32        self
33    }
34
35    /// The token `dex` posts margin in.
36    #[must_use]
37    pub fn of(&self, dex: &str) -> u32 {
38        self.0.get(dex).copied().unwrap_or(USDC_INDEX)
39    }
40
41    /// Every collateral token, USDC first.
42    #[must_use]
43    pub fn tokens(&self) -> Vec<u32> {
44        let mut tokens = vec![USDC_INDEX];
45        for token in self.0.values() {
46            if !tokens.contains(token) {
47                tokens.push(*token);
48            }
49        }
50        tokens[1..].sort_unstable();
51        tokens
52    }
53}
54
55impl FromIterator<(String, u32)> for Collateral {
56    fn from_iter<I: IntoIterator<Item = (String, u32)>>(iter: I) -> Collateral {
57        Collateral(iter.into_iter().collect())
58    }
59}
60
61/// Where an order's money comes from: the kind of market it is on.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum Surface<'a> {
64    /// A perp dex, `""` the main one.
65    Perps { dex: &'a str },
66    /// A spot pair quoted in this token.
67    Spot { quote: u32 },
68    /// HIP-4 outcomes, quoted in USDC and settled on spot.
69    Outcomes,
70}
71
72impl Surface<'_> {
73    /// The token an order here is paid in.
74    #[must_use]
75    pub fn token(&self, collateral: &Collateral) -> u32 {
76        match self {
77            Surface::Perps { dex } => collateral.of(dex),
78            Surface::Spot { quote } => *quote,
79            Surface::Outcomes => USDC_INDEX,
80        }
81    }
82}
83
84/// What a balance pays for.
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86pub enum Pays {
87    /// One perp dex's orders, `""` the main dex.
88    Perps { dex: String },
89    /// DEX abstraction: the main dex's USDC, for every USDC dex.
90    UsdcPerps,
91    /// Spot and outcome orders.
92    Spot,
93    /// A pooled account's one balance of a token: every market settled in it.
94    Any,
95}
96
97/// One balance an order can draw on: a perp dex's margin, a spot token, or
98/// a pooled account's one balance of a token.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Balance {
101    pub token: u32,
102    /// A dex's account value, or a spot token's total.
103    pub total: Dec,
104    /// Free to trade or move: a dex's `withdrawable`, spot's total less its hold.
105    pub available: Dec,
106    pub pays: Pays,
107}
108
109/// The balance an order on `surface` draws on, or `None` when the account
110/// holds none that pays for it.
111#[must_use]
112pub fn payer<'a>(balances: &'a [Balance], surface: Surface<'_>, collateral: &Collateral) -> Option<&'a Balance> {
113    let token = surface.token(collateral);
114    balances.iter().find(|balance| {
115        balance.token == token
116            && match (&balance.pays, surface) {
117                (Pays::Any, _) => true,
118                (Pays::Spot, Surface::Spot { .. } | Surface::Outcomes) => true,
119                (Pays::Perps { dex }, Surface::Perps { dex: wanted }) => dex == wanted,
120                (Pays::UsdcPerps, Surface::Perps { .. }) => token == USDC_INDEX,
121                _ => false,
122            }
123    })
124}
125
126/// Where usable money sits.
127#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128pub enum Source {
129    /// One perp dex's own margin.
130    Perps {
131        dex: String,
132    },
133    /// DEX abstraction: the main dex's USDC, for a HIP-3 USDC dex.
134    NativePerps,
135    Spot,
136    Unified,
137    PortfolioMargin,
138}
139
140/// What an account can put on a surface, and where that sits.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Usable {
143    pub amount: Dec,
144    /// The token it is counted in.
145    pub token: u32,
146    pub from: Source,
147    /// Portfolio margin: the part of `amount` that would be borrowed.
148    pub borrowable: Dec,
149}
150
151impl Standing {
152    /// Every balance an order can draw on, by what it pays for. Spot counts
153    /// USDC and the dexes' collateral tokens: USDC always, another only when
154    /// held. Any other token is a holding, not a balance.
155    #[must_use]
156    pub fn balances(&self, collateral: &Collateral) -> Vec<Balance> {
157        let keeping = self.keeping();
158        let mut out = Vec::new();
159        if !keeping.pools() {
160            for dex in &self.dexes {
161                let token = collateral.of(&dex.dex);
162                let pays = match (keeping, dex.dex.is_empty(), token == USDC_INDEX) {
163                    (Keeping::Native, true, _) => Pays::UsdcPerps,
164                    (Keeping::Native, false, true) => continue,
165                    _ => Pays::Perps { dex: dex.dex.clone() },
166                };
167                let summary = &dex.state.margin_summary;
168                out.push(Balance { token, total: summary.account_value, available: dex.state.withdrawable, pays });
169            }
170        }
171        let pays = match keeping.pools() {
172            true => Pays::Any,
173            false => Pays::Spot,
174        };
175        let counted = collateral.tokens();
176        let rows = self.spot.iter().flat_map(|spot| &spot.balances).filter(|row| !row.is_outcome());
177        let mut usdc = false;
178        for row in rows {
179            let Some(token) = row.token.filter(|token| counted.contains(token)) else { continue };
180            let available = row.available().unwrap_or(Dec::ZERO);
181            if token != USDC_INDEX && row.total.is_zero() && available.is_zero() {
182                continue;
183            }
184            usdc |= token == USDC_INDEX;
185            out.push(Balance { token, total: row.total, available, pays: pays.clone() });
186        }
187        if !usdc {
188            let none = Balance { token: USDC_INDEX, total: Dec::ZERO, available: Dec::ZERO, pays };
189            let at = out.iter().position(|balance| balance.pays == Pays::Spot || balance.pays == Pays::Any);
190            out.insert(at.unwrap_or(out.len()), none);
191        }
192        out
193    }
194
195    /// What this account can put on `surface`. `price` is a spot token's
196    /// price in USDC by its name, which only portfolio margin's lending reads.
197    /// A figure past what a `Dec` counts reads as nothing, never as more.
198    #[must_use]
199    pub fn usable(&self, surface: Surface<'_>, collateral: &Collateral, price: impl Fn(&str) -> Option<Dec>) -> Usable {
200        let keeping = self.keeping();
201        let token = surface.token(collateral);
202        let balances = self.balances(collateral);
203        let paying = payer(&balances, surface, collateral);
204        let from = match (keeping, paying.map(|balance| &balance.pays), surface) {
205            (Keeping::Unified, _, _) => Source::Unified,
206            (Keeping::Portfolio, _, _) => Source::PortfolioMargin,
207            (_, Some(Pays::UsdcPerps), Surface::Perps { dex }) if !dex.is_empty() => Source::NativePerps,
208            (_, _, Surface::Perps { dex }) => Source::Perps { dex: dex.to_owned() },
209            (_, _, Surface::Spot { .. } | Surface::Outcomes) => Source::Spot,
210        };
211        let free = paying.map_or(Dec::ZERO, |balance| balance.available);
212        let borrowable = match (keeping, token == USDC_INDEX) {
213            (Keeping::Portfolio, true) => self.borrowable(&price),
214            _ => Dec::ZERO,
215        };
216        let amount = free.checked_add(borrowable).unwrap_or(free);
217        Usable { amount, token, from, borrowable }
218    }
219
220    /// What portfolio margin would lend in USDC now: each eligible token's total at `price` and its
221    /// loan-to-value (the row's own `ltv`, else [`LTV`]), less what is owed already, at most what USDC
222    /// has free after maintenance where the venue says, and nothing once liquidatable. Nothing on any
223    /// other mode.
224    #[must_use]
225    pub fn borrowable(&self, price: &impl Fn(&str) -> Option<Dec>) -> Dec {
226        let Some(spot) = self.spot.as_ref().filter(|_| self.keeping() == Keeping::Portfolio) else {
227            return Dec::ZERO;
228        };
229        if spot.risk() == Risk::Liquidatable {
230            return Dec::ZERO;
231        }
232        let rows = || spot.balances.iter().filter(|row| !row.is_outcome());
233        let room = rows()
234            .filter(|row| row.token != Some(USDC_INDEX))
235            .filter_map(|row| {
236                let ltv = row.ltv.or_else(|| {
237                    LTV.iter().find(|(coin, _)| *coin == row.coin).map(|(_, hundredths)| Dec::lit::<2>(*hundredths))
238                })?;
239                row.total.checked_mul(price(&row.coin)?)?.checked_mul(ltv).filter(|lent| lent.is_positive())
240            })
241            .fold(Dec::ZERO, |sum, lent| sum.checked_add(lent).unwrap_or(sum));
242        let owed =
243            rows().filter_map(|row| row.borrowed).fold(Dec::ZERO, |sum, owed| sum.checked_add(owed).unwrap_or(sum));
244        let mut out = room.checked_sub(owed).unwrap_or(Dec::ZERO).max(Dec::ZERO);
245        if let Some(after) = spot.available_after_maintenance(USDC_INDEX) {
246            out = out.min(after.max(Dec::ZERO));
247        }
248        out
249    }
250}
251
252#[cfg(test)]
253#[path = "tests/usable.rs"]
254mod tests;