Skip to main content

df_hyperliquid_core/account/
clearinghouse.rs

1//! What an account holds on one perp dex and in spot, the same shape whether `/info` answers it,
2//! `subAccounts` carries it or the socket sends it. Each figure is the venue's decimal string,
3//! read exactly.
4
5use serde::Deserialize;
6
7use crate::client::network::USDC_INDEX;
8use crate::markets::side::Side;
9use crate::utils::address::Address;
10use crate::utils::dec::{Dec, Round};
11
12/// Places [`Position::mark`] is kept to: past a price's, so it only ever rounds a quotient that
13/// does not end.
14const MARK_PLACES: u32 = 12;
15
16/// How a position is margined.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum MarginMode {
20    /// Shares the account's perp balance.
21    Cross,
22    /// Margined on its own.
23    Isolated,
24}
25
26/// One dex's clearinghouse for one account. On a unified or portfolio-margin account the figures
27/// are not a balance of their own: the money is in spot. A position the account has closed can
28/// stay listed at size zero; [`Clearinghouse::positions`] leaves it out.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Clearinghouse {
32    /// Every position, cross and isolated.
33    pub margin_summary: MarginSummary,
34    /// The cross positions alone.
35    pub cross_margin_summary: MarginSummary,
36    pub cross_maintenance_margin_used: Dec,
37    /// What can leave this dex's balance now.
38    pub withdrawable: Dec,
39    pub asset_positions: Vec<AssetPosition>,
40    /// When the venue read it, in milliseconds; not always sent.
41    #[serde(default)]
42    pub time: Option<u64>,
43}
44
45impl Clearinghouse {
46    /// Every open position: the listed ones of size zero left out.
47    pub fn positions(&self) -> impl Iterator<Item = &Position> {
48        self.asset_positions.iter().map(|entry| &entry.position).filter(|position| !position.szi.is_zero())
49    }
50
51    /// An open position by its coin: `BTC`, or `xyz:TSLA` on a HIP-3 dex.
52    #[must_use]
53    pub fn position(&self, coin: &str) -> Option<&Position> {
54        self.positions().find(|position| position.coin == coin)
55    }
56
57    /// Whether there is margin or a position here: a dex worth reading again.
58    #[must_use]
59    pub fn holds_anything(&self) -> bool {
60        !self.margin_summary.account_value.is_zero() || self.positions().next().is_some()
61    }
62
63    /// Same as [`Clearinghouse::holds_anything`].
64    #[must_use]
65    pub fn is_used(&self) -> bool {
66        self.holds_anything()
67    }
68}
69
70/// What a set of positions is worth and uses, in the dex's collateral.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct MarginSummary {
74    pub account_value: Dec,
75    /// The positions' notional, every one counted as positive.
76    pub total_ntl_pos: Dec,
77    /// The collateral before the positions' value: the account value less what the positions
78    /// are worth.
79    pub total_raw_usd: Dec,
80    pub total_margin_used: Dec,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
84pub struct AssetPosition {
85    /// `oneWay`: the venue nets a coin's longs and shorts into one position.
86    #[serde(rename = "type")]
87    pub kind: String,
88    pub position: Position,
89}
90
91/// One position.
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct Position {
95    /// `BTC` on the main dex, `xyz:XYZ100` on a HIP-3 one.
96    pub coin: String,
97    /// Signed: negative is short.
98    pub szi: Dec,
99    pub leverage: Leverage,
100    /// The average entry; `null` on a listed position of size zero.
101    #[serde(default)]
102    pub entry_px: Option<Dec>,
103    /// The size at the mark, positive either way.
104    pub position_value: Dec,
105    pub unrealized_pnl: Dec,
106    /// The unrealized profit over the margin at entry; not always sent.
107    #[serde(default)]
108    pub return_on_equity: Option<Dec>,
109    /// `null` where no move of the price liquidates it.
110    #[serde(default)]
111    pub liquidation_px: Option<Dec>,
112    pub margin_used: Dec,
113    /// The most this account may take on this coin now.
114    pub max_leverage: u32,
115    /// Funding on the position; not always sent.
116    #[serde(default)]
117    pub cum_funding: Option<CumFunding>,
118}
119
120impl Position {
121    #[must_use]
122    pub const fn is_long(&self) -> bool {
123        self.szi.is_positive()
124    }
125
126    #[must_use]
127    pub const fn is_short(&self) -> bool {
128        self.szi.is_negative()
129    }
130
131    /// The size, positive either way.
132    #[must_use]
133    pub const fn size(&self) -> Dec {
134        self.szi.abs()
135    }
136
137    /// The side an order closing it takes: a sell for a long, a buy for a short; `None` at
138    /// size zero.
139    #[must_use]
140    pub const fn closing_side(&self) -> Option<Side> {
141        if self.szi.is_positive() {
142            Some(Side::Sell)
143        } else if self.szi.is_negative() {
144            Some(Side::Buy)
145        } else {
146            None
147        }
148    }
149
150    /// The mark the venue valued it at: its value over its size. `None` at size zero.
151    #[must_use]
152    pub fn mark(&self) -> Option<Dec> {
153        self.position_value.checked_div(self.size(), MARK_PLACES, Round::Nearest)
154    }
155}
156
157/// How a position is margined, and at what leverage; or what an account has set on a coin
158/// (`activeAssetData`). An isolated one always carries its `rawUsd`: one without it is refused,
159/// never read as none put up.
160#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
161#[serde(tag = "type", rename_all = "camelCase")]
162pub enum Leverage {
163    Cross {
164        value: u32,
165    },
166    /// `rawUsd` is the collateral put up less the notional bought (so negative for a long):
167    /// what the position uses is its value plus it.
168    Isolated {
169        value: u32,
170        #[serde(rename = "rawUsd")]
171        raw_usd: Dec,
172    },
173}
174
175impl Leverage {
176    #[must_use]
177    pub const fn value(&self) -> u32 {
178        match self {
179            Leverage::Cross { value } | Leverage::Isolated { value, .. } => *value,
180        }
181    }
182
183    #[must_use]
184    pub const fn mode(&self) -> MarginMode {
185        match self {
186            Leverage::Cross { .. } => MarginMode::Cross,
187            Leverage::Isolated { .. } => MarginMode::Isolated,
188        }
189    }
190
191    /// An isolated position's `rawUsd`.
192    #[must_use]
193    pub const fn raw_usd(&self) -> Option<Dec> {
194        match self {
195            Leverage::Cross { .. } => None,
196            Leverage::Isolated { raw_usd, .. } => Some(*raw_usd),
197        }
198    }
199}
200
201/// `activeAssetData`: an account on one perp, with or without a position there: the leverage in
202/// force and what an order on each side may take. The venue counts these itself, in every
203/// account mode.
204#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct ActiveAssetData {
207    pub user: Address,
208    pub coin: String,
209    /// What an order here would be margined at.
210    pub leverage: Leverage,
211    /// The largest size an order may be, buy then sell, at the leverage over the mark.
212    pub max_trade_szs: [Dec; 2],
213    /// What an order may draw on, in the collateral, buy then sell.
214    pub available_to_trade: [Dec; 2],
215    pub mark_px: Dec,
216}
217
218impl ActiveAssetData {
219    /// The largest size an order on `side` may be.
220    #[must_use]
221    pub const fn max_size(&self, side: Side) -> Dec {
222        self.max_trade_szs[at(side)]
223    }
224
225    /// What an order on `side` may draw on.
226    #[must_use]
227    pub const fn available(&self, side: Side) -> Dec {
228        self.available_to_trade[at(side)]
229    }
230}
231
232/// Buy then sell, as the venue lists both sides.
233const fn at(side: Side) -> usize {
234    match side {
235        Side::Buy => 0,
236        Side::Sell => 1,
237    }
238}
239
240/// Funding on one position: positive is paid, negative received.
241#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct CumFunding {
244    /// Since the account first held the coin.
245    pub all_time: Dec,
246    /// Since this position opened.
247    pub since_open: Dec,
248    /// Since its size last changed.
249    pub since_change: Dec,
250}
251
252/// `spotClearinghouseState`: the authoritative holdings, outcome sides among them (`+14720`, with
253/// no token). On a unified or portfolio-margin account every balance and hold of every dex is
254/// here.
255#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct SpotState {
258    pub balances: Vec<SpotBalance>,
259    /// Said on a portfolio-margin account's answer alone: `None` on every other answer and frame,
260    /// where it is not said either way.
261    #[serde(default)]
262    pub portfolio_margin_enabled: Option<bool>,
263    /// Portfolio margin: the maintenance requirement over the liquidation value. Past 0.95 the
264    /// account is liquidated.
265    #[serde(default)]
266    pub portfolio_margin_ratio: Option<Dec>,
267    /// Pooled accounts: what each token, by index, has free above maintenance; none listed on a
268    /// standard account.
269    #[serde(default)]
270    pub token_to_available_after_maintenance: Vec<(u32, Dec)>,
271}
272
273impl SpotState {
274    #[must_use]
275    pub fn balance(&self, token: u32) -> Option<&SpotBalance> {
276        self.balances.iter().find(|balance| balance.token == Some(token) && !balance.is_outcome())
277    }
278
279    /// Spot USDC, by index rather than name. An account that never held any has no row.
280    #[must_use]
281    pub fn usdc(&self) -> Option<&SpotBalance> {
282        self.balance(USDC_INDEX)
283    }
284
285    /// Every row with something in it, tokens and outcome sides alike, the largest total first: what
286    /// the venue says is held, which is the authoritative answer.
287    pub fn held(&self) -> impl Iterator<Item = &SpotBalance> {
288        let mut held: Vec<&SpotBalance> = self.balances.iter().filter(|row| !row.total.is_zero()).collect();
289        held.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.coin.cmp(&b.coin)));
290        held.into_iter()
291    }
292
293    /// [`SpotBalance::available`] of a token: nothing for a token never held, and nothing, never
294    /// more, past what a `Dec` counts.
295    #[must_use]
296    pub fn free(&self, token: u32) -> Dec {
297        self.balance(token).and_then(SpotBalance::available).unwrap_or(Dec::ZERO)
298    }
299
300    /// What a pooled account's token has free above maintenance, where the venue says.
301    #[must_use]
302    pub fn available_after_maintenance(&self, token: u32) -> Option<Dec> {
303        self.token_to_available_after_maintenance.iter().find(|(held, _)| *held == token).map(|(_, free)| *free)
304    }
305}
306
307/// One token's row. `total` already includes `hold`.
308#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct SpotBalance {
311    pub coin: String,
312    /// The token's index, a number here, not the `NAME:0x..` a transfer names. HIP-4 rows
313    /// (`+92260`, `o458`) carry none.
314    #[serde(default)]
315    pub token: Option<u32>,
316    pub total: Dec,
317    /// What open orders and margin hold. On a portfolio-margin account it can be negative
318    /// (`-268.81821068` against a `total` of `0.55817531`), so it is never subtracted as it stands:
319    /// see [`SpotBalance::available`].
320    pub hold: Dec,
321    /// What it cost.
322    pub entry_ntl: Dec,
323    /// Portfolio margin: what spot orders hold.
324    #[serde(default)]
325    pub spot_hold: Option<Dec>,
326    /// Portfolio margin: owed on this token.
327    #[serde(default)]
328    pub borrowed: Option<Dec>,
329    /// Portfolio margin: supplied as collateral or to lending. Part of `total`.
330    #[serde(default)]
331    pub supplied: Option<Dec>,
332    /// Portfolio margin: the loan-to-value this token counts at.
333    #[serde(default)]
334    pub ltv: Option<Dec>,
335}
336
337impl SpotBalance {
338    /// What is free to move: `total` less the larger positive hold (`hold` or `spotHold`), never
339    /// below zero and never above `total`. `None` only past what a `Dec` counts.
340    #[must_use]
341    pub fn available(&self) -> Option<Dec> {
342        let held = [Some(self.hold), self.spot_hold].into_iter().flatten().filter(|h| h.is_positive()).max();
343        let free = self.total.checked_sub(held.unwrap_or(Dec::ZERO))?;
344        Some(free.max(Dec::ZERO).min(self.total.max(Dec::ZERO)))
345    }
346
347    /// A HIP-4 row: a side (`+92260`, `#92260`) or a question (`o458`). Never collateral, and
348    /// never a token a transfer names.
349    #[must_use]
350    pub fn is_outcome(&self) -> bool {
351        let coin = self.coin.as_str();
352        coin.starts_with(['+', '#'])
353            || coin.strip_prefix('o').is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
354    }
355}