Skip to main content

df_hyperliquid_core/account/
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::state::{Abstraction, PerpState, SpotRisk, SpotState};
8use crate::client::USDC_INDEX;
9use crate::utils::{Address, Dec};
10
11/// USDC's token index: the main dex's collateral, and what portfolio margin lends.
12pub const USDC: u32 = USDC_INDEX;
13
14/// The users the venue lets one IP's socket follow.
15pub const MOST_USERS: usize = 10;
16
17/// What portfolio margin lends against each eligible collateral, in
18/// hundredths, by the spot token's name (BTC is `UBTC` in spot), where its
19/// row carries no `ltv` of its own.
20pub const LTV: [(&str, i128); 2] = [("HYPE", 65), ("UBTC", 50)];
21
22/// Each perp dex's collateral token by the dex's name (`""` the main dex), as
23/// `allPerpMetas` lists `collateralToken`. A dex not listed posts USDC.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Collateral(BTreeMap<String, u32>);
26
27impl Collateral {
28    #[must_use]
29    pub fn new() -> Collateral {
30        Collateral::default()
31    }
32
33    #[must_use]
34    pub fn with(mut self, dex: &str, token: u32) -> Collateral {
35        self.0.insert(dex.to_owned(), token);
36        self
37    }
38
39    /// The token `dex` posts margin in.
40    #[must_use]
41    pub fn of(&self, dex: &str) -> u32 {
42        self.0.get(dex).copied().unwrap_or(USDC)
43    }
44
45    /// Every collateral token, USDC first.
46    #[must_use]
47    pub fn tokens(&self) -> Vec<u32> {
48        let mut tokens = vec![USDC];
49        for token in self.0.values() {
50            if !tokens.contains(token) {
51                tokens.push(*token);
52            }
53        }
54        tokens[1..].sort_unstable();
55        tokens
56    }
57}
58
59impl FromIterator<(String, u32)> for Collateral {
60    fn from_iter<I: IntoIterator<Item = (String, u32)>>(iter: I) -> Collateral {
61        Collateral(iter.into_iter().collect())
62    }
63}
64
65/// Where an order's money comes from: the kind of market it is on.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Surface<'a> {
68    /// A perp dex, `""` the main one.
69    Perps { dex: &'a str },
70    /// A spot pair quoted in this token.
71    Spot { quote: u32 },
72    /// HIP-4 outcomes, quoted in USDC and settled on spot.
73    Outcomes,
74}
75
76impl Surface<'_> {
77    /// The token an order here is paid in.
78    #[must_use]
79    pub fn token(&self, collateral: &Collateral) -> u32 {
80        match self {
81            Surface::Perps { dex } => collateral.of(dex),
82            Surface::Spot { quote } => *quote,
83            Surface::Outcomes => USDC,
84        }
85    }
86}
87
88/// How an account keeps its balances, from its mode.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum Keeping {
91    /// Standard, a mode this build does not know, or none read: each perp
92    /// dex and spot keep their own.
93    Apart,
94    /// DEX abstraction: as standard, but every USDC dex draws on the main dex.
95    Native,
96    /// Unified: one balance per asset, held in spot.
97    Unified,
98    /// Portfolio margin: unified, and an order may borrow.
99    Portfolio,
100}
101
102impl Keeping {
103    /// From the mode read, or, with none read, from the spot answer's own
104    /// portfolio-margin flag.
105    #[must_use]
106    pub fn of(mode: Option<&Abstraction>, spot: Option<&SpotState>) -> Keeping {
107        match mode {
108            Some(Abstraction::UnifiedAccount) => Keeping::Unified,
109            Some(Abstraction::PortfolioMargin) => Keeping::Portfolio,
110            Some(Abstraction::DexAbstraction) => Keeping::Native,
111            Some(_) => Keeping::Apart,
112            None if spot.is_some_and(|spot| spot.portfolio_margin_enabled == Some(true)) => Keeping::Portfolio,
113            None => Keeping::Apart,
114        }
115    }
116
117    /// Whether perps and spot draw on one balance.
118    #[must_use]
119    pub const fn pools(self) -> bool {
120        matches!(self, Keeping::Unified | Keeping::Portfolio)
121    }
122
123    /// The balance a movement of USDC spends.
124    #[must_use]
125    pub const fn draws(self, movement: Move) -> Draws {
126        if self.pools() {
127            return Draws::Pooled;
128        }
129        match movement {
130            Move::ClassTransfer { to_perp: true } => Draws::Spot,
131            Move::ClassTransfer { to_perp: false } | Move::Send | Move::Withdraw => Draws::Perp,
132        }
133    }
134
135    /// Why a movement is refused for this account, when it is: nothing moves
136    /// between the perps and spot of an account that keeps one balance.
137    #[must_use]
138    pub fn refuses(self, movement: Move) -> Option<String> {
139        let account = match (self, movement) {
140            (Keeping::Unified, Move::ClassTransfer { .. }) => "a unified account",
141            (Keeping::Portfolio, Move::ClassTransfer { .. }) => "a portfolio margin account",
142            _ => return None,
143        };
144        Some(format!("{account} holds USDC in one balance, so there is nothing to move between perps and spot"))
145    }
146}
147
148/// A movement of the account's USDC.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
150pub enum Move {
151    /// `usdClassTransfer`: between the account's own perps and spot.
152    ClassTransfer { to_perp: bool },
153    /// `usdSend`: to another account on the venue.
154    Send,
155    /// `withdraw3`: off the venue, to Arbitrum.
156    Withdraw,
157}
158
159/// The balance a movement spends.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum Draws {
162    /// Spot USDC less its hold.
163    Spot,
164    /// What the main perp dex can move out.
165    Perp,
166    /// A unified or portfolio-margin account's one balance, held in spot.
167    Pooled,
168}
169
170/// What a balance pays for.
171#[derive(Debug, Clone, PartialEq, Eq, Hash)]
172pub enum Pays {
173    /// One perp dex's orders, `""` the main dex.
174    Perps { dex: String },
175    /// DEX abstraction: the main dex's USDC, for every USDC dex.
176    UsdcPerps,
177    /// Spot and outcome orders.
178    Spot,
179    /// A pooled account's one balance of a token: every market settled in it.
180    Any,
181}
182
183/// One balance an order can draw on: a perp dex's margin, a spot token, or
184/// a pooled account's one balance of a token.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Balance {
187    pub token: u32,
188    /// A dex's account value, or a spot token's total.
189    pub total: Dec,
190    /// Free to trade or move: a dex's `withdrawable`, spot's total less its hold.
191    pub available: Dec,
192    pub pays: Pays,
193}
194
195/// The balance an order on `surface` draws on, or `None` when the account
196/// holds none that pays for it.
197#[must_use]
198pub fn payer<'a>(balances: &'a [Balance], surface: Surface<'_>, collateral: &Collateral) -> Option<&'a Balance> {
199    let token = surface.token(collateral);
200    balances.iter().find(|balance| {
201        balance.token == token
202            && match (&balance.pays, surface) {
203                (Pays::Any, _) => true,
204                (Pays::Spot, Surface::Spot { .. } | Surface::Outcomes) => true,
205                (Pays::Perps { dex }, Surface::Perps { dex: wanted }) => dex == wanted,
206                (Pays::UsdcPerps, Surface::Perps { .. }) => token == USDC,
207                _ => false,
208            }
209    })
210}
211
212/// Where usable money sits; [`Source::words`] names it for display.
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214pub enum Source {
215    /// One perp dex's own margin.
216    Perps {
217        dex: String,
218    },
219    /// DEX abstraction: the main dex's USDC, for a HIP-3 USDC dex.
220    NativePerps,
221    Spot,
222    Unified,
223    PortfolioMargin,
224}
225
226impl Source {
227    #[must_use]
228    pub fn words(&self) -> String {
229        match self {
230            Source::Perps { dex } if dex.is_empty() => "perps".to_owned(),
231            Source::Perps { dex } => format!("perps on {dex}"),
232            Source::NativePerps => "perps, the main dex's USDC".to_owned(),
233            Source::Spot => "spot".to_owned(),
234            Source::Unified => "unified, in spot".to_owned(),
235            Source::PortfolioMargin => "portfolio margin".to_owned(),
236        }
237    }
238}
239
240/// What an account can put on a surface, and where that sits.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct Usable {
243    pub amount: Dec,
244    /// The token it is counted in.
245    pub token: u32,
246    pub from: Source,
247    /// Portfolio margin: the part of `amount` that would be borrowed.
248    pub borrowable: Dec,
249}
250
251/// One perp dex's clearinghouse, by the dex's name.
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct DexState {
254    /// `""` for the main dex.
255    pub dex: String,
256    pub state: PerpState,
257}
258
259/// One account as last read: its mode, each dex it was read on, and spot.
260#[derive(Debug, Clone, Default, PartialEq, Eq)]
261pub struct Standing {
262    /// `None` until a read or the socket says.
263    pub mode: Option<Abstraction>,
264    pub dexes: Vec<DexState>,
265    pub spot: Option<SpotState>,
266    /// When it was last told anything, in milliseconds.
267    pub heard: Option<u64>,
268}
269
270impl Standing {
271    #[must_use]
272    pub fn dex(&self, dex: &str) -> Option<&PerpState> {
273        self.dexes.iter().find(|state| state.dex == dex).map(|state| &state.state)
274    }
275
276    #[must_use]
277    pub fn keeping(&self) -> Keeping {
278        Keeping::of(self.mode.as_ref(), self.spot.as_ref())
279    }
280
281    /// Whether perp margin is drawn from spot, so a dex's value is already
282    /// inside the spot balances.
283    #[must_use]
284    pub fn pools(&self) -> bool {
285        self.keeping().pools()
286    }
287
288    /// A spot token less what orders hold of it, never below nothing.
289    #[must_use]
290    pub fn free(&self, token: u32) -> Dec {
291        self.spot.as_ref().map_or(Dec::ZERO, |spot| spot.free(token))
292    }
293
294    /// Every balance an order can draw on, by what it pays for. Spot counts
295    /// USDC and the dexes' collateral tokens: USDC always, another only when
296    /// held. Any other token is a holding, not a balance
297    /// ([`crate::account::worth::holdings`]).
298    #[must_use]
299    pub fn balances(&self, collateral: &Collateral) -> Vec<Balance> {
300        let keeping = self.keeping();
301        let mut out = Vec::new();
302        if !keeping.pools() {
303            for dex in &self.dexes {
304                let token = collateral.of(&dex.dex);
305                let pays = match (keeping, dex.dex.is_empty(), token == USDC) {
306                    (Keeping::Native, true, _) => Pays::UsdcPerps,
307                    (Keeping::Native, false, true) => continue,
308                    _ => Pays::Perps { dex: dex.dex.clone() },
309                };
310                let summary = &dex.state.margin_summary;
311                out.push(Balance { token, total: summary.account_value, available: dex.state.withdrawable, pays });
312            }
313        }
314        let pays = match keeping.pools() {
315            true => Pays::Any,
316            false => Pays::Spot,
317        };
318        let counted = collateral.tokens();
319        let rows = self.spot.iter().flat_map(|spot| &spot.balances).filter(|row| !row.is_outcome());
320        let mut usdc = false;
321        for row in rows {
322            let Some(token) = row.token.filter(|token| counted.contains(token)) else { continue };
323            let available = row.available().unwrap_or(Dec::ZERO);
324            if token != USDC && row.total.is_zero() && available.is_zero() {
325                continue;
326            }
327            usdc |= token == USDC;
328            out.push(Balance { token, total: row.total, available, pays: pays.clone() });
329        }
330        if !usdc {
331            let none = Balance { token: USDC, total: Dec::ZERO, available: Dec::ZERO, pays };
332            let at = out.iter().position(|balance| balance.pays == Pays::Spot || balance.pays == Pays::Any);
333            out.insert(at.unwrap_or(out.len()), none);
334        }
335        out
336    }
337
338    /// What this account can put on `surface`. `price` is a spot token's
339    /// price in USDC by its name, which only portfolio margin's lending reads.
340    /// A figure past what a `Dec` counts reads as nothing, never as more.
341    #[must_use]
342    pub fn usable(&self, surface: Surface<'_>, collateral: &Collateral, price: impl Fn(&str) -> Option<Dec>) -> Usable {
343        let keeping = self.keeping();
344        let token = surface.token(collateral);
345        let balances = self.balances(collateral);
346        let paying = payer(&balances, surface, collateral);
347        let from = match (keeping, paying.map(|balance| &balance.pays), surface) {
348            (Keeping::Unified, _, _) => Source::Unified,
349            (Keeping::Portfolio, _, _) => Source::PortfolioMargin,
350            (_, Some(Pays::UsdcPerps), Surface::Perps { dex }) if !dex.is_empty() => Source::NativePerps,
351            (_, _, Surface::Perps { dex }) => Source::Perps { dex: dex.to_owned() },
352            (_, _, Surface::Spot { .. } | Surface::Outcomes) => Source::Spot,
353        };
354        let free = paying.map_or(Dec::ZERO, |balance| balance.available);
355        let borrowable = match (keeping, token == USDC) {
356            (Keeping::Portfolio, true) => self.borrowable(&price),
357            _ => Dec::ZERO,
358        };
359        let amount = free.checked_add(borrowable).unwrap_or(free);
360        Usable { amount, token, from, borrowable }
361    }
362
363    /// What portfolio margin would lend in USDC now: each eligible token's total at `price` and its
364    /// loan-to-value (the row's own `ltv`, else [`LTV`]), less what is owed already, at most what USDC
365    /// has free after maintenance where the venue says, and nothing once liquidatable. Nothing on any
366    /// other mode.
367    #[must_use]
368    pub fn borrowable(&self, price: &impl Fn(&str) -> Option<Dec>) -> Dec {
369        let Some(spot) = self.spot.as_ref().filter(|_| self.keeping() == Keeping::Portfolio) else {
370            return Dec::ZERO;
371        };
372        if spot.risk() == crate::account::state::Risk::Liquidatable {
373            return Dec::ZERO;
374        }
375        let rows = || spot.balances.iter().filter(|row| !row.is_outcome());
376        let room = rows()
377            .filter(|row| row.token != Some(USDC))
378            .filter_map(|row| {
379                let ltv = row.ltv.or_else(|| {
380                    LTV.iter().find(|(coin, _)| *coin == row.coin).map(|(_, hundredths)| Dec::lit::<2>(*hundredths))
381                })?;
382                row.total.checked_mul(price(&row.coin)?)?.checked_mul(ltv).filter(|lent| lent.is_positive())
383            })
384            .fold(Dec::ZERO, |sum, lent| sum.checked_add(lent).unwrap_or(sum));
385        let owed =
386            rows().filter_map(|row| row.borrowed).fold(Dec::ZERO, |sum, owed| sum.checked_add(owed).unwrap_or(sum));
387        let mut out = room.checked_sub(owed).unwrap_or(Dec::ZERO).max(Dec::ZERO);
388        if let Some(after) = spot.available_after_maintenance(USDC) {
389            out = out.min(after.max(Dec::ZERO));
390        }
391        out
392    }
393}
394
395/// Every account followed at one moment: the connected one and its
396/// subaccounts, each as last told, at most [`MOST_USERS`].
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
398pub struct Standings {
399    by: BTreeMap<Address, Standing>,
400    /// The connected account first.
401    followed: Vec<Address>,
402}
403
404impl Standings {
405    #[must_use]
406    pub fn of(&self, address: Address) -> Option<&Standing> {
407        self.by.get(&address)
408    }
409
410    #[must_use]
411    pub fn followed(&self) -> &[Address] {
412        &self.followed
413    }
414
415    /// Follows these users, the first kept first, at most [`MOST_USERS`]. One
416    /// no longer followed is let go. Says whether anything changed.
417    pub fn follow(&mut self, users: &[Address]) -> bool {
418        let mut wanted: Vec<Address> = Vec::new();
419        for user in users {
420            if !wanted.contains(user) && wanted.len() < MOST_USERS {
421                wanted.push(*user);
422            }
423        }
424        if wanted == self.followed {
425            return false;
426        }
427        self.by.retain(|address, _| wanted.contains(address));
428        self.followed = wanted;
429        true
430    }
431
432    /// Changes what is known of `user`, heard at `now` milliseconds. A user
433    /// not followed is not kept; says whether it was.
434    pub fn accept(&mut self, user: Address, now: u64, change: impl FnOnce(&mut Standing)) -> bool {
435        if !self.followed.contains(&user) {
436            return false;
437        }
438        let standing = self.by.entry(user).or_default();
439        change(standing);
440        standing.heard = Some(now);
441        true
442    }
443
444    /// The mode, when something said one; saying none keeps what was known.
445    pub fn accept_mode(&mut self, user: Address, mode: Option<Abstraction>, now: u64) {
446        if let Some(mode) = mode {
447            self.accept(user, now, |standing| standing.mode = Some(mode));
448        }
449    }
450}
451
452#[cfg(test)]
453#[path = "tests/usable.rs"]
454mod tests;